diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f774e..67b47a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2026-10-28 — lightweight standalone PDF import preview + +- Add PDFium/Pillow PDF import with native text, macOS system Vision OCR and an optional Tesseract adapter; no translation or heavyweight model dependency required. +- Support Chinese reflow directly and English EPUB export for the existing translation pipeline, without automatically starting paid translation. +- Cache source-bound page recognition, retain coordinates and raw OCR, reconstruct single-column paragraphs across pages, and preserve bookmark hierarchy and source-page anchors. +- Preserve selected illustration/title/back-cover pages, export digital PDF image regions, and apply source-bound exact OCR corrections without rewriting raw checkpoints. +- Add PDF/source-anchor desktop and narrow-screen QA with actual NAV clicks; validate all 476 pages and 87 bookmark targets on the user-provided sample. +- Keep output explicitly marked as an OCR reflow draft; complex layouts, scanned-page figure segmentation and footnote reconstruction remain outside this release. + ## 2026-10-10 — Highest Exam edition metadata - Preserve OPF metadata XML comments while replacing edition values and their refinements. diff --git a/README.md b/README.md index 54fefe5..13473cc 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Mac 沙箱中 Calibre 的 Qt 渲染可能需要允许进程运行。QA 使用项 无法可靠绑定的目录条目仅翻译标签,不改写猜测的章节正文;具体条目记录在 `book_structure.json` 的 `navigation_titles[].binding_note`。这类条目需要检查章节标题是否一致,不能视为全书标题同步已经通过。索引、版权、注释全文仍遵守原有跳过策略;导航中这些页面的名称可以翻译。 -后续扫描 PDF/OCR 大版本的图文、结构与目录方案见 [路线图](doc/OCR_EPUB_ROADMAP.md)。 +轻量 PDF/OCR 首版见 [使用说明](doc/PDF_IMPORT.md);后续困难版式见 [路线图](doc/OCR_EPUB_ROADMAP.md)。 ### 中文封面与书库 metadata(v0.14) @@ -149,3 +149,16 @@ Mac 沙箱中 Calibre 的 Qt 渲染可能需要允许进程运行。QA 使用项 路径相对于 JSON 文件;常规译本保留,另生成 `edition_*.epub`。缺少已审封面会报错,不自动使用另一个版本的图片。基础封装不新增依赖、无网络请求;生图和书目检索由 skill 编排。 2026-10-08 接入核实:本机 Codex 使用 ChatGPT 登录,缓存目录没有可选择的 image 模型。官方模型目录提供 `gpt-image-2.5-sunburst`/`gpt-image-2.5-flare`,但[ChatGPT OAuth API 路径暂不支持图片生成](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations)。本轮实际使用 Codex 内置 imagegen 成功生成两版;该工具不公开模型 ID/选择参数,因此记录 `model: null`,不能宣称使用 GPT-Image-2.5。后续运行先检查当时的官方支持情况和工具能力;不要读取或转用私有 OAuth token 绕过限制,也不自动改用付费 API。 + +### 轻量 PDF → EPUB(独立模块) + +新增 `scripts/pdf_to_epub.py`,仅安装 `requirements-pdf.txt` 即可独立使用。中文 PDF 直接重排;英文 PDF 先导出 EPUB,再进入现有翻译流程,转换阶段不调用翻译 API。支持文本层、macOS 系统 Vision OCR、可选 Tesseract、逐页恢复、原书签目录和来源锚点。首版限定横排单栏;样本已跑通476页全书,输出仍标为 OCR 重排初稿。 + +```bash +.venv/bin/pip install -r requirements-pdf.txt +.venv/bin/python scripts/pdf_to_epub.py input/book.pdf \ + --output output/pdf/book.epub --work .work/pdf-book \ + --language zh-CN --title '书名' --author '作者' +``` + +独立环境、英文翻译衔接、图片页保留和视觉QA见 [PDF导入使用说明](doc/PDF_IMPORT.md)。 diff --git a/doc/OCR_EPUB_ROADMAP.md b/doc/OCR_EPUB_ROADMAP.md index 568ef39..56e961d 100644 --- a/doc/OCR_EPUB_ROADMAP.md +++ b/doc/OCR_EPUB_ROADMAP.md @@ -1,6 +1,6 @@ # 扫描 PDF → EPUB:后续大版本 -这是独立的大版本方向,本轮不实现。目标是把旧中文书的扫描 PDF 转成可重排、可搜索、可点击目录的 EPUB,同时保留原书中有阅读意义的结构与图文关系。 +2026-10-28 已开始独立模块首版,见 [PDF_IMPORT.md](PDF_IMPORT.md)。首版优先轻量方案和横排单栏;以下困难版式仍属后续范围。目标是把旧中文书的扫描 PDF 转成可重排、可搜索、可点击目录的 EPUB,同时保留原书中有阅读意义的结构与图文关系。 ## 输入与评测样本 diff --git a/doc/PDF_IMPORT.md b/doc/PDF_IMPORT.md new file mode 100644 index 0000000..ca8549b --- /dev/null +++ b/doc/PDF_IMPORT.md @@ -0,0 +1,90 @@ +# 轻量 PDF → EPUB 独立模块 + +首版只处理横排单栏、普通段落和简单图片。中文 PDF 转为中文重排 EPUB;英文 PDF 转为英文 EPUB,然后由现有翻译程序处理。PDF 模块不导入翻译客户端,不读取 API Key,不发起翻译请求。 + +## 安装和后端 + +只使用 PDF 模块时,可单独建环境,不安装原项目的翻译 API 依赖: + +```bash +python3 -m venv .work/pdf-env +.work/pdf-env/bin/pip install -r requirements-pdf.txt +``` + +当前项目环境也可以直接安装该文件。相对已有项目,新增 PDF 相关依赖仅 `pypdfium2`、`Pillow`;独立安装还包含 EPUB 封装/结构审计所需的 EbookLib、lxml、BeautifulSoup、html5lib。没有 Docling、MinerU、Torch 或额外模型权重。 + +- `--ocr auto`:正常文本 PDF 使用文本层;全页扫描图片重新 OCR,即使已有隐藏 OCR 层也不直接相信旧结果。Mac 默认使用系统 Apple Vision;其他系统使用可选 Tesseract。 +- `--ocr native`:只用已有文本层。扫描页会记录警告,不能把已有 OCR 质量视为已验证。 +- `--ocr vision`:强制系统 Vision,需要 macOS 和 Apple command line tools 的 `swiftc`。辅助程序在工作目录编译并复用,本地处理图片。Codex 沙箱内 Vision 如报 nilError,应允许本地 OCR 进程在沙箱外执行;不是 API 认证错误。 +- `--ocr tesseract`:强制可选 Tesseract CLI;需自行安装程序以及 `eng`/`chi_sim` 语言数据。适配器已实现,本轮机器没有安装 Tesseract,未进行真实 Tesseract 识别验收。 + +官方依据:[PDFium Python API](https://pypdfium2.readthedocs.io/en/stable/python_api.html)、[Apple Vision](https://developer.apple.com/documentation/vision/recognizing-text-in-images)、[Tesseract TSV](https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html)。PDFium 在本模块中串行调用,避免跨线程调用原生库。 + +## 中文 PDF 直接重排 + +```bash +.work/pdf-env/bin/python scripts/pdf_to_epub.py input/chinese.pdf \ + --output output/pdf/chinese.epub --work .work/pdf-chinese \ + --language zh-CN --title '书名' --author '作者' --cover-page 1 +``` + +中文正文使用两格缩进;保留原语言,不做繁简转换或自由改写。输出 metadata 明确标注“PDF重排初稿”,不误标为 AI 翻译版。 + +## 英文 PDF → EPUB → 翻译 + +```bash +.work/pdf-env/bin/python scripts/pdf_to_epub.py input/english.pdf \ + --output output/pdf/english.epub --work .work/pdf-english \ + --language en --title 'Original Title' --author 'Original Author' + +# 在已有翻译环境中执行,沿用它的模型、凭据和配置: +.venv/bin/python main.py output/pdf/english.epub --complete-book --bilingual +``` + +PDF 转换完成只打印可选的后续翻译命令,不执行。英文原生文本 PDF 已通过真实 PDF 夹具转换,并验证可由现有 EpubCleaner/FineGrainedExtractor 完整提取;没有调用模型翻译此夹具。 + +## 小样、图片与恢复 + +- `--pages 1-20,30`:按1起算、闭区间选页;未覆盖全书时,书名和报告明确标注部分样例。 +- `--cover-page 1`:显式指定封面,不默认把所有 PDF 的第一页当封面。 +- `--image-pages 2,13,476`:手动保留插图、扉页、原目录或封底整页原貌。少量文字/空白页也会保留为原页图片并列入报告,不声称所有页均已文字重排。 +- `--dpi 180`:扫描渲染分辨率,支持96–400。 +- 同一工作目录一次运行一个任务。再次运行相同命令会复用完成的逐页 OCR;源 SHA、引擎、OCR 实现/系统版本、语言或 DPI 变化时不复用不匹配结果。另一份源文件必须使用新目录。 + +工作目录保留 `source.json`、`pages/` 原始识别/坐标/置信度、`renders/` 原页图、`book.json` 段落与书签结构、`report.json`。OCR 失败保留已完成页面,禁止把仅完成部分的处理结果写成全书 EPUB。 + +EPUB 按原书签组织章节和目录层级;没有书签时使用识别出的简单章节标题,或明确的来源页导航。每个原页都有 `pdf-page-N` 来源锚点;跨页仅合并明显连续的正文,保留原页追踪。页眉删除记录和原 OCR 行仍在中间结果中。 + +普通文本 PDF 内的独立图片按页面区域渲染嵌入;扫描页中的混合图文首版不做自动精细分割,需要 `--image-pages` 保留原页。双栏、竖排、公式、复杂表格、脚注双向链接重建不在本轮范围,不作为已实现功能。 + +## QA + +可选安装现有 `requirements-qa.txt` 和 Chromium 后: + +```bash +PLAYWRIGHT_BROWSERS_PATH="$PWD/.work/playwright" \ +.venv/bin/python scripts/pdf_qa.py output/pdf/chinese.epub \ + --work .work/pdf-chinese --pages 1,4,16 --out .work/pdf-chinese/qa +``` + +生成原页/EPUB 900px/390px 对照,实际点击生成的 NAV 目录链接,并检查目标及来源锚点。HTML 报告和 JSON 分开保留自动结果与视觉待审状态。结构审计通过不等于 OCR 字词准确;Vision 置信分数不是字错率,也不能据此给全书准确率。 + +EPUB 在临时文件中构建并通过结构审计后才替换输出。没有依赖 Calibre 或桌面阅读器;Chromium 连续 XHTML 不能代表所有阅读器分页。 + +## 可追溯的局部校正 + +`--corrections corrections.json` 支持查看原扫描后确认的逐行校正: + +```json +{ + "source_sha256": "当前PDF的完整SHA256", + "edits": [{ + "line_id": "p0101-l0011", + "expected": "原始OCR整行原文", + "replacement": "核对原扫描后的整行文字", + "evidence": "核对依据" + }] +} +``` + +源 SHA、行 ID 或 expected 不匹配就停止;重复条目和空替换也拒绝。原始 `pages/*.json` 不改写,校正写在派生 `book.json` 和报告里,可在重建时重复应用。识别内容变化会改变输出版本标识。此功能用于有证据的文字校对,不替代全书审读。 diff --git a/doc/QA_PDF_IMPORT_2026-10-28.md b/doc/QA_PDF_IMPORT_2026-10-28.md new file mode 100644 index 0000000..640f42b --- /dev/null +++ b/doc/QA_PDF_IMPORT_2026-10-28.md @@ -0,0 +1,60 @@ +# 轻量 PDF 导入首版与样本验收 + +按本轮收窄的范围开发:不引入 Docling 或大型模型,不实现困难版式。新增独立 `src/pdf_import/`、`scripts/pdf_to_epub.py` 和可选 `scripts/pdf_qa.py`。中文直接重排为 EPUB;英文先生成英文 EPUB,再进入已有翻译程序。转换阶段不调用翻译 API。 + +## 已实现与依赖 + +新增 PDF 依赖为 PDFium Python 包与 Pillow。独立环境只装 `requirements-pdf.txt`,包含 EPUB 导出/审计的轻量基础库;实际验证未加载 OpenAI SDK、Torch、Transformers、Docling 或 Paddle。macOS 使用系统 Vision;Swift 辅助进程常驻批量识别,未额外下载 OCR 模型权重。Tesseract 适配器可选,本机未安装程序,未做真实 Tesseract 验收。 + +原生文本提取与扫描页识别分流,扫描页即使已有隐藏 OCR 层也重新识别。页面缓存按源 SHA、页号、实际引擎/实现与系统版本、语言、DPI 和 PDFium 版本匹配。失败只保留已完成页面;未完成全书时不写出冒充全书的结果。部分选页输出有明确样例标签。 + +根据来源坐标重建普通段落,修复首轮发现的页眉混入正文与连续缩进段落被逐行拆开的情况;明显的跨页连续正文合并,原页锚点保留。书签层级生成 NAV/NCX。保留指定整页图,数字 PDF 的独立图片按页面区域渲染嵌入。构建临时 EPUB,结构审计通过后才替换最终输出。 + +## 样本 + +用户指定下载目录中的《中国的世界高原 上》,沈卫平。476页,无密码;抽查为横排单栏扫描,已有 ABBYY 文本层,但可见漏字,不能当作可靠原文。 + +源 SHA256:`aebc9f263ab0599f6d11c7db6c5e89e1316f2a950f2faa9ddbc72a94fe3da335`。 + +```bash +.venv/bin/python scripts/pdf_to_epub.py \ + '/Users/kaitan/Downloads/中国的世界高原 上 (沈卫平) (z-library.sk, 1lib.sk, z-lib.sk).pdf' \ + --output 'output/pdf/中国的世界高原_上_重排初稿.epub' \ + --work .work/pdf-highland --language zh-CN \ + --title '中国的世界高原(上)' --author 沈卫平 \ + --cover-page 1 --image-pages 2,13,476 \ + --corrections .work/pdf-highland/corrections.json +``` + +476/476页处理,476个来源页锚点均存在。459页为重排文字,17页为封面、扉页、空白/短文字页等原页图片;报告逐项列明。第2、13、476页为显式指定保留原貌的扉页、纸本目录和带签名二维码的封底;其余少量空白/短文字页自动保留原页图,不称为可搜索的重排文字。 + +原87个书签对应87个有效目录目标,层级为10个一级、77个二级。浏览器实际点击87个 NAV 链接,目标均到达。509行页眉/页码候选从派生正文移除,原识别行和删除记录仍保存。 + +## 对照 QA 与修复 + +以原页序1、4、16、101、248、476生成原扫描/900px/390px对照,检查封面、普通段落、跨页衔接、引文和封底保留。自动结构审计零问题,抽样自动布局检查零问题。实际查看的图片及 SHA 写入 `.work/pdf-highland/review-record.json`。 + +原页对照确认第101页两行中的三个字形识别错误,使用带源 SHA、稳定行 ID、expected 原文和 replacement 的校正文件修正;原始逐页 OCR JSON 不变。校正不匹配或重复时拒绝应用,不做全书无证据替换。 + +OCR 准确率仍待全文校对,未计算人工真值字错率。Vision 的低置信提示不是准确率百分比。输出 metadata 明确标为“PDF重排初稿”;没有宣称所有文字或每个阅读器分页已验收。 + +## 独立与英文路径验证 + +在新环境 `.work/pdf-light-env` 仅安装 `requirements-pdf.txt`,实际将两页英文原生文本 PDF 夹具转换为 EPUB,并打印后续翻译命令,没有加载翻译 SDK 或发起翻译。另用现有 EpubCleaner 和 FineGrainedExtractor 验证生成英文 EPUB 可进入现有提取链路。后续翻译在原项目 `.venv` 中执行,沿用其模型与凭据选择。 + +测试:98 passed,2 skipped。新增覆盖页范围、页眉误识别、英文提取衔接、扫描页覆盖旧文本层、缓存复用/失效、失败恢复与禁止部分全书输出、连续缩进段落及源绑定校正。 + +## 范围限制 + +- 首版针对横排单栏普通书;没有实现双栏、竖排正文、公式、复杂表格或脚注双向链接重建。 +- 扫描页混合插图不自动精细切分;可指定整页保留。并非所有字体、粗体及页面坐标都被重建。 +- 来源锚点使用PDF文件页序,不冒充纸本页码。纸本目录页保留原图,交互目录由实际 PDF 书签生成。 +- headless 为连续 XHTML,不代表 Calibre、Apple Books 等所有阅读器的分页。全书文字校对没有完成。 + +## 本地证据 + +`.work/pdf-highland/`:`source.json`、原始 `pages/`、`renders/`、`book.json`、`corrections.json`、`report.json`、`qa-final/review.html`、`qa-final/report.json` 和 SHA 绑定的 `review-record.json`。原PDF不修改,书籍和识别文字不提交仓库。 + +输出:`output/pdf/中国的世界高原_上_重排初稿.epub`。 + +输出 SHA256:`9e2bece683420803d59292aa49fb44ee6e1cd002331364af7380ba0a5971b71d`。 diff --git a/requirements-pdf.txt b/requirements-pdf.txt new file mode 100644 index 0000000..f8a0640 --- /dev/null +++ b/requirements-pdf.txt @@ -0,0 +1,8 @@ +# Optional PDF conversion only; no model weights or translation SDK required. +pypdfium2>=5,<6 +Pillow>=10,<13 +# Minimal EPUB export/audit dependencies (the translation API stack is optional). +ebooklib>=0.19,<1 +lxml>=4.9 +beautifulsoup4>=4.12 +html5lib>=1.1 diff --git a/scripts/pdf_qa.py b/scripts/pdf_qa.py new file mode 100644 index 0000000..8ce85b2 --- /dev/null +++ b/scripts/pdf_qa.py @@ -0,0 +1,67 @@ +"""Compare cached source PDF pages and actual EPUB source anchors locally.""" +import argparse +import hashlib +import json +import shutil +import sys +from pathlib import Path +from urllib.parse import quote +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('epub',type=Path);parser.add_argument('--work',type=Path,required=True) + parser.add_argument('--pages',default='1,4,16');parser.add_argument('--out',type=Path,required=True) + args=parser.parse_args() + from src.qa.headless import archive_server,book_inventory,LAYOUT_JS + from src.qa.epub_audit import audit_epub + from playwright.sync_api import sync_playwright + data=json.loads((args.work/'book.json').read_text()) + pages={p['number']:p for p in data['pages']};selected=[int(p) for p in args.pages.split(',')] + if any(p not in pages for p in selected):parser.error('Requested page not present in the conversion IR') + args.out.mkdir(parents=True,exist_ok=True) + inventory=book_inventory(args.epub);evidence=[];clicks=[];sections=[] + with archive_server({'book':args.epub}) as base,sync_playwright() as pw: + browser=pw.chromium.launch(headless=True) + try: + context=browser.new_context(java_script_enabled=False,service_workers='block') + context.route('**/*',lambda route:route.continue_() if route.request.url.startswith(base+'/') else route.abort()) + tab=context.new_page() + for number in selected: + anchor=f'pdf-page-{number}' + document=next((name for name,soup in inventory['documents'].items() if soup.find(id=anchor)),None) + if not document:raise ValueError(f'EPUB lost source anchor {anchor}') + source_name=f'pdf-{number:04d}.jpg';shutil.copy2(pages[number]['render'],args.out/source_name) + rendered=[] + for width in [900,390]: + tab.set_viewport_size({'width':width,'height':1100}) + tab.goto(base+'/book/'+quote(document)+'#'+anchor,wait_until='networkidle') + tab.evaluate('() => document.fonts.ready') + tab.evaluate('(id) => {const e=document.getElementById(id); scrollTo(0,Math.max(0,e.getBoundingClientRect().top+scrollY-140));}',anchor) + name=f'epub-{number:04d}-{width}.png';tab.screenshot(path=str(args.out/name));rendered.append(name) + evidence.append({'source_page':number,'document':document,'viewport_width':width,'image':name,'layout_findings':tab.evaluate(LAYOUT_JS)}) + sections.append(f'

