fix(skills): 禁止 HTML base64 内嵌 docx,新增 docx-generate 技能
避免 Agent 在下载页用 data URI 嵌入 Word 导致文件截断乱码;规范公网下载须单独落盘并用相对路径链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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」
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -203,6 +203,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
- 不要写入当前工作区以外的任何目录
|
||||
- shell 仅用于本目录内整理文件/简单脚本;不要 \`rm -rf\` 越界路径、不要安装系统级依赖
|
||||
- **禁止**在 HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能)
|
||||
|
||||
## 示例
|
||||
|
||||
|
||||
Reference in New Issue
Block a user