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;右:窄屏。截图生成不等于视觉或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('