Files
CraftKit/plugins/doc/skills/docx-to-md/scripts/convert.py
T

330 lines
14 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.
#!/usr/bin/env python3
"""使用 Python 标准库将 DOCX 的常见内容转换为 Markdown。"""
from __future__ import annotations
import argparse
import hashlib
import re
import sys
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from xml.etree import ElementTree as ET
NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"pr": "http://schemas.openxmlformats.org/package/2006/relationships",
}
def qname(prefix: str, local: str) -> str:
"""生成 ElementTree 使用的完整命名空间标签。"""
return f"{{{NS[prefix]}}}{local}"
def escape_markdown(text: str) -> str:
"""转义会破坏普通行结构的 Markdown 字符。"""
return text.replace("\\", "\\\\").replace("|", "\\|")
@dataclass
class Result:
"""记录转换产物与可向用户展示的统计信息。"""
output_file: Path
images_dir: Path
paragraphs: int = 0
tables: int = 0
images: int = 0
warnings: list[str] = field(default_factory=list)
class DocxConverter:
"""读取 DOCX 包并转换常见 OOXML 块级元素。"""
def __init__(self, source: Path, output_dir: Path, force: bool = False) -> None:
self.source = source
self.output_dir = output_dir
self.force = force
self.output_file = output_dir / f"{source.stem}.md"
self.images_dir = output_dir / "images"
self.relationships: dict[str, tuple[str, str]] = {}
self.heading_styles: dict[str, int] = {}
self.number_formats: dict[str, str] = {}
self.image_names: dict[str, str] = {}
self.result = Result(self.output_file, self.images_dir)
def convert(self) -> Result:
"""验证目标、解析文档并写入 Markdown 与图片。"""
if self.output_dir.exists() and not self.force:
raise FileExistsError(f"输出目录已存在:{self.output_dir}")
self.output_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(self.source) as archive:
names = set(archive.namelist())
if "word/document.xml" not in names:
raise ValueError("文件不是有效的 DOCX:缺少 word/document.xml")
self._load_relationships(archive, names)
self._load_styles(archive, names)
self._load_numbering(archive, names)
self._detect_unsupported_parts(names)
root = ET.fromstring(archive.read("word/document.xml"))
body = root.find("w:body", NS)
if body is None:
raise ValueError("文件不是有效的 DOCX:缺少正文节点")
blocks: list[str] = []
for child in body:
if child.tag == qname("w", "p"):
rendered = self._render_paragraph(child, archive)
if rendered:
blocks.append(rendered)
self.result.paragraphs += 1
elif child.tag == qname("w", "tbl"):
rendered = self._render_table(child, archive)
if rendered:
blocks.append(rendered)
self.result.tables += 1
markdown = "\n\n".join(blocks).strip() + "\n"
self.output_file.write_text(markdown, encoding="utf-8")
return self.result
def _load_relationships(self, archive: zipfile.ZipFile, names: set[str]) -> None:
"""读取超链接和媒体关系,后续按关系 ID 解析目标。"""
path = "word/_rels/document.xml.rels"
if path not in names:
return
root = ET.fromstring(archive.read(path))
for rel in root.findall("pr:Relationship", NS):
rel_id = rel.get("Id")
target = rel.get("Target")
rel_type = rel.get("Type", "").rsplit("/", 1)[-1]
if rel_id and target:
self.relationships[rel_id] = (rel_type, target)
def _load_styles(self, archive: zipfile.ZipFile, names: set[str]) -> None:
"""识别标题样式 ID;同时兼容英文 Heading 与中文标题名称。"""
if "word/styles.xml" not in names:
return
root = ET.fromstring(archive.read("word/styles.xml"))
for style in root.findall("w:style", NS):
if style.get(qname("w", "type")) != "paragraph":
continue
style_id = style.get(qname("w", "styleId"), "")
name_node = style.find("w:name", NS)
style_name = name_node.get(qname("w", "val"), "") if name_node is not None else ""
match = re.search(r"(?:heading|标题)\s*([1-6])", style_name, re.IGNORECASE)
if not match:
match = re.search(r"heading([1-6])", style_id, re.IGNORECASE)
if match:
self.heading_styles[style_id] = int(match.group(1))
def _load_numbering(self, archive: zipfile.ZipFile, names: set[str]) -> None:
"""建立编号实例到列表类型的基础映射。"""
if "word/numbering.xml" not in names:
return
root = ET.fromstring(archive.read("word/numbering.xml"))
abstract_formats: dict[str, str] = {}
for abstract in root.findall("w:abstractNum", NS):
abstract_id = abstract.get(qname("w", "abstractNumId"), "")
level = abstract.find("w:lvl", NS)
fmt = level.find("w:numFmt", NS) if level is not None else None
if fmt is not None:
abstract_formats[abstract_id] = fmt.get(qname("w", "val"), "bullet")
for number in root.findall("w:num", NS):
number_id = number.get(qname("w", "numId"), "")
abstract = number.find("w:abstractNumId", NS)
if abstract is not None:
abstract_id = abstract.get(qname("w", "val"), "")
self.number_formats[number_id] = abstract_formats.get(abstract_id, "bullet")
def _detect_unsupported_parts(self, names: set[str]) -> None:
"""对存在但当前不能可靠转换的部件给出明确警告。"""
checks = {
"word/comments.xml": "文档包含批注,当前转换器不会输出批注内容",
"word/footnotes.xml": "文档包含脚注,当前转换器不会输出脚注内容",
"word/endnotes.xml": "文档包含尾注,当前转换器不会输出尾注内容",
}
for path, warning in checks.items():
if path in names:
self.result.warnings.append(warning)
if any(name.startswith("word/header") for name in names):
self.result.warnings.append("文档包含页眉,当前转换器不会输出页眉内容")
if any(name.startswith("word/footer") for name in names):
self.result.warnings.append("文档包含页脚,当前转换器不会输出页脚内容")
if any(name.startswith("word/embeddings/") for name in names):
self.result.warnings.append("文档包含内嵌附件,当前转换器不会提取附件")
def _render_paragraph(self, paragraph: ET.Element, archive: zipfile.ZipFile) -> str:
"""转换段落、超链接、基础行内格式和段落内图片。"""
parts: list[str] = []
for child in paragraph:
if child.tag == qname("w", "r"):
parts.append(self._render_run(child, archive))
elif child.tag == qname("w", "hyperlink"):
text = "".join(self._render_run(run, archive) for run in child.findall("w:r", NS))
rel_id = child.get(qname("r", "id"))
relation = self.relationships.get(rel_id or "")
if text and relation and relation[0] == "hyperlink":
parts.append(f"[{text}]({relation[1]})")
else:
parts.append(text)
text = "".join(parts).strip()
if not text:
return ""
properties = paragraph.find("w:pPr", NS)
if properties is not None:
style = properties.find("w:pStyle", NS)
style_id = style.get(qname("w", "val"), "") if style is not None else ""
if style_id in self.heading_styles:
return f"{'#' * self.heading_styles[style_id]} {text}"
numbering = properties.find("w:numPr", NS)
if numbering is not None:
level = numbering.find("w:ilvl", NS)
number = numbering.find("w:numId", NS)
depth = int(level.get(qname("w", "val"), "0")) if level is not None else 0
number_id = number.get(qname("w", "val"), "") if number is not None else ""
marker = "-" if self.number_formats.get(number_id, "bullet") == "bullet" else "1."
return f"{' ' * depth}{marker} {text}"
return text
def _render_run(self, run: ET.Element, archive: zipfile.ZipFile) -> str:
"""转换单个文字区段,并在当前位置追加图片引用。"""
chunks: list[str] = []
for child in run:
if child.tag == qname("w", "t"):
chunks.append(child.text or "")
elif child.tag in {qname("w", "br"), qname("w", "cr")}:
chunks.append(" \n")
elif child.tag == qname("w", "tab"):
chunks.append(" ")
elif child.tag == qname("w", "drawing"):
chunks.extend(self._render_images(child, archive))
text = "".join(chunks)
if not text:
return ""
properties = run.find("w:rPr", NS)
if properties is not None:
if properties.find("w:b", NS) is not None:
text = f"**{text}**"
if properties.find("w:i", NS) is not None:
text = f"*{text}*"
return text
def _render_images(self, drawing: ET.Element, archive: zipfile.ZipFile) -> list[str]:
"""提取 drawing 关系指向的包内媒体,使用内容哈希稳定命名。"""
rendered: list[str] = []
for blip in drawing.findall(".//a:blip", NS):
rel_id = blip.get(qname("r", "embed"))
relation = self.relationships.get(rel_id or "")
if not relation or relation[0] != "image":
continue
target = PurePosixPath("word") / PurePosixPath(relation[1])
normalized = str(PurePosixPath(*[part for part in target.parts if part not in {".", ".."}]))
try:
data = archive.read(normalized)
except KeyError:
self.result.warnings.append(f"图片关系无法读取:{relation[1]}")
continue
if normalized not in self.image_names:
suffix = Path(normalized).suffix.lower() or ".bin"
filename = f"image-{hashlib.sha256(data).hexdigest()[:12]}{suffix}"
self.images_dir.mkdir(parents=True, exist_ok=True)
(self.images_dir / filename).write_bytes(data)
self.image_names[normalized] = filename
self.result.images += 1
rendered.append(f"![图片](images/{self.image_names[normalized]})")
return rendered
def _render_table(self, table: ET.Element, archive: zipfile.ZipFile) -> str:
"""将普通表格转换为 Markdown;复杂合并关系以警告提示。"""
rows: list[list[str]] = []
merged = False
for row in table.findall("w:tr", NS):
cells: list[str] = []
for cell in row.findall("w:tc", NS):
if cell.find(".//w:gridSpan", NS) is not None or cell.find(".//w:vMerge", NS) is not None:
merged = True
paragraphs = [self._render_paragraph(p, archive) for p in cell.findall("w:p", NS)]
content = "<br>".join(part for part in paragraphs if part)
cells.append(escape_markdown(content))
rows.append(cells)
if not rows:
return ""
width = max(len(row) for row in rows)
padded = [row + [""] * (width - len(row)) for row in rows]
header = padded[0]
lines = [f"| {' | '.join(header)} |", f"| {' | '.join(['---'] * width)} |"]
lines.extend(f"| {' | '.join(row)} |" for row in padded[1:])
if merged:
self.result.warnings.append("文档包含合并单元格,Markdown 表格可能无法保持原结构")
return "\n".join(lines)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="将 DOCX 的常见内容转换为 Markdown")
parser.add_argument("input", type=Path, help="输入 .docx 文件")
parser.add_argument("--output-dir", type=Path, help="输出目录,默认位于输入文件旁的同名目录")
parser.add_argument("--force", action="store_true", help="允许覆盖同名生成文件,但不删除其他文件")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
"""命令行入口,失败时返回非零状态。"""
args = parse_args(argv)
source = args.input.resolve()
if not source.is_file():
print(f"错误:输入文件不存在:{source}", file=sys.stderr)
return 2
if source.suffix.lower() != ".docx":
print("错误:只支持 .docx 文件", file=sys.stderr)
return 2
output_dir = (args.output_dir or source.with_suffix("")).resolve()
try:
result = DocxConverter(source, output_dir, args.force).convert()
except (FileExistsError, ValueError, zipfile.BadZipFile, OSError) as error:
print(f"错误:{error}", file=sys.stderr)
return 1
print(f"Markdown:{result.output_file}")
print(f"段落:{result.paragraphs},表格:{result.tables},图片:{result.images}")
if result.warnings:
print("警告:")
for warning in dict.fromkeys(result.warnings):
print(f"- {warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())