97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
"""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:僅佔位
|
||
parts.append(f"")
|
||
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()
|