fix(skills): 禁止 HTML base64 内嵌 docx,新增 docx-generate 技能
避免 Agent 在下载页用 data URI 嵌入 Word 导致文件截断乱码;规范公网下载须单独落盘并用相对路径链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
#!/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 xml_text(text: str) -> str:
|
||||
return escape(str(text or ""))
|
||||
|
||||
|
||||
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:
|
||||
return paragraph(run(text, bold=True))
|
||||
|
||||
|
||||
def body_paragraph(text: str) -> str:
|
||||
return paragraph(run(text))
|
||||
|
||||
|
||||
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>"
|
||||
)
|
||||
# Table must be a direct child of w:body — never wrap w:tbl inside w:p.
|
||||
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:
|
||||
body.append(heading(title))
|
||||
body.append(body_paragraph(""))
|
||||
|
||||
for section in sections:
|
||||
heading_text = section.get("heading") or section.get("title")
|
||||
if heading_text:
|
||||
body.append(heading(str(heading_text)))
|
||||
for para in section.get("paragraphs") or []:
|
||||
text = str(para).strip()
|
||||
if text and not is_ooxml_fragment(text):
|
||||
body.append(body_paragraph(text))
|
||||
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)
|
||||
body.append(body_paragraph(""))
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user