"""PDF → Markdown。無文字層(掃描件)回報 needs_ocr 並跳過,不擅自呼叫 OCR。 可獨立執行:python tools/convert/from_pdf.py [-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"\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()