Files
pp-qa-km/tools/convert/from_docx.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

99 lines
3.4 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.
"""Word (.docx) → Markdown。保留標題層級與表格結構圖片抽出為附檔並留佔位引用。
可獨立執行python tools/convert/from_docx.py <file.docx> [-o out.md] [--dry-run]
"""
import argparse
import pathlib
import re
import docx
from docx.oxml.ns import qn
from docx.table import Table
from docx.text.paragraph import Paragraph
def _heading_level(par):
# ponytail: 只認 "Heading N" / "標題 N" 樣式名;其他語系的 Word 樣式名不涵蓋,
# 升級路徑:改讀 w:outlineLvl。
m = re.match(r"(?:Heading|標題)\s*(\d)", par.style.name or "")
return int(m.group(1)) if m else None
def _table_md(tbl):
rows = [
[c.text.strip().replace("\n", " ").replace("|", "\\|") for c in row.cells]
for row in tbl.rows
]
if not rows:
return ""
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
out = ["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width]
out += ["| " + " | ".join(r) + " |" for r in rows[1:]]
return "\n".join(out)
def _par_md(par, doc, assets_dir, md_dir, counter):
parts = []
for run in par.runs:
for blip in run._element.findall(".//" + qn("a:blip")):
rid = blip.get(qn("r:embed"))
if not rid:
continue
part = doc.part.related_parts[rid]
ext = pathlib.Path(part.partname).suffix or ".png"
counter[0] += 1
name = f"img{counter[0]}{ext}"
if assets_dir is not None:
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / name).write_bytes(part.blob)
rel = (assets_dir / name).relative_to(md_dir).as_posix()
else:
rel = name # dry-run僅佔位
# 連結目標以 <> 包住:來源檔名可能含空格或不平衡括號,兩者都會讓
# Markdown 連結失效(圖片存在卻整行退化成純文字)
parts.append(f"![{name}](<{rel}>)")
parts.append(run.text)
text = "".join(parts).strip()
lvl = _heading_level(par)
if lvl and text:
return "#" * lvl + " " + text
return text
def convert(src, assets_dir=None, md_dir=None):
"""回傳 (markdown, status)。status 恆為 'ok'docx 必有文字層)。"""
src = pathlib.Path(src)
md_dir = pathlib.Path(md_dir) if md_dir else src.parent
doc = docx.Document(str(src))
counter = [0]
blocks = []
for child in doc.element.body.iterchildren():
if child.tag == qn("w:p"):
blocks.append(_par_md(Paragraph(child, doc), doc, assets_dir, md_dir, counter))
elif child.tag == qn("w:tbl"):
blocks.append(_table_md(Table(child, doc)))
md = "\n\n".join(b for b in blocks if b)
return md, "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, "
f"{md.count(chr(10)) + 1} lines")
return
out = pathlib.Path(a.output) if a.output else pathlib.Path(a.src).with_suffix(".md")
md, _ = convert(a.src, assets_dir=out.parent / f"{out.stem}-assets", md_dir=out.parent)
out.write_text(md, encoding="utf-8")
print(out)
if __name__ == "__main__":
main()