"""Word (.docx) → Markdown。保留標題層級與表格結構,圖片抽出為附檔並留佔位引用。 可獨立執行:python tools/convert/from_docx.py [-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()