Files
pp-qa-km/tools/convert/from_pdf.py
LittleYellow 3c1b268074 修復含空格/括號檔名的圖片連結失效,自檢改驗連結可 render
來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效——
圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡
括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與
落單括號會壞。

- convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標
  一律以 <> 包住(![name](<rel>)),同時涵蓋空格與不平衡括號。
- selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片
  連結數 == <img> 數且目標檔存在,取代原本只檢查 "![" 字串在不在的表面斷言。
  測資補內嵌圖片(docx/pdf/pptx 各一路徑)、檔名改含空格與不平衡括號。
- requirements.txt:明確宣告 markdown-it-py(原為 fastmcp 傳遞依賴,自檢直接
  使用,宣告以免上游調整依賴樹後自檢失效)。
- AGENTS.md §13.4:新增「斷言要驗結果可用,不是驗字串存在」規則。

驗證:四個自檢全通過;移除任一處 <> 修法,assert_links_ok 即失敗。
ingest / lint / search 在含括號檔名下全鏈路已另行驗證正常(source_ref 走
YAML 純量,不受影響)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:12:47 +08:00

72 lines
2.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDF → Markdown。無文字層掃描件回報 needs_ocr 並跳過,不擅自呼叫 OCR。
可獨立執行python tools/convert/from_pdf.py <file.pdf> [-o out.md] [--dry-run]
"""
import argparse
import pathlib
import fitz # pymupdf
# ponytail: 掃描件偵測用「平均每頁字元數 < 25」的 naive heuristic
# 混合型 PDF部分頁掃描會整份判為有文字層升級路徑逐頁判定 + 逐頁標記。
_MIN_CHARS_PER_PAGE = 25
def convert(src, assets_dir=None, md_dir=None):
"""回傳 (markdown, status)。status ∈ {'ok', 'needs_ocr'}。
ponytail: 表格輸出為純文字行不重建表格結構升級路徑page.find_tables()。
"""
src = pathlib.Path(src)
md_dir = pathlib.Path(md_dir) if md_dir else src.parent
doc = fitz.open(str(src))
pages = [page.get_text("text").strip() for page in doc]
total = sum(len(p) for p in pages)
if total < _MIN_CHARS_PER_PAGE * max(len(pages), 1):
doc.close()
return "", "needs_ocr"
counter = 0
blocks = []
for i, page in enumerate(doc):
blocks.append(f"<!-- page {i + 1} -->\n\n{pages[i]}")
if assets_dir is None:
continue
for img in page.get_images(full=True):
xref = img[0]
pix = fitz.Pixmap(doc, xref)
if pix.n > 4: # CMYK 等 → 轉 RGB
pix = fitz.Pixmap(fitz.csRGB, pix)
counter += 1
name = f"img{counter}.png"
assets_dir.mkdir(parents=True, exist_ok=True)
pix.save(str(assets_dir / name))
rel = (assets_dir / name).relative_to(md_dir).as_posix()
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
doc.close()
return "\n\n".join(blocks), "ok"
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("src")
ap.add_argument("-o", "--output")
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args(argv)
if a.dry_run:
md, status = convert(a.src)
print(f"[dry-run] {a.src}: status={status}, {len(md)} chars")
return
out = pathlib.Path(a.output) if a.output else pathlib.Path(a.src).with_suffix(".md")
md, status = convert(a.src, assets_dir=out.parent / f"{out.stem}-assets", md_dir=out.parent)
if status == "needs_ocr":
print(f"needs_ocr: {a.src} 無文字層,已跳過(不擅自 OCR")
raise SystemExit(2)
out.write_text(md, encoding="utf-8")
print(out)
if __name__ == "__main__":
main()