來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效—— 圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡 括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與 落單括號會壞。 - convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標 一律以 <> 包住(),同時涵蓋空格與不平衡括號。 - 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>
72 lines
2.6 KiB
Python
72 lines
2.6 KiB
Python
"""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"")
|
||
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()
|