PDF 原页序 {number}

原扫描页EPUB实际排版窄屏排版
') + # Real browser navigation for every generated TOC target, including fragments. + for entry in inventory['toc']: + target=entry['target'];name,_,fragment=target.partition('#') + nav=entry['file'] + nav_soup=inventory['documents'].get(nav) + if nav_soup is not None: + from src.qa.headless import destination + anchors=nav_soup.find_all('a',href=True) + link_index=next(i for i,a in enumerate(anchors) if destination(nav,a['href'])==target) + tab.goto(base+'/book/'+quote(nav),wait_until='domcontentloaded') + tab.locator('a[href]').nth(link_index).click(timeout=5000) + else: + tab.goto(base+'/book/'+quote(name)+(('#'+quote(fragment)) if fragment else ''),wait_until='domcontentloaded') + reached=(tab.url.split('#')[0]==base+'/book/'+quote(name)) and (not fragment or tab.evaluate('(id) => !!document.getElementById(id)',fragment)) + clicks.append({'target':target,'destination_reached':reached}) + finally:browser.close() + report={'source_sha256':data['source_sha256'],'epub_sha256':hashlib.sha256(args.epub.read_bytes()).hexdigest(),'epub_audit':audit_epub(args.epub),'evidence':evidence,'toc_navigation_checks':clicks,'visual_review':'pending','ocr_accuracy_review':'pending','limits':'Chromium continuous XHTML, not all reader pagination; source anchor may be inside a continued paragraph.'} + (args.out/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n') + (args.out/'review.html').write_text('PDF → EPUB QA

