#!/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()