215 lines
7.9 KiB
Python
215 lines
7.9 KiB
Python
#!/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()
|