PDF → EPUB 对照

左:原扫描;中:EPUB;右:窄屏。截图生成不等于视觉或OCR验收。

'+''.join(sections)) + print(f'PDF source comparisons: {len(selected)}; TOC target navigation: {len(clicks)}') + return int(bool(report['epub_audit']['issues']) or any(not c['destination_reached'] for c in clicks) or any(e['layout_findings'] for e in evidence)) + +if __name__=='__main__':sys.exit(main()) diff --git a/scripts/pdf_to_epub.py b/scripts/pdf_to_epub.py new file mode 100644 index 0000000..503f6af --- /dev/null +++ b/scripts/pdf_to_epub.py @@ -0,0 +1,30 @@ +"""Independent lightweight PDF import. English output can enter the EPUB translator.""" +import argparse +import json +import shlex +import sys +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +from src.pdf_import.converter import convert + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('source',type=Path);parser.add_argument('--output',required=True,type=Path) + parser.add_argument('--work',required=True,type=Path);parser.add_argument('--language',choices=['zh-CN','en'],required=True) + parser.add_argument('--title');parser.add_argument('--author');parser.add_argument('--pages',help='1-based inclusive selection, e.g. 1-30,40; output is explicitly partial') + parser.add_argument('--ocr',choices=['auto','native','vision','tesseract'],default='auto') + parser.add_argument('--dpi',type=int,default=180);parser.add_argument('--cover-page',type=int);parser.add_argument('--image-pages',help='Preserve chosen illustration/title/back-cover pages as images, e.g. 2,13,476') + parser.add_argument('--corrections',type=Path,help='Source SHA-bound, exact OCR line corrections JSON') + args=parser.parse_args() + try: + report=convert(args.source,args.output,args.work,args.language,args.title,args.author,args.ocr,args.pages,args.dpi,args.cover_page,image_pages=args.image_pages,corrections=args.corrections) + except (ValueError,RuntimeError,ImportError,OSError) as exc: + parser.exit(1,f'PDF import failed: {exc}\n') + print(json.dumps({k:report[k] for k in ['output','partial','processed_pages','source_pages','engines']},ensure_ascii=False)) + if args.language=='en': + translation_python=Path(__file__).resolve().parents[1]/'.venv/bin/python' + print('Optional next step in the translation environment (not executed): '+shlex.join([str(translation_python) if translation_python.exists() else 'python',str(Path(__file__).resolve().parents[1]/'main.py'),str(args.output.resolve()),'--complete-book','--bilingual'])) + return 0 + +if __name__=='__main__':sys.exit(main()) diff --git a/src/pdf_import/__init__.py b/src/pdf_import/__init__.py new file mode 100644 index 0000000..65b79cd --- /dev/null +++ b/src/pdf_import/__init__.py @@ -0,0 +1 @@ +"""Optional lightweight PDF import, independent of the translation pipeline.""" diff --git a/src/pdf_import/converter.py b/src/pdf_import/converter.py new file mode 100644 index 0000000..d170936 --- /dev/null +++ b/src/pdf_import/converter.py @@ -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=(''+''.join(body)+'').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'' + if not page['image_fallback']: + for heading in labels: body.append('

'+html.escape(heading)+'

') + 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'
PDF第{number}页原貌
') + 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 .02PDF第{number}页图片');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('

') + if continuation: + extra=join_text(previous['text'],block['text'],data['language'])[len(previous['text']):] + body[-1]=body[-1][:-4]+marker+f''+html.escape(extra)+'

' + 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"])}') + 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) diff --git a/src/pdf_import/layout.py b/src/pdf_import/layout.py new file mode 100644 index 0000000..7d4ab1b --- /dev/null +++ b/src/pdf_import/layout.py @@ -0,0 +1,78 @@ +"""Conservative reconstruction for horizontal, single-column prose only.""" +import re +import statistics +from collections import Counter +from difflib import SequenceMatcher + +CJK = r'\u3400-\u9fff' + + +def clean_text(text, language): + text = re.sub(r'\s+', ' ', text).strip() + if language.startswith('zh'): + text = re.sub(r'(?<=['+CJK+r']) +(?=['+CJK+r'])', '', text) + return text + + +def join_text(left, right, language): + if not left: return right + if not right: return left + if language.startswith('zh'): + space = ' ' if left[-1].isascii() and left[-1].isalnum() and right[0].isascii() and right[0].isalnum() else '' + else: + space = '' if left.endswith('-') else ' ' + return left + space + right + + +def furniture_key(text): + return re.sub(r'[\W\d_]+', '', text).casefold() + + +def repeated_furniture(pages): + counts = Counter() + for page in pages: + counts.update({furniture_key(line['text']) for line in page['lines'] if line['bbox'][3] < .10 or line['bbox'][1] > .94}) + return {key for key, count in counts.items() if key and count >= 3} + + +def paragraphs(page, language, repeated): + lines = sorted(page['lines'], key=lambda line: (round(line['bbox'][1], 3), line['bbox'][0])) + kept, removed = [], [] + for line in lines: + text = clean_text(line['text'], language) + if not text: continue + edge = line['bbox'][3] < .10 or line['bbox'][1] > .94 + folio = len(text)<45 and bool(re.search(r'(?:^\d{1,4}\s*[|I丨!!/]|[|I丨!!/]\s*\d{1,4}$)', text) or re.match(r'^\d{3}\s+[^\d]', text)) + key=furniture_key(text) + similar_header=len(key)>=4 and any(len(other)>=4 and SequenceMatcher(None,key,other).ratio()>=.72 for other in repeated) + folio |= bool(re.search(r'\d{3}$',text)) + if edge and (folio or key in repeated or similar_header or re.fullmatch(r'[\W\d_]+', text)): + removed.append(line); continue + kept.append({**line, 'text': text}) + if not kept: return [], removed + heights = [l['bbox'][3]-l['bbox'][1] for l in kept] + height = statistics.median(heights) + lefts = sorted(l['bbox'][0] for l in kept) + baseline = lefts[max(0, len(lefts)//5)] + right = statistics.median(l['bbox'][2] for l in kept) + blocks = [] + for line_index, line in enumerate(kept): + box = line['bbox']; text = line['text'] + # This is a heuristic heading proposal, not a claim of semantic certainty. + heading = bool(re.fullmatch(r'第[一二三四五六七八九十百零〇两\d]+[章节部篇卷].{0,30}', text) or re.fullmatch(r'(?i)(chapter|part)\s+[\divxlc]+(?:\b.*)?', text)) + heading |= len(text) <= 10 and box[0] > .28 and box[2] < .75 and (bool(re.fullmatch(r'\d{1,3}', text)) or heights[line_index] > height*1.35) + indented = box[0] > baseline + max(.015, height*.7) + previous = blocks[-1] if blocks else None + indent_start=indented and (line_index==0 or box[0]>kept[line_index-1]['bbox'][0]+max(.009,height*.3)) + split = heading or previous is None or previous['kind']=='heading' or indent_start + if previous: + gap = box[1]-previous['bbox'][3] + split |= gap > height*.9 or previous['bbox'][2] < right-.10 + if split: + blocks.append({'kind':'heading' if heading else 'paragraph', 'text':text, 'bbox':list(box), 'indented':indented, 'source_page':page['number'], 'line_ids':[line['id']]}) + else: + previous['text'] = join_text(previous['text'], text, language) + previous['bbox'] = [min(previous['bbox'][0],box[0]), previous['bbox'][1], max(previous['bbox'][2],box[2]), box[3]] + previous['line_ids'].append(line['id']) + for index, block in enumerate(blocks): block['id'] = f'p{page["number"]:04d}-b{index+1:04d}' + return blocks, removed diff --git a/src/pdf_import/ocr.py b/src/pdf_import/ocr.py new file mode 100644 index 0000000..af9101b --- /dev/null +++ b/src/pdf_import/ocr.py @@ -0,0 +1,92 @@ +"""Small OCR adapters. Vision uses macOS frameworks; Tesseract is an optional CLI.""" +import csv +import hashlib +import io +import json +import platform +import selectors +import shutil +import subprocess +from pathlib import Path + + +class VisionOCR: + name = 'vision' + + def __init__(self, work, language, timeout=120): + if platform.system() != 'Darwin': + raise RuntimeError('Vision OCR requires macOS; select tesseract or native instead') + compiler = shutil.which('swiftc') + if not compiler: + raise RuntimeError('Vision OCR requires Apple command line tools (swiftc)') + source = Path(__file__).with_name('vision.swift') + # Include OS version in cache identity: framework OCR can change between releases. + self.identity = hashlib.sha256(source.read_bytes() + platform.mac_ver()[0].encode()).hexdigest() + cache = Path(work)/'vision-runtime'; cache.mkdir(parents=True, exist_ok=True) + binary = cache/('vision-' + self.identity[:16]) + if not binary.exists(): + module_cache = cache/'module-cache'; module_cache.mkdir(exist_ok=True) + built = subprocess.run([compiler, '-O', '-module-cache-path', str(module_cache), str(source), '-o', str(binary)], capture_output=True, text=True, timeout=180) + if built.returncode: + raise RuntimeError('Vision helper compilation failed: ' + built.stderr[-3000:]) + self.languages = ['zh-Hans', 'en-US'] if language.startswith('zh') else ['en-US'] + self.timeout = timeout + self.log = (cache/'runtime.log').open('a') + self.process = subprocess.Popen([str(binary)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.log, text=True, bufsize=1) + + def recognize(self, image): + self.process.stdin.write(json.dumps({'image': str(Path(image).resolve()), 'languages': self.languages})+'\n') + self.process.stdin.flush() + with selectors.DefaultSelector() as selector: + selector.register(self.process.stdout, selectors.EVENT_READ) + if not selector.select(self.timeout): + self.process.kill() + raise RuntimeError('Vision OCR timed out; completed page cache retained') + raw = self.process.stdout.readline() + if not raw: + raise RuntimeError('Vision OCR stopped; inspect vision-runtime/runtime.log') + result = json.loads(raw) + if result.get('error'): + raise RuntimeError('Vision OCR failed: ' + result['error']) + return result['lines'] + + def close(self): + if self.process.poll() is None: + self.process.stdin.close() + try: self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill(); self.process.wait() + self.log.close() + + +class TesseractOCR: + name = 'tesseract' + + def __init__(self, work, language, timeout=120): + self.binary = shutil.which('tesseract') + if not self.binary: + raise RuntimeError('Install tesseract and language data, or use Vision on macOS') + self.language = 'chi_sim+eng' if language.startswith('zh') else 'eng' + self.timeout = timeout + version = subprocess.run([self.binary, '--version'], capture_output=True, text=True, check=True) + self.identity = version.stdout.splitlines()[0] + ':' + self.language + + def recognize(self, image): + from PIL import Image + with Image.open(image) as picture: width, height = picture.size + output = subprocess.run([self.binary, str(image), 'stdout', '-l', self.language, 'tsv'], capture_output=True, text=True, check=True, timeout=self.timeout) + groups = {} + for word in csv.DictReader(io.StringIO(output.stdout), delimiter='\t', quoting=csv.QUOTE_NONE): + if not (word.get('text') or '').strip(): continue + key = (word['block_num'], word['par_num'], word['line_num']) + groups.setdefault(key, []).append(word) + lines = [] + for words in groups.values(): + left = min(int(w['left']) for w in words); top = min(int(w['top']) for w in words) + right = max(int(w['left'])+int(w['width']) for w in words) + bottom = max(int(w['top'])+int(w['height']) for w in words) + text = (' ' if self.language == 'eng' else '').join(w['text'] for w in words) + lines.append({'text': text, 'bbox': [left/width, top/height, right/width, bottom/height], 'confidence': min(float(w['conf'])/100 for w in words)}) + return lines + + def close(self): pass diff --git a/src/pdf_import/vision.swift b/src/pdf_import/vision.swift new file mode 100644 index 0000000..401f1fa --- /dev/null +++ b/src/pdf_import/vision.swift @@ -0,0 +1,35 @@ +import Foundation +import Vision +import ImageIO +import Darwin + +struct Input: Decodable { let image: String; let languages: [String] } +struct Line: Encodable { let text: String; let bbox: [Double]; let confidence: Float } +struct Output: Encodable { let lines: [Line]; let engine: String; let error: String? } +let decoder = JSONDecoder() +let encoder = JSONEncoder() +while let line = readLine() { + autoreleasepool { + let result: Output + do { + let job = try decoder.decode(Input.self, from: Data(line.utf8)) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = job.languages + request.usesLanguageCorrection = true + let handler = VNImageRequestHandler(url: URL(fileURLWithPath: job.image), options: [:]) + try handler.perform([request]) + let lines = (request.results ?? []).compactMap { observation -> Line? in + guard let candidate = observation.topCandidates(1).first else { return nil } + let r = observation.boundingBox + return Line(text: candidate.string, bbox: [r.minX, 1-r.maxY, r.maxX, 1-r.minY].map(Double.init), confidence: candidate.confidence) + } + result = Output(lines: lines, engine: "apple-vision", error: nil) + } catch { + result = Output(lines: [], engine: "apple-vision", error: String(describing: error)) + } + if let data = try? encoder.encode(result), let output = String(data: data, encoding: .utf8) { + print(output); fflush(stdout) + } + } +} diff --git a/tests/test_pdf_import.py b/tests/test_pdf_import.py new file mode 100644 index 0000000..fc0282d --- /dev/null +++ b/tests/test_pdf_import.py @@ -0,0 +1,145 @@ +"""Lightweight import: selection, provenance, OCR routing, reflow and EPUB links.""" +import json +import zipfile +from pathlib import Path +import pytest +from src.pdf_import.converter import convert, page_selection, validate_lines +from src.pdf_import.layout import paragraphs, repeated_furniture, join_text +from src.qa.epub_audit import audit_epub + + +@pytest.fixture +def native_pdf(tmp_path): + pytest.importorskip('pypdfium2') + # Minimal native-text PDF fixture; no external PDF authoring dependency. + def write(name='input.pdf', image=False): + streams=[b'BT /F1 18 Tf 60 740 Td (Chapter 1: Start) Tj 0 -40 Td /F1 12 Tf (A paragraph with enough native English text to use without OCR.) Tj ET',b'BT /F1 12 Tf 60 740 Td (This is the second page with more native English prose.) Tj ET'] + if image:streams=[b'q 600 0 0 800 0 0 cm /Im1 Do Q '+s for s in streams] + resources=b'<< /Font << /F1 7 0 R >>'+(b' /XObject << /Im1 8 0 R >>' if image else b'')+b' >>' + objects=[b'<< /Type /Catalog /Pages 2 0 R >>',b'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>'] + objects.extend(b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources '+resources+b' /Contents '+str(n).encode()+b' 0 R >>' for n in [5,6]) + objects.extend(b'<< /Length '+str(len(s)).encode()+b' >>\nstream\n'+s+b'\nendstream' for s in streams) + objects.append(b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') + if image:objects.append(b'<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\xff\xff\xff\nendstream') + raw=b'%PDF-1.4\n';offsets=[0] + for i,obj in enumerate(objects):offsets.append(len(raw));raw+=f'{i+1} 0 obj\n'.encode()+obj+b'\nendobj\n' + xref=len(raw);raw+=f'xref\n0 {len(objects)+1}\n0000000000 65535 f \n'.encode() + raw+=b''.join(f'{n:010d} 00000 n \n'.encode() for n in offsets[1:]) + raw+=f'trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF'.encode() + path=tmp_path/name;path.write_bytes(raw);return path + return write + + +def test_page_ranges_do_not_silently_drop_pages(): + assert page_selection(None,3)==[0,1,2] + assert page_selection('3,1-2,2',3)==[0,1,2] + for value in ['0','3-2','1-4','-1','1,','abc']: + with pytest.raises(ValueError):page_selection(value,3) + + +def test_folio_ocr_variants_are_removed_but_body_years_survive(): + lines=[{'id':'header','text':'002 | 中国的世界系','bbox':[.15,.04,.4,.06]}, {'id':'body','text':'2019年发生的事。','bbox':[.15,.12,.8,.14]}] + blocks,removed=paragraphs({'number':5,'lines':lines},'zh-CN',set()) + assert [b['text'] for b in blocks]==['2019年发生的事。'] + assert removed[0]['id']=='header' + assert join_text('跨页','继续。','zh-CN')=='跨页继续。' + assert join_text('Some','words','en')=='Some words' + + +def test_native_english_import_is_independent_and_complete(native_pdf,tmp_path): + source=native_pdf();out=tmp_path/'book.epub';work=tmp_path/'work' + report=convert(source,out,work,language='en',engine='native') + assert report['engines']=={'native':2} and not report['partial'] + assert audit_epub(out)['issues']==[] + with zipfile.ZipFile(out) as z: + chapters=[n for n in z.namelist() if '/chapters/' in n] + assert len(chapters)==1 # Consecutive pages flow inside one reading document. + text=z.read(chapters[0]).decode() + assert 'native English' in text and 'second page' in text + assert 'pdf-page-1' in text and 'pdf-page-2' in text + assert '../styles/book.css' in text and 'lang="en"' in text + from src.preprocessing.epub_cleaner import EpubCleaner + from src.preprocessing.text_extractor import FineGrainedExtractor + from src.common.data_model import BookStructure + structure=BookStructure.load(EpubCleaner(out,tmp_path/'translation-input').clean()) + units=FineGrainedExtractor(complete_book=True).extract(structure) + assert any('native English' in unit.original_text for unit in units) + ir=json.loads((work/'book.json').read_text()) + assert len(ir['pages'])==2 and ir['pages'][0]['lines'][0]['bbox'] + partial=convert(source,tmp_path/'partial.epub',work,language='en',engine='native',pages='2') + assert partial['partial'] and partial['processed_pages']==1 + with zipfile.ZipFile(tmp_path/'partial.epub') as z: + opf=z.read('EPUB/content.opf').decode();assert '部分样例' in opf + with pytest.raises(ValueError,match='another source'): + convert(native_pdf('another.pdf',image=True),tmp_path/'other.epub',work,engine='native') + + +def test_scanned_text_layer_is_replaced_and_ocr_cache_is_reused(native_pdf,tmp_path,monkeypatch): + from src.pdf_import import ocr + import src.pdf_import.converter as converter + called=[] + class FakeOCR: + identity='fixture-v1' + def __init__(self,*args):pass + def recognize(self,image): + called.append(image) + return [{'text':'这是来自扫描图像的新识别结果,必须保留完整正文内容,不能使用已有错误文本层。','bbox':[.1,.2,.9,.25],'confidence':.9}] + def close(self):pass + monkeypatch.setattr(ocr,'VisionOCR',FakeOCR) + monkeypatch.setattr(ocr,'TesseractOCR',FakeOCR) + source=native_pdf(image=True);out=tmp_path/'scan.epub';work=tmp_path/'work' + convert(source,out,work,language='zh-CN',engine='auto') + assert len(called)==2 + convert(source,out,work,language='zh-CN',engine='auto') + assert len(called)==2 + with zipfile.ZipFile(out) as z: + body=b''.join(z.read(n) for n in z.namelist() if '/chapters/' in n).decode() + assert '新识别结果' in body and 'native English' not in body + assert 'lang="zh-CN"' in body + FakeOCR.identity='fixture-v2' + convert(source,out,work,language='zh-CN',engine='auto') + assert len(called)==4 + + +def test_invalid_ocr_geometry_fails_closed(): + with pytest.raises(ValueError):validate_lines([{'text':'bad','bbox':[.8,.2,.1,.4]}]) + with pytest.raises(ValueError):validate_lines([{'text':'bad','bbox':[0,0,float('nan'),1]}]) + + +def test_ocr_failure_leaves_page_checkpoint_and_no_partial_final(native_pdf,tmp_path,monkeypatch): + from src.pdf_import import ocr + called=[] + class FakeOCR: + identity='resume-fixture' + fail=True + def __init__(self,*args):pass + def recognize(self,image): + called.append(Path(image).name) + if Path(image).stem=='0002' and self.fail:raise RuntimeError('fixture OCR failure') + return [{'text':'A sufficiently long recognized paragraph to remain searchable in the EPUB.', 'bbox':[.1,.2,.9,.3], 'confidence':1}] + def close(self):pass + monkeypatch.setattr(ocr,'VisionOCR',FakeOCR);monkeypatch.setattr(ocr,'TesseractOCR',FakeOCR) + source=native_pdf(image=True);out=tmp_path/'output.epub';work=tmp_path/'work' + with pytest.raises(RuntimeError,match='fixture OCR failure'):convert(source,out,work,language='en') + assert not out.exists() and (work/'pages/0001.json').exists() and not (work/'pages/0002.json').exists() + FakeOCR.fail=False + convert(source,out,work,language='en') + assert called==['0001.jpg','0002.jpg','0002.jpg'] + assert audit_epub(out)['issues']==[] + + +def test_consistently_indented_quote_lines_stay_one_paragraph(): + lines=[{'id':'a','text':'这是正文段落。','bbox':[.1,.11,.9,.13]}, {'id':'b','text':'一段尚未结束的引文','bbox':[.2,.16,.8,.18]}, {'id':'c','text':'继续在相同缩进位置。','bbox':[.2,.19,.8,.21]}] + blocks,_=paragraphs({'number':1,'lines':lines},'zh-CN',set()) + assert len(blocks)==2 + assert blocks[1]['text']=='一段尚未结束的引文继续在相同缩进位置。' + + +def test_corrections_require_source_and_exact_expected_text(): + from src.pdf_import.converter import apply_corrections + pages=[{'lines':[{'id':'p0001-l0001','text':'牲富'}]}] + patch={'source_sha256':'source','edits':[{'line_id':'p0001-l0001','expected':'牲富','replacement':'牲畜'}]} + with pytest.raises(ValueError):apply_corrections(pages,patch,'different-source') + applied=apply_corrections(pages,patch,'source') + assert pages[0]['lines'][0]['text']=='牲畜' and len(applied)==1 + with pytest.raises(ValueError,match='Stale'):apply_corrections(pages,patch,'source')