Files
memind/skills/docx-generate/generate_docx.py

215 lines
7.9 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.
#!/usr/bin/env python3
"""Generate a minimal valid .docx using only Python stdlib (zipfile + XML)."""
from __future__ import annotations
import argparse
import json
import sys
import zipfile
from pathlib import Path
from xml.sax.saxutils import escape
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CP_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
DC_NS = "http://purl.org/dc/elements/1.1"
CONTENT_TYPES = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="{CT_NS}">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
</Types>"""
ROOT_RELS = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="{REL_NS}">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>"""
DOCUMENT_RELS = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="{REL_NS}"/>"""
def sanitize_text(text: str) -> str:
"""Strip emoji and XML-illegal control chars (Word/iOS rejects these)."""
cleaned = str(text or "")
cleaned = cleaned.encode("utf-8", "ignore").decode("utf-8")
cleaned = "".join(
ch
for ch in cleaned
if not (0xD800 <= ord(ch) <= 0xDFFF)
and (ord(ch) == 0x9 or ord(ch) == 0xA or ord(ch) == 0xD or ord(ch) >= 0x20)
and not (0x1F000 <= ord(ch) <= 0x1FAFF or 0x2600 <= ord(ch) <= 0x27BF)
)
return cleaned.strip()
def xml_text(text: str) -> str:
return escape(sanitize_text(text))
def run(text: str, bold: bool = False) -> str:
props = "<w:b/>" if bold else ""
return (
f'<w:r><w:rPr>{props}</w:rPr>'
f'<w:t xml:space="preserve">{xml_text(text)}</w:t></w:r>'
)
def paragraph(parts: str, style: str | None = None) -> str:
ppr = f'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
return f"<w:p>{ppr}{parts}</w:p>"
def heading(text: str) -> str:
cleaned = sanitize_text(text)
if not cleaned:
return ""
return paragraph(run(cleaned, bold=True))
def body_paragraph(text: str) -> str:
cleaned = sanitize_text(text)
if not cleaned:
return ""
return paragraph(run(cleaned))
def is_ooxml_fragment(text: str) -> bool:
stripped = str(text or "").strip()
return stripped.startswith("<w:") or "</w:" in stripped
def table_block(headers: list[str], rows: list[list[str]]) -> str:
col_count = max(len(headers), max((len(r) for r in rows), default=0))
if col_count == 0:
return ""
col_width = max(1800, 9000 // col_count)
def cell(text: str) -> str:
return (
f'<w:tc><w:tcPr><w:tcW w:w="{col_width}" w:type="dxa"/></w:tcPr>'
f"{paragraph(run(text))}</w:tc>"
)
def table_row(cells: list[str]) -> str:
padded = cells + [""] * (col_count - len(cells))
return f"<w:tr>{''.join(cell(c) for c in padded[:col_count])}</w:tr>"
grid_cols = "".join(f'<w:gridCol w:w="{col_width}"/>' for _ in range(col_count))
tbl_pr = (
"<w:tblPr>"
'<w:tblW w:w="5000" w:type="pct"/>'
"<w:tblBorders>"
'<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
'<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
'<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
'<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
'<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
'<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
"</w:tblBorders>"
"</w:tblPr>"
)
parts = ["<w:tbl>", tbl_pr, f"<w:tblGrid>{grid_cols}</w:tblGrid>"]
if headers:
parts.append(table_row(headers))
parts.extend(table_row(r) for r in rows)
parts.append("</w:tbl>")
return "".join(parts)
def build_document_xml(title: str, sections: list[dict]) -> str:
body: list[str] = []
if title:
block = heading(title)
if block:
body.append(block)
for section in sections:
heading_text = section.get("heading") or section.get("title")
if heading_text:
block = heading(str(heading_text))
if block:
body.append(block)
for para in section.get("paragraphs") or []:
text = str(para).strip()
if text and not is_ooxml_fragment(text):
block = body_paragraph(text)
if block:
body.append(block)
table = section.get("table")
if isinstance(table, dict):
block = table_block(
list(table.get("headers") or []),
[list(r) for r in (table.get("rows") or [])],
)
if block:
body.append(block)
sect_pr = (
f'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
f'<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>'
)
inner = "".join(body) + sect_pr
return (
f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<w:document xmlns:w="{W_NS}" xmlns:r="{R_NS}">'
f"<w:body>{inner}</w:body></w:document>"
)
def core_xml(title: str) -> str:
return (
f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<cp:coreProperties xmlns:cp="{CP_NS}" xmlns:dc="{DC_NS}">'
f"<dc:title>{xml_text(title)}</dc:title>"
f"</cp:coreProperties>"
)
def write_docx(output: Path, title: str, sections: list[dict]) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
document_xml = build_document_xml(title, sections)
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", CONTENT_TYPES)
zf.writestr("_rels/.rels", ROOT_RELS)
zf.writestr("word/_rels/document.xml.rels", DOCUMENT_RELS)
zf.writestr("word/document.xml", document_xml)
zf.writestr("docProps/core.xml", core_xml(title))
def load_payload(args: argparse.Namespace) -> dict:
if args.json:
source = sys.stdin.read() if args.json == "-" else Path(args.json).read_text(encoding="utf-8")
return json.loads(source)
if args.title:
return {"title": args.title, "sections": [{"paragraphs": args.paragraph or []}]}
raise SystemExit("需要 --json <file>(或 - 读 stdin),或 --title")
def main() -> None:
parser = argparse.ArgumentParser(description="用 Python 标准库生成 .docx(无需 python-docx")
parser.add_argument("--output", required=True, help="输出路径,如 oa/报告.docx")
parser.add_argument("--json", help="JSON 内容文件;传 - 则从 stdin 读取")
parser.add_argument("--title", help="仅标题 + --paragraph 时的文档标题")
parser.add_argument("--paragraph", action="append", help="配合 --title 追加段落")
args = parser.parse_args()
payload = load_payload(args)
title = str(payload.get("title") or "")
sections = list(payload.get("sections") or [])
output = Path(args.output)
write_docx(output, title, sections)
size = output.stat().st_size
print(f"已生成 {output}{size} 字节)")
if __name__ == "__main__":
main()