fix(skills): 禁止 HTML base64 内嵌 docx,新增 docx-generate 技能

避免 Agent 在下载页用 data URI 嵌入 Word 导致文件截断乱码;规范公网下载须单独落盘并用相对路径链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 11:19:20 +08:00
parent 637b32515a
commit d67adc1df8
5 changed files with 280 additions and 0 deletions
+1
View File
@@ -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,
};
+80
View File
@@ -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` | 表格行 |
**禁止**把 `<w:tbl>` 等 OOXML 标签写进 `paragraphs` 文本里;表格只能走 `table` 字段。
## 公网下载页(HTML + docx
用户要「打开链接下载 Word」时:
1. 用本脚本生成 docx(建议 `public/文件名.docx``oa/文件名.docx` 再复制到 `public/`
2.`static-page-publish` 写下载页,`href` 指向**同目录相对路径**
```html
<a href="medical-plan.docx" download>下载 Word 文档</a>
```
3. **禁止** `href="data:...;base64,..."` 嵌入 docx
## 备选方案
1. **用户要在线查看、可分享**:用 `static-page-publish` 技能写 `public/xxx.html`
2. **环境有 python-docx**(工作区 `.venv` 已装):仍可用,但生成后必须 `list_dir` 验证
3. **禁止**在未验证文件存在时宣称「已生成 docx」
+191
View File
@@ -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()
+7
View File
@@ -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/`
- 下载按钮示例:`<a href="report.docx" download>下载文档</a>`(与 HTML 同目录时用文件名即可)
- **禁止** `<a href="data:application/vnd...;base64,...">` 内嵌 docx/pdf
+1
View File
@@ -203,6 +203,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
- 不要写入当前工作区以外的任何目录
- shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖
- **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能)
## 示例