Files
pp-qa-km/tools/convert/from_docx.py
2026-07-14 20:51:17 +08:00

97 lines
3.3 KiB
Python
Raw 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僅佔位
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()