From d67adc1df8cd0950b4977f47f458b9b4e5ca6769 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 11:19:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(skills):=20=E7=A6=81=E6=AD=A2=20HTML=20base?= =?UTF-8?q?64=20=E5=86=85=E5=B5=8C=20docx=EF=BC=8C=E6=96=B0=E5=A2=9E=20doc?= =?UTF-8?q?x-generate=20=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 Agent 在下载页用 data URI 嵌入 Word 导致文件截断乱码;规范公网下载须单独落盘并用相对路径链接。 Co-authored-by: Cursor --- skills-registry.mjs | 1 + skills/docx-generate/SKILL.md | 80 +++++++++++ skills/docx-generate/generate_docx.py | 191 ++++++++++++++++++++++++++ skills/static-page-publish/SKILL.md | 7 + user-publish.mjs | 1 + 5 files changed, 280 insertions(+) create mode 100644 skills/docx-generate/SKILL.md create mode 100644 skills/docx-generate/generate_docx.py diff --git a/skills-registry.mjs b/skills-registry.mjs index 900cddf..0c18873 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -17,6 +17,7 @@ export const DEFAULT_USER_SKILLS = { 'form-builder': true, 'table-viewer': true, 'product-campaign-page': true, + 'docx-generate': true, [PUBLISH_SKILL_NAME]: false, }; diff --git a/skills/docx-generate/SKILL.md b/skills/docx-generate/SKILL.md new file mode 100644 index 0000000..4dbf011 --- /dev/null +++ b/skills/docx-generate/SKILL.md @@ -0,0 +1,80 @@ +--- +name: docx-generate +description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .docx,无需 python-docx 或 docx_tool +--- + +# Word 文档生成(.docx) + +## 重要说明 + +- **平台没有 `docx_tool` / `update_doc`**,不要编造或调用不存在的工具 +- **`write_file` 不能写二进制 .docx**,不要用 write_file 假装生成 Word +- **禁止**在 HTML 里用 `data:application/...;base64,...` 内嵌 docx(极易被截断损坏,下载后乱码) +- 需要公网下载时:先用本脚本生成 `.docx` 落盘,再在 HTML 里用**相对路径**链接(如 `public/方案.docx`) +- 生产沙箱通常**不能 pip install**,优先用本技能自带的 **stdlib 脚本** +- 生成后必须用 **`list_dir oa/`**(或目标目录)确认文件已落盘,再告诉用户 + +## 何时使用 + +- 用户要 Word / docx / .doc 文档(输出 `.docx`) +- 需要保存到 `oa/`、`private/` 等分区 + +## 推荐命令 + +技能目录内有 `generate_docx.py`(仅依赖 Python 3 标准库): + +```bash +python3 .agents/skills/docx-generate/generate_docx.py --json - --output oa/报告.docx <<'EOF' +{ + "title": "文档标题", + "sections": [ + { + "heading": "一、章节标题", + "paragraphs": ["段落一", "段落二"], + "table": { + "headers": ["列1", "列2"], + "rows": [["A", "B"], ["C", "D"]] + } + } + ] +} +EOF +``` + +然后: + +```bash +list_dir oa +``` + +## JSON 字段 + +| 字段 | 说明 | +|------|------| +| `title` | 文档主标题(可选) | +| `sections[]` | 章节数组 | +| `sections[].heading` | 章节标题 | +| `sections[].paragraphs` | 字符串段落列表 | +| `sections[].table.headers` | 表头 | +| `sections[].table.rows` | 表格行 | + +**禁止**把 `` 等 OOXML 标签写进 `paragraphs` 文本里;表格只能走 `table` 字段。 + +## 公网下载页(HTML + docx) + +用户要「打开链接下载 Word」时: + +1. 用本脚本生成 docx(建议 `public/文件名.docx` 或 `oa/文件名.docx` 再复制到 `public/`) +2. 用 `static-page-publish` 写下载页,`href` 指向**同目录相对路径**: + +```html +下载 Word 文档 +``` + +3. **禁止** `href="data:...;base64,..."` 嵌入 docx + +## 备选方案 + +1. **用户要在线查看、可分享**:用 `static-page-publish` 技能写 `public/xxx.html` +2. **环境有 python-docx**(工作区 `.venv` 已装):仍可用,但生成后必须 `list_dir` 验证 +3. **禁止**在未验证文件存在时宣称「已生成 docx」 diff --git a/skills/docx-generate/generate_docx.py b/skills/docx-generate/generate_docx.py new file mode 100644 index 0000000..20c1945 --- /dev/null +++ b/skills/docx-generate/generate_docx.py @@ -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""" + + + + + +""" + +ROOT_RELS = f""" + + + +""" + +DOCUMENT_RELS = f""" +""" + + +def xml_text(text: str) -> str: + return escape(str(text or "")) + + +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: + 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(" 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 = ( + "" + '' + "" + '' + '' + '' + '' + '' + '' + "" + "" + ) + # Table must be a direct child of w:body — never wrap w:tbl inside w:p. + 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: + 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'' + 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() diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 65ac9ff..f316b3f 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -19,6 +19,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 4. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 5. 静态文件保存即可访问,**无需重启** +6. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏) 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 @@ -75,3 +76,9 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 **禁止**省略 mindspace-cover 或填写与页面无关的通用配色;促销/运动/品牌页必须写明 `tag`、`accent` 和 `cover`。 本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` + +## 附带文件下载(Word / PDF) + +- 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`) +- 下载按钮示例:`下载文档`(与 HTML 同目录时用文件名即可) +- **禁止** `` 内嵌 docx/pdf diff --git a/user-publish.mjs b/user-publish.mjs index 274840b..16e93d2 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -203,6 +203,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - 不要写入当前工作区以外的任何目录 - shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖 +- **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能) ## 示例