Add lightweight standalone PDF to EPUB import and source-page QA
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"""PDF -> source-traceable, reflowable EPUB. No translation calls or heavy models."""
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from ebooklib import epub
|
||||
from .layout import paragraphs, repeated_furniture, join_text
|
||||
|
||||
SCHEMA = 1
|
||||
|
||||
|
||||
def digest(path):
|
||||
h = hashlib.sha256()
|
||||
with Path(path).open('rb') as f:
|
||||
for chunk in iter(lambda:f.read(1024*1024), b''): h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
path = Path(path); path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_suffix('.tmp'); temp.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n'); temp.replace(path)
|
||||
|
||||
|
||||
def page_selection(spec, count):
|
||||
if not spec: return list(range(count))
|
||||
selected = set()
|
||||
for item in spec.split(','):
|
||||
if not re.fullmatch(r'\d+(?:-\d+)?', item): raise ValueError('Pages must look like 1-20,25 (1-based inclusive)')
|
||||
pair = [int(x) for x in item.split('-')]; lo, hi = pair[0], pair[-1]
|
||||
if not 1 <= lo <= hi <= count: raise ValueError('Page range is outside the source PDF')
|
||||
selected.update(range(lo-1, hi))
|
||||
return sorted(selected)
|
||||
|
||||
|
||||
def native_lines(page):
|
||||
text = page.get_textpage()
|
||||
try:
|
||||
lines=[]; width,height=page.get_size()
|
||||
for i in range(text.count_rects()):
|
||||
left,bottom,right,top=text.get_rect(i)
|
||||
value=text.get_text_bounded(left,bottom,right,top).strip()
|
||||
if value:
|
||||
lines.append({'text':value,'bbox':[left/width,1-top/height,right/width,1-bottom/height], 'confidence':None})
|
||||
return lines
|
||||
finally: text.close()
|
||||
|
||||
|
||||
def image_boxes(page):
|
||||
import pypdfium2.raw as raw
|
||||
w,h=page.get_size(); boxes=[]
|
||||
for obj in page.get_objects(filter=[raw.FPDF_PAGEOBJ_IMAGE]):
|
||||
left,bottom,right,top=obj.get_bounds()
|
||||
boxes.append([max(0,left/w), max(0,1-top/h), min(1,right/w), min(1,1-bottom/h)])
|
||||
return boxes
|
||||
|
||||
|
||||
def validate_lines(lines):
|
||||
if not isinstance(lines,list): raise ValueError('OCR lines must be an array')
|
||||
for line in lines:
|
||||
box=line.get('bbox',[])
|
||||
if not isinstance(line.get('text'),str) or len(box)!=4 or not all(isinstance(v,(int,float)) and 0<=v<=1 for v in box) or box[0]>=box[2] or box[1]>=box[3]:
|
||||
raise ValueError('Invalid OCR text or normalized top-left bounding box')
|
||||
|
||||
|
||||
def apply_corrections(pages, correction, source_sha):
|
||||
"""Source-bound exact edits; never overwrite the raw OCR checkpoint."""
|
||||
if correction.get('source_sha256')!=source_sha or not isinstance(correction.get('edits'),list):
|
||||
raise ValueError('Corrections must be bound to this PDF SHA and contain an edits array')
|
||||
lines={line['id']:line for page in pages for line in page['lines']};applied=[];seen=set()
|
||||
for edit in correction['edits']:
|
||||
key=edit.get('line_id');line=lines.get(key)
|
||||
if key in seen or line is None or line['text']!=edit.get('expected') or not isinstance(edit.get('replacement'),str) or not edit['replacement'].strip():
|
||||
raise ValueError('Stale, duplicate or invalid correction: '+str(key))
|
||||
line['text']=edit['replacement'];seen.add(key);applied.append(edit)
|
||||
return applied
|
||||
|
||||
|
||||
def convert(source, output, work, language='zh-CN', title=None, author=None,
|
||||
engine='auto', pages=None, dpi=180, cover_page=None, ocr_timeout=120, image_pages=None, corrections=None):
|
||||
import pypdfium2 as pdfium
|
||||
source,output,work=Path(source),Path(output),Path(work)
|
||||
if not source.is_file(): raise ValueError('PDF source does not exist')
|
||||
if source.resolve()==output.resolve(): raise ValueError('Output must not overwrite source')
|
||||
if output.suffix.lower()!='.epub': raise ValueError('Output must have .epub suffix')
|
||||
if not 96<=dpi<=400: raise ValueError('DPI must be between 96 and 400')
|
||||
work.mkdir(parents=True,exist_ok=True); source_sha=digest(source)
|
||||
source_identity=work/'source.json'
|
||||
if source_identity.exists() and json.loads(source_identity.read_text())['sha256']!=source_sha:
|
||||
raise ValueError('Work directory belongs to another source; use a new directory')
|
||||
save_json(source_identity,{'path':str(source.resolve()),'sha256':source_sha})
|
||||
document=pdfium.PdfDocument(source); ocr=None
|
||||
try:
|
||||
selected=page_selection(pages,len(document)); partial=len(selected)!=len(document)
|
||||
preserved_images=set(i+1 for i in page_selection(image_pages,len(document))) if image_pages else set()
|
||||
if cover_page is not None and not 1<=cover_page<=len(document): raise ValueError('Cover page outside source PDF')
|
||||
backend=('vision' if platform.system()=='Darwin' else 'tesseract') if engine=='auto' else engine
|
||||
outlines=[]
|
||||
for bookmark in document.get_toc():
|
||||
dest=bookmark.get_dest()
|
||||
if dest and dest.get_index() in selected:
|
||||
outlines.append({'title':bookmark.get_title(),'page':dest.get_index()+1,'level':bookmark.level})
|
||||
results=[]; warnings=[]
|
||||
for index in selected:
|
||||
page=document[index]
|
||||
try:
|
||||
boxes=image_boxes(page)
|
||||
scan=any((b[2]-b[0])*(b[3]-b[1])>.65 for b in boxes)
|
||||
native=native_lines(page) if engine=='native' or (engine=='auto' and not scan) else []
|
||||
text_size=sum(len(l['text']) for l in native)
|
||||
use_ocr=engine not in {'native','auto'} or (engine=='auto' and (scan or (text_size==0 and bool(boxes))))
|
||||
is_cover=index+1==cover_page
|
||||
is_image=is_cover or index+1 in preserved_images
|
||||
if use_ocr and not is_image and ocr is None:
|
||||
from .ocr import VisionOCR,TesseractOCR
|
||||
ocr=(VisionOCR if backend=='vision' else TesseractOCR)(work,language,ocr_timeout)
|
||||
used='cover-image' if is_cover else 'page-image' if is_image else backend if use_ocr else 'native'
|
||||
stamp={'source':source_sha,'page':index+1,'engine':used,'ocr_identity':ocr.identity if use_ocr and not is_image else None,'language':language,'dpi':dpi,'pdfium':str(pdfium.PYPDFIUM_INFO),'schema':SCHEMA}
|
||||
cache=work/'pages'/f'{index+1:04d}.json'
|
||||
cached=json.loads(cache.read_text()) if cache.exists() else None
|
||||
render=work/'renders'/f'{index+1:04d}.jpg';render.parent.mkdir(exist_ok=True)
|
||||
if cached and cached.get('stamp')==stamp and render.exists():
|
||||
result=cached
|
||||
else:
|
||||
bitmap=page.render(scale=dpi/72)
|
||||
try: bitmap.to_pil().convert('RGB').save(render,quality=88)
|
||||
finally: bitmap.close()
|
||||
lines=[] if is_image else ocr.recognize(render) if use_ocr else native
|
||||
validate_lines(lines)
|
||||
for n,line in enumerate(lines): line['id']=f'p{index+1:04d}-l{n+1:04d}'
|
||||
result={'number':index+1,'width':page.get_width(),'height':page.get_height(),'engine':used,'scan':scan,'lines':lines,'image_boxes':boxes,'render':str(render.resolve()),'stamp':stamp}
|
||||
save_json(cache,result)
|
||||
results.append(result)
|
||||
print(f'PDF page {index+1}/{len(document)}: {used}, {len(result["lines"])} lines',file=sys.stderr,flush=True)
|
||||
finally: page.close()
|
||||
applied=apply_corrections(results,json.loads(Path(corrections).read_text()),source_sha) if corrections else []
|
||||
repeated=repeated_furniture(results)
|
||||
for page in results:
|
||||
blocks,removed=paragraphs(page,language,repeated)
|
||||
page['blocks']=blocks;page['removed_furniture']=removed
|
||||
count=sum(len(l['text']) for l in page['lines'])
|
||||
page['image_fallback']=page['engine'] in {'cover-image','page-image'} or count<30
|
||||
if page['image_fallback'] and page['engine'] not in {'cover-image','page-image'}:
|
||||
warnings.append({'page':page['number'],'kind':'page_image_fallback','detail':'Blank/illustrated/short-text page retained as an image; inspect source, not claimed as reflowed text.'})
|
||||
removed_ids={l['id'] for l in removed}
|
||||
low=[l['id'] for l in page['lines'] if l['id'] not in removed_ids and l.get('confidence') is not None and l['confidence']<.6]
|
||||
if low: warnings.append({'page':page['number'],'kind':'low_ocr_confidence','line_ids':low})
|
||||
if engine=='native' and page['scan']: warnings.append({'page':page['number'],'kind':'existing_scan_text_layer','detail':'Existing OCR text layer reused without verification.'})
|
||||
book={'schema':SCHEMA,'source_sha256':source_sha,'source':str(source.resolve()),'source_pages':len(document),'selected_pages':[i+1 for i in selected],'partial':partial,'language':language,'title':title or source.stem,'author':author or 'Unknown','outlines':outlines,'image_pages':sorted(preserved_images),'cover_page':cover_page,'pages':results,'warnings':warnings,'applied_corrections':applied,'scope':'horizontal single-column; no automatic footnote/table reconstruction'}
|
||||
save_json(work/'book.json',book)
|
||||
export_epub(book,output,work,cover_page)
|
||||
from src.qa.epub_audit import audit_epub
|
||||
audit=audit_epub(output)
|
||||
report={'source_sha256':source_sha,'output_sha256':digest(output),'output':str(output.resolve()),'partial':partial,'processed_pages':len(results),'source_pages':len(document),'image_only_pages':[p['number'] for p in results if p['image_fallback']],'removed_furniture_lines':sum(len(p['removed_furniture']) for p in results),'engines':{name:sum(p['engine']==name for p in results) for name in set(p['engine'] for p in results)},'warnings':warnings,'applied_corrections':applied,'epub_audit':audit,'visual_review':'pending','ocr_accuracy_review':'pending','translation':'not requested; English EPUB can be passed to main.py separately'}
|
||||
save_json(work/'report.json',report)
|
||||
if audit['issues']: raise ValueError('EPUB structural audit failed; inspect report.json')
|
||||
return report
|
||||
finally:
|
||||
if ocr: ocr.close()
|
||||
document.close()
|
||||
|
||||
|
||||
STYLE='''body {margin: 5%; line-height:1.65;} p {margin:.5em 0;} html[lang^="zh"] p {text-indent:2em;} h1,h2 {text-align:center;page-break-before:always;} img {max-width:100%;height:auto;} figure {margin:1em 0;text-align:center;} figcaption {font-size:.8em;} .source-page {font-size:.7em;color:#777;text-indent:0;} .image-page {text-align:center;text-indent:0;} .image-page img {max-height:90vh;width:auto;object-fit:contain;}'''
|
||||
|
||||
|
||||
def export_epub(data, output, work, cover_page):
|
||||
from PIL import Image
|
||||
import io
|
||||
book=epub.EpubBook(); identity={k:data[k] for k in ['source_sha256','language','title','author','selected_pages']}
|
||||
identity['content_sha256']=hashlib.sha256(json.dumps([[p['number'],p['image_fallback'],[(b['kind'],b['text']) for b in p['blocks']]] for p in data['pages']],ensure_ascii=False).encode()).hexdigest()
|
||||
book.set_identifier('urn:uuid:'+str(uuid.uuid5(uuid.NAMESPACE_URL,json.dumps(identity,sort_keys=True))))
|
||||
label='【部分样例】' if data['partial'] else ''
|
||||
book.set_title(label+data['title']+'【PDF重排初稿】');book.set_language(data['language']);book.add_author(data['author'])
|
||||
book.add_metadata('DC','source','urn:sha256:'+data['source_sha256'])
|
||||
book.add_metadata('DC','description','PDF结构重建初稿;OCR未逐字校对。'+('仅含选定页,非完整书。' if data['partial'] else ''))
|
||||
css=epub.EpubItem(uid='style',file_name='styles/book.css',media_type='text/css',content=STYLE.encode());book.add_item(css)
|
||||
chapters=[]; toc_records=[]; body=[]; current=None; previous=None; previous_page=None
|
||||
def finish():
|
||||
if current is not None:
|
||||
current.content=('<html xmlns:epub="http://www.idpf.org/2007/ops"><head></head><body>'+''.join(body)+'</body></html>').encode()
|
||||
current.add_link(href='../styles/book.css',rel='stylesheet',type='text/css')
|
||||
book.add_item(current);chapters.append(current)
|
||||
for page in data['pages']:
|
||||
number=page['number']; outlines=[o for o in data['outlines'] if o['page']==number]
|
||||
labels=[o['title'] for o in outlines]
|
||||
if current is None or labels or page['image_fallback'] or previous_page is None or number!=previous_page+1:
|
||||
finish();body=[];previous=None
|
||||
current=epub.EpubHtml(title=' / '.join(labels) or f'PDF {number}',file_name=f'chapters/p{number:04d}.xhtml',lang=data['language'])
|
||||
for outline in outlines:
|
||||
toc_records.append((epub.Link(current.file_name+f'#pdf-page-{number}',outline['title'],f'nav-{len(toc_records)+1}'),outline['level']))
|
||||
marker=f'<span id="pdf-page-{number}" epub:type="pagebreak" role="doc-pagebreak" aria-label="PDF {number}"></span>'
|
||||
if not page['image_fallback']:
|
||||
for heading in labels: body.append('<h1>'+html.escape(heading)+'</h1>')
|
||||
if page['image_fallback']:
|
||||
body.append(marker)
|
||||
image=Path(page['render']).read_bytes();name=f'images/page-{number:04d}.jpg'
|
||||
book.add_item(epub.EpubItem(uid=f'image-{number}',file_name=name,media_type='image/jpeg',content=image))
|
||||
body.append(f'<div class="image-page"><img src="../{name}" alt="PDF第{number}页原貌"/></div>')
|
||||
if number==cover_page: book.set_cover('images/cover.jpg',image,create_page=False)
|
||||
previous=None
|
||||
else:
|
||||
entries=[dict(b) for b in page['blocks']]
|
||||
for n,box in enumerate(page['image_boxes']):
|
||||
area=(box[2]-box[0])*(box[3]-box[1])
|
||||
if .02<area<.65: entries.append({'kind':'image','bbox':box,'image_index':n})
|
||||
entries.sort(key=lambda b:(b['bbox'][1],b['bbox'][0]))
|
||||
marker_pending=True
|
||||
for index,block in enumerate(entries):
|
||||
if block['kind']=='image':
|
||||
with Image.open(page['render']) as picture:
|
||||
box=block['bbox'];region=picture.crop(tuple(int(v*(picture.width if i%2==0 else picture.height)) for i,v in enumerate(box)))
|
||||
stream=io.BytesIO();region.save(stream,format='PNG')
|
||||
n=block['image_index'];name=f'images/p{number:04d}-{n}.png'
|
||||
book.add_item(epub.EpubItem(uid=f'figure-{number}-{n}',file_name=name,media_type='image/png',content=stream.getvalue()))
|
||||
if marker_pending:body.append(marker);marker_pending=False
|
||||
body.append(f'<figure><img src="../{name}" alt="PDF第{number}页图片"/></figure>');previous=None;continue
|
||||
plain=re.sub(r'\s','',block['text'])
|
||||
if any(plain==re.sub(r'\s','',label) or (index==0 and plain in {'序','序言','前言','自序','引言','后记','目录'} and re.sub(r'\s','',label).startswith(plain)) for label in labels):continue
|
||||
# Merge only obvious prose continuation across consecutive source pages.
|
||||
continuation=marker_pending and previous and not labels and previous_page==number-1 and previous['kind']=='paragraph' and block['kind']=='paragraph' and not block['indented'] and not re.search(r'[。!?.!?::;;”’"]$',previous['text']) and body[-1].endswith('</p>')
|
||||
if continuation:
|
||||
extra=join_text(previous['text'],block['text'],data['language'])[len(previous['text']):]
|
||||
body[-1]=body[-1][:-4]+marker+f'<span id="{block["id"]}"></span>'+html.escape(extra)+'</p>'
|
||||
marker_pending=False;previous={**block,'text':join_text(previous['text'],block['text'],data['language'])};continue
|
||||
if marker_pending:body.append(marker);marker_pending=False
|
||||
tag='h2' if block['kind']=='heading' else 'p'
|
||||
body.append(f'<{tag} id="{block["id"]}">{html.escape(block["text"])}</{tag}>')
|
||||
if block['kind']=='heading' and not data['outlines']:
|
||||
toc_records.append((epub.Link(current.file_name+'#'+block['id'],block['text'],f'nav-{len(toc_records)+1}'),0))
|
||||
previous=block
|
||||
if marker_pending:body.append(marker)
|
||||
previous_page=number
|
||||
if page['image_fallback']:
|
||||
finish();current=None;body=[];previous=None
|
||||
finish()
|
||||
if not toc_records:toc_records=[(epub.Link(c.file_name,c.title,f'nav-{i+1}'),0) for i,c in enumerate(chapters)]
|
||||
roots=[];stack=[]
|
||||
for link,level in toc_records:
|
||||
node={'link':link,'children':[]}
|
||||
while stack and stack[-1][0]>=level:stack.pop()
|
||||
(stack[-1][1]['children'] if stack else roots).append(node);stack.append((level,node))
|
||||
def flatten(nodes):return [(node['link'],flatten(node['children'])) if node['children'] else node['link'] for node in nodes]
|
||||
book.toc=flatten(roots);book.add_item(epub.EpubNcx());nav=epub.EpubNav();nav.title='目录' if data['language'].startswith('zh') else 'Contents';book.add_item(nav);book.spine=['nav']+chapters
|
||||
output=Path(output);output.parent.mkdir(parents=True,exist_ok=True);temp=output.with_suffix('.tmp.epub')
|
||||
try:
|
||||
epub.write_epub(str(temp),book,{'epub3_pages':False})
|
||||
from src.qa.epub_audit import audit_epub
|
||||
if audit_epub(temp)['issues']:raise ValueError('Generated EPUB failed structural audit; existing output retained')
|
||||
temp.replace(output)
|
||||
finally:temp.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user