#!/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""" """ ROOT_RELS = f""" """ DOCUMENT_RELS = f""" """ 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 = "" if bold else "" return ( f'{props}' f'{xml_text(text)}' ) def paragraph(parts: str, style: str | None = None) -> str: ppr = f'' if style else "" return f"{ppr}{parts}" 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(" 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'' f"{paragraph(run(text))}" ) def table_row(cells: list[str]) -> str: padded = cells + [""] * (col_count - len(cells)) return f"{''.join(cell(c) for c in padded[:col_count])}" grid_cols = "".join(f'' for _ in range(col_count)) tbl_pr = ( "" '' "" '' '' '' '' '' '' "" "" ) parts = ["", tbl_pr, f"{grid_cols}"] if headers: parts.append(table_row(headers)) parts.extend(table_row(r) for r in rows) parts.append("") 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'' f'' ) inner = "".join(body) + sect_pr return ( f'' f'' f"{inner}" ) def core_xml(title: str) -> str: return ( f'' f'' f"{xml_text(title)}" f"" ) 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 (或 - 读 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()