feat: excel word skill

This commit is contained in:
hp0912 2026-07-26 02:45:00 +08:00
parent b3207088b7
commit b81403f199
24 changed files with 7174 additions and 0 deletions

332
skills/docx/SKILL.md Normal file
View File

@ -0,0 +1,332 @@
---
name: docx
description: "创建、读取、编辑、转换、批注、接受修订、校验和渲染本地或远程 HTTPS Microsoft Word 文档。用户提到 Word、文档、报告、备忘录、合同、信函、模板、目录、页眉页脚、页码、表格、图片、批注或修订或提供 HTTPS Word 地址、.docx、.dotx、.doc 文件时使用;支持安全下载、结构化创建、跨 Run 查找替换、安全 OOXML 解包/打包、旧格式转换、关系与 XML 校验及逐页视觉检查。若主要交付物是 PDF、电子表格、Google Docs 或普通代码,则不要使用。"
---
# Word 文档处理
## 强制执行规则
当前智能体不能直接执行 shell、任意 Python 代码或系统命令。只能通过 `execute_skill_script` 调用本 Skill 中真实存在的固定 Python 脚本。
- 只调用下表列出的可执行脚本,不执行 `scripts/` 目录、`scripts/_docx_common.py` 或 `scripts/_document_builder.py`
- 不把 `python3`、`soffice`、`libreoffice`、`pandoc`、`pdftoppm`、`zip`、`unzip`、`find`、`rm` 或其他系统命令作为脚本参数。
- LibreOffice、Pandoc、Poppler 和 ZIP 操作只允许由固定 Python 脚本在内部调用。
- 每次检查脚本返回 JSON只有 `ok``true` 时才继续。`validate_document.py` 还必须返回 `status: valid`
- 远程地址只交给 `download_document.py`;不要在回复、日志摘要或文件名中复述可能含敏感查询参数的完整 URL。
- 不覆盖用户提供的源文件。最终结果写入 `output/docx/`,中间产物写入 `tmp/docx/<任务名>/`
- 环境已预置依赖,不安装软件包,也不提示用户安装依赖。
## 脚本清单
| 脚本 | 用途 | 底层能力 |
| --- | --- | --- |
| `scripts/download_document.py` | 下载并校验远程 HTTPS Word 文档 | Python `urllib`、安全 OOXML 解析、`python-docx` |
| `scripts/inspect_document.py` | 分段读取正文、表格、样式、批注和修订 | `python-docx`、安全 OOXML 解析 |
| `scripts/create_document.py` | 按受控 JSON 创建专业 DOCX | `python-docx`、Pillow |
| `scripts/edit_document.py` | 查找替换、追加/插入内容、调整样式和页面 | `python-docx` |
| `scripts/add_comment.py` | 给精确文本范围添加批注 | `python-docx` |
| `scripts/accept_changes.py` | 接受文档中的全部修订 | 固定 LibreOffice 宏 |
| `scripts/convert_document.py` | `.doc/.dotx/.docx` 转 DOCX/PDF/Markdown/文本 | LibreOffice、Pandoc、`python-docx` |
| `scripts/unpack_document.py` | 安全解包 OOXML 供高级编辑 | Python `zipfile` |
| `scripts/pack_document.py` | 把 OOXML 目录安全打包为 DOCX | Python `zipfile`、`python-docx` |
| `scripts/validate_document.py` | 校验 ZIP、XML、关系、批注、修订和可渲染性 | `defusedxml`、`python-docx`、LibreOffice |
| `scripts/render_document.py` | 把 Word 文档渲染为逐页 PNG/PDF | LibreOffice、Poppler |
## 标准流程
1. 输入是 HTTPS 地址时,先调用 `download_document.py` 下载到本次任务临时目录;本地文件直接进入下一步。
2. 旧版 `.doc` 或模板 `.dotx` 先调用 `convert_document.py` 转为 `.docx`;保留原文件。
3. 编辑、总结或重组现有文档前调用 `inspect_document.py`,确认段落、表格、章节、页眉页脚、批注和修订状态。
4. 新建文档使用 `create_document.py`;常规编辑使用 `edit_document.py`;添加批注使用 `add_comment.py`
5. 输入有修订时,先确认用户希望保留还是接受。普通编辑脚本默认拒绝含修订的文档,避免把修订静默损坏。
6. 只有固定编辑脚本不能完成的 OOXML 高级需求,才使用 `unpack_document.py` → 编辑 XML 文件 → `pack_document.py`;不得直接运行 ZIP 或 shell 命令。
7. 所有创建或修改结果必须调用 `validate_document.py --check-convert`,确保 `status: valid`、`issue_count: 0`。
8. 再调用 `render_document.py` 渲染全部页面,逐页检查版式;有游标时继续到 `has_more: false`
9. 结构、内容、修订/批注和视觉检查都通过后才交付。
## 下载远程文档
只接受 HTTPS 地址。完整保留 URL 及查询参数传给脚本,但不要在回复、日志摘要或输出文件名中复述敏感参数。
调用 `scripts/download_document.py`
```text
--url 'https://example.com/report.docx?signature=...' --output 'tmp/docx/<任务名>/source.docx'
```
可选参数:
- `--timeout <1-600>`:连接和读取超时秒数,默认 `60`
- `--max-bytes <字节数>`:默认 `104857600`100 MiB最高 `536870912`512 MiB
- `--overwrite`:只在目标是本次任务生成的旧缓存时使用。
`output` 扩展名必须是 `.docx`、`.dotx` 或 `.doc`。脚本阻止 HTTPS 重定向降级到 HTTP流式限制大小先写同目录临时文件再原子发布DOCX/DOTX 会检查 ZIP 路径、成员大小、必要部件和内容类型,并用 `python-docx` 打开。实际 OOXML 格式与 `output` 扩展名不一致时,根据错误中的实际格式更正缓存扩展名,再调用同一脚本。
成功结果包含 `path`、`size_bytes`、`format` 和 `validation`OOXML 还包含段落、表格和章节数量。后续脚本只使用返回的本地 `path`,不再访问原 URL。
## 检查文档
调用 `scripts/inspect_document.py`
```text
--input 'source.docx'
```
可选参数:
- `--start-paragraph <索引>`、`--max-paragraphs <1-300>`:分段读取正文,索引从 `0` 开始。
- `--start-table <索引>`、`--max-tables <0-50>`、`--max-table-cells <数量>`:限制表格输出。
- `--max-chars <1000-200000>`:限制单次正文字符数。
- `--include-runs`:需要检查局部字体、粗体、斜体或跨 Run 替换问题时使用。
重点检查:
- `tracked_changes.total``authors`:是否存在修订及修订作者。
- `comments`:批注正文和作者。
- `sections`:纸张、方向、页边距、页眉、页脚。
- `archive.missing_required_parts`、`duplicate_members`:结构异常。
- `has_more`、`next_paragraph`、`next_table`:继续读取长文档。
## 创建文档
调用 `scripts/create_document.py`
```text
--output 'output/docx/result.docx' --spec '<JSON对象>'
```
内容较长时先把 JSON 写到任务临时目录,再传 `--spec-file`。目标是本次任务旧产物且确认可覆盖时才传 `--overwrite`
文档说明顶层结构:
```json
{
"properties": {
"title": "2026 年度经营报告",
"author": "示例公司",
"subject": "经营分析"
},
"page": {
"size": "A4",
"orientation": "portrait",
"margins": {
"top": 0.85,
"bottom": 0.85,
"left": 0.9,
"right": 0.9
}
},
"default_font": {
"name": "Arial",
"east_asia": "Noto Sans CJK SC",
"size": 10.5,
"line_spacing": 1.15,
"space_after": 6
},
"styles": {
"Title": {"size": 24, "bold": true, "color": "1F4E78"},
"Heading 1": {"size": 16, "bold": true, "color": "1F4E78"}
},
"header": {
"text": "示例公司 · 年度报告",
"alignment": "right"
},
"footer": {
"text": "",
"alignment": "center",
"page_number": true,
"page_number_prefix": "第 ",
"page_number_suffix": " 页"
},
"blocks": []
}
```
支持的 `blocks[].type`
| 类型 | 关键字段 |
| --- | --- |
| `paragraph` | `text``runs`;可选 `style/alignment/space_before/space_after/indent` |
| `heading` | `level`19、`text` 或 `runs` |
| `bullet_list` | `items[]`,可选 `level` |
| `numbered_list` | `items[]`,可选 `level` |
| `table` | `rows[][]`;可选 `column_widths/header_rows/style/header_fill/merges` |
| `image` | `path`;可选 `width_inches/height_inches/alignment/caption` |
| `toc` | 可选 `title`、`levels`,如 `1-3` |
| `horizontal_rule` | 可选 `color/size/style` |
| `page_break` | 无其他必填字段 |
| `section_break` | 可选 `break_type/page size/orientation/margins` |
| `spacer` | 可选 `points` |
带局部格式和链接的段落:
```json
{
"type": "paragraph",
"alignment": "justify",
"runs": [
{"text": "重要:", "bold": true, "color": "C00000"},
{"text": "本报告数据截至 2026-06-30。"},
{
"text": "查看来源",
"hyperlink": "https://example.com/source"
}
]
}
```
表格示例:
```json
{
"type": "table",
"rows": [
["指标", "本期", "同比"],
["收入", "1,250 万元", "12.5%"],
["毛利率", "38.2%", "2.1 个百分点"]
],
"column_widths": [2.2, 1.7, 1.7],
"header_rows": 1,
"header_fill": "1F4E78",
"style": "Table Grid"
}
```
`runs[]` 支持 `bold/italic/underline/strike/color/font/east_asia_font/size/superscript/subscript/style/hyperlink`。不要用换行符模拟段落或分页;使用独立 `paragraph``page_break`。项目符号和编号必须使用列表块,不要手写 `•` 或数字前缀。
## 编辑文档
调用 `scripts/edit_document.py`
```text
--input 'source.docx' --output 'output/docx/edited.docx' --spec '<JSON对象>'
```
JSON 顶层只有 `operations`。支持:
| `operations[].type` | 关键字段 |
| --- | --- |
| `replace_text` | `find`、`replace`;可选 `scope/match_case/whole_word/count/required` |
| `append_blocks` | `blocks[]`,格式与创建脚本相同 |
| `insert_blocks_after` | `find`、`blocks[]`;可选 `match: exact|contains` |
| `remove_paragraphs` | `text`;可选 `match/count/required` |
| `set_paragraph_style` | `style`,以及 `indexes[]``contains` |
| `set_properties` | `properties` |
| `set_page` | `page``section` 为索引或 `all` |
| `set_header_footer` | 可选 `header`、`footer` |
| `remove_tables` | `indexes[]` |
查找替换会处理 Word 把可见短语拆成多个 `<w:r>` 的情况,并尽量保留首个匹配 Run 的格式。默认范围 `all` 包含正文、表格、页眉和页脚;可指定 `body/tables/headers/footers`
输入含现有修订时默认停止:
- 用户希望干净副本:先用 `accept_changes.py`
- 用户明确要求保留修订:才传 `--allow-existing-revisions`;修改后的内容本身不会自动变成新的修订。
- 用户要求“所有修改都显示为修订”且固定脚本无法表达时,不要伪装完成;说明当前安全脚本只支持接受现有修订,不支持通用修订式编辑。
## 添加批注
调用 `scripts/add_comment.py`
```text
--input 'source.docx' --output 'output/docx/commented.docx' --find '费用上限' --comment '请确认该上限是否含税' --author '审阅人' --initials 'SR'
```
可选:
- `--scope <body|tables|headers|footers|all>`
- `--occurrence <序号>`:为全文第几个匹配添加批注,默认 `1`
- `--ignore-case`
脚本会在必要时拆分 Run让批注尽量精确锚定到目标文本而不是整个段落。
## 接受修订
调用 `scripts/accept_changes.py`
```text
--input 'redlined.docx' --output 'output/docx/clean.docx'
```
脚本只执行固定的“接受全部修订”宏,不能运行用户提供的宏。必须检查:
- `revision_markers_before` 大于 `0` 时,`revision_markers_after` 必须为 `0`
- `status` 必须为 `success`
- 之后仍要执行结构校验和逐页渲染,特别检查删除段落、编号列表和空白段落。
## 转换
调用 `scripts/convert_document.py`
```text
--input 'legacy.doc' --output 'tmp/docx/task/source.docx'
```
支持:
- `.doc` / `.dotx``.docx`
- `.docx``.pdf`,用于预览或用户明确要求的 PDF 副本。
- `.docx``.md` / `.txt`,默认按接受修订后的视图导出;可传 `--track-changes reject|all`
不要把转换为 Markdown 的结果当作版式等价副本;表格宽度、浮动图片、页眉页脚、脚注和分页可能简化。
## 高级 OOXML 编辑
只有 `edit_document.py` 无法完成且确实需要编辑底层部件时:
1. 调用 `scripts/unpack_document.py --input <docx> --output-dir <空目录>`
2. 用可用的文件编辑工具最小化修改 `word/document.xml` 或相关部件;不要重排、格式化或重写无关 XML。
3. 调用 `scripts/pack_document.py --input-dir <目录> --output <新docx>`
4. 调用 `validate_document.py --check-convert``render_document.py`
解包脚本拒绝路径穿越、符号链接和压缩炸弹;打包脚本拒绝缺少 `[Content_Types].xml`、`_rels/.rels` 或 `word/document.xml` 的目录。不要手动调用 `unzip`、`zip`、`find` 或删除命令。
## 校验
调用 `scripts/validate_document.py`
```text
--input 'output/docx/result.docx' --check-convert
```
必须满足:
- `status: valid`
- `issue_count: 0`
- `archive.missing_required_parts``duplicate_members` 为空。
- 批注引用完整,内部关系目标存在,所有 XML 可安全解析。
- 修订元素有作者和时间;干净副本的 `tracked_changes.total` 应为 `0`
- `render_check.success: true` 且页数大于 `0`
该检查验证结构和可打开性,不替代人工视觉检查。
## 渲染与视觉检查
调用 `scripts/render_document.py`
```text
--input 'output/docx/result.docx' --output-dir 'tmp/docx/task/rendered'
```
默认 150 DPI、单次最多 20 页。可传:
- `--start-page`、`--end-page`、`--max-pages`:分批渲染。
- `--dpi <72-300>`:小字、复杂表格或页眉脚注可提高到 180220。
- `--include-pdf`:同时保留 `document.pdf`
- `--overwrite`:只覆盖本次任务旧渲染。
`has_more: true`,用 `next_page` 继续。通过可用的图片查看工具逐页检查。
## 质量要求
- 默认采用 A4、合理页边距、清晰标题层级现有文档的规范优先。
- 中文使用 `Noto Sans CJK SC` 或原文档字体,拉丁文字使用 Arial不要依赖运行环境中不存在的专有字体。
- 标题必须使用内置 `Heading 1``Heading 9` 或具有大纲级别的样式,目录才能收录。
- 表格明确设置列宽、重复表头并禁止跨页拆分关键行;不要使用百分比列宽假设不同客户端一致。
- 表格底色使用明确填充色;不要用表格模拟水平线。
- 页码、目录和交叉引用使用字段,不手写空格或点号对齐。
- 图片保持纵横比,注明来源或说明文字,确认没有超出版心。
- 不用 `\n` 代替独立段落,不用空格填充对齐,不把分页符直接放在正文字符串中。
- 检查孤行孤字、标题落在页尾、表格断裂、图片拉伸、文字裁切、异常空白页、乱码、重叠和页码连续性。
- 最终文档必须内容准确、结构有效、可由 LibreOffice 打开,并通过全部页面视觉检查。

View File

@ -0,0 +1,4 @@
interface:
display_name: "Word 文档"
short_description: "安全下载、创建、编辑、校验并渲染专业 Word 文档"
default_prompt: "使用 $docx 创建或处理本地文件或 HTTPS 链接中的 Word 文档,并完成内容与版式校验。"

View File

@ -0,0 +1,767 @@
#!/usr/bin/env python3
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any, Iterable, Optional
from _docx_common import W_NS, input_file, qn
MAX_BLOCKS = 1_000
MAX_TABLE_CELLS = 20_000
MAX_IMAGES = 100
ALIGNMENTS = {
"left": 0,
"center": 1,
"right": 2,
"justify": 3,
"distribute": 4,
}
VERTICAL_ALIGNMENTS = {
"top": 0,
"center": 1,
"bottom": 3,
}
HEX_COLOR_LENGTHS = {6, 8}
def expect_object(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError(f"{label} 必须是对象")
return value
def expect_list(value: Any, label: str) -> list[Any]:
if not isinstance(value, list):
raise ValueError(f"{label} 必须是数组")
return value
def color(value: Any, label: str) -> str:
text = str(value).strip().lstrip("#")
if len(text) not in HEX_COLOR_LENGTHS:
raise ValueError(f"{label} 必须是 6 位或 8 位十六进制颜色")
try:
int(text, 16)
except ValueError as exc:
raise ValueError(f"{label} 必须是十六进制颜色") from exc
return text.upper()[-6:]
def set_run_font_name(run: Any, name: str, east_asia: Optional[str] = None) -> None:
run.font.name = name
fonts = run._element.get_or_add_rPr().get_or_add_rFonts()
fonts.set(qn("ascii"), name)
fonts.set(qn("hAnsi"), name)
fonts.set(qn("eastAsia"), east_asia or name)
def apply_run_style(run: Any, raw_spec: Any, defaults: Optional[dict[str, Any]] = None) -> None:
from docx.enum.text import WD_UNDERLINE
from docx.shared import Pt, RGBColor
spec = {**(defaults or {}), **expect_object(raw_spec, "run")}
allowed = {
"text",
"bold",
"italic",
"underline",
"strike",
"color",
"highlight",
"font",
"east_asia_font",
"size",
"superscript",
"subscript",
"small_caps",
"all_caps",
"style",
"hyperlink",
}
unknown = set(spec) - allowed
if unknown:
raise ValueError(f"run 包含未知字段:{sorted(unknown)}")
if "bold" in spec:
run.bold = bool(spec["bold"])
if "italic" in spec:
run.italic = bool(spec["italic"])
if "underline" in spec:
underline = spec["underline"]
if underline in {True, "single"}:
run.underline = True
elif underline in {False, None}:
run.underline = False
elif underline == "double":
run.underline = WD_UNDERLINE.DOUBLE
else:
raise ValueError("underline 仅支持 true、false、single、double")
if "strike" in spec:
run.font.strike = bool(spec["strike"])
if "color" in spec:
run.font.color.rgb = RGBColor.from_string(color(spec["color"], "run.color"))
if "font" in spec or "east_asia_font" in spec:
font_name = str(spec.get("font", "Arial"))
set_run_font_name(run, font_name, str(spec.get("east_asia_font", font_name)))
if "size" in spec:
size = float(spec["size"])
if size < 1 or size > 200:
raise ValueError("run.size 必须在 1 到 200 之间")
run.font.size = Pt(size)
for key in ("superscript", "subscript", "small_caps", "all_caps"):
if key in spec:
setattr(run.font, key, bool(spec[key]))
if "style" in spec:
run.style = str(spec["style"])
def _add_hyperlink(
paragraph: Any,
text: str,
url: str,
raw_spec: dict[str, Any],
) -> Any:
from docx.oxml import OxmlElement
from docx.opc.constants import RELATIONSHIP_TYPE
relationship_id = paragraph.part.relate_to(
url,
RELATIONSHIP_TYPE.HYPERLINK,
is_external=True,
)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(f"{{http://schemas.openxmlformats.org/officeDocument/2006/relationships}}id", relationship_id)
run = paragraph.add_run(text)
apply_run_style(run, raw_spec)
run_properties = run._element.get_or_add_rPr()
if run_properties.find(qn("color")) is None:
color_element = OxmlElement("w:color")
color_element.set(qn("val"), "0563C1")
run_properties.append(color_element)
if run_properties.find(qn("u")) is None:
underline = OxmlElement("w:u")
underline.set(qn("val"), "single")
run_properties.append(underline)
paragraph._p.remove(run._element)
hyperlink.append(run._element)
paragraph._p.append(hyperlink)
return run
def add_runs(
paragraph: Any,
*,
text: Optional[str] = None,
runs: Optional[list[Any]] = None,
defaults: Optional[dict[str, Any]] = None,
) -> None:
if runs is not None and text is not None:
raise ValueError("段落不能同时提供 text 和 runs")
if runs is None:
run = paragraph.add_run("" if text is None else str(text))
if defaults:
apply_run_style(run, defaults)
return
for index, raw_run in enumerate(runs):
if isinstance(raw_run, str):
run = paragraph.add_run(raw_run)
if defaults:
apply_run_style(run, defaults)
continue
spec = expect_object(raw_run, f"runs[{index}]")
run_text = str(spec.get("text", ""))
if spec.get("hyperlink"):
_add_hyperlink(
paragraph,
run_text,
str(spec["hyperlink"]),
{**(defaults or {}), **spec},
)
else:
run = paragraph.add_run(run_text)
apply_run_style(run, spec, defaults)
def apply_paragraph_format(paragraph: Any, raw_spec: Any) -> None:
from docx.shared import Inches, Pt
spec = expect_object(raw_spec, "paragraph")
if "style" in spec:
paragraph.style = str(spec["style"])
if "alignment" in spec:
alignment = str(spec["alignment"]).lower()
if alignment not in ALIGNMENTS:
raise ValueError(f"段落对齐方式无效:{alignment}")
paragraph.alignment = ALIGNMENTS[alignment]
paragraph_format = paragraph.paragraph_format
points = {
"space_before": "space_before",
"space_after": "space_after",
"line_spacing": "line_spacing",
}
for source, target in points.items():
if source in spec:
value = float(spec[source])
if value < 0 or value > 500:
raise ValueError(f"{source} 超出合理范围")
setattr(paragraph_format, target, Pt(value))
inches = {
"left_indent": "left_indent",
"right_indent": "right_indent",
"first_line_indent": "first_line_indent",
}
for source, target in inches.items():
if source in spec:
setattr(paragraph_format, target, Inches(float(spec[source])))
for key in (
"keep_with_next",
"keep_together",
"page_break_before",
"widow_control",
):
if key in spec:
setattr(paragraph_format, key, bool(spec[key]))
def add_paragraph_from_spec(
container: Any,
raw_spec: Any,
*,
style: Optional[str] = None,
default_run_style: Optional[dict[str, Any]] = None,
) -> Any:
spec = (
{"text": raw_spec}
if isinstance(raw_spec, str)
else expect_object(raw_spec, "paragraph")
)
paragraph = container.add_paragraph()
if style:
paragraph.style = style
add_runs(
paragraph,
text=str(spec["text"]) if "text" in spec else None,
runs=spec.get("runs"),
defaults=default_run_style,
)
apply_paragraph_format(paragraph, spec)
return paragraph
def _set_cell_shading(cell: Any, fill: str) -> None:
from docx.oxml import OxmlElement
properties = cell._tc.get_or_add_tcPr()
existing = properties.find(qn("shd"))
if existing is not None:
properties.remove(existing)
shading = OxmlElement("w:shd")
shading.set(qn("fill"), color(fill, "table.shading"))
properties.append(shading)
def _set_cell_width(cell: Any, width_inches: float) -> None:
from docx.oxml import OxmlElement
cell.width = _inches(width_inches)
properties = cell._tc.get_or_add_tcPr()
width = properties.find(qn("tcW"))
if width is None:
width = OxmlElement("w:tcW")
properties.append(width)
width.set(qn("w"), str(round(width_inches * 1440)))
width.set(qn("type"), "dxa")
def _inches(value: float) -> Any:
from docx.shared import Inches
return Inches(float(value))
def _repeat_table_row(row: Any) -> None:
from docx.oxml import OxmlElement
properties = row._tr.get_or_add_trPr()
repeat = OxmlElement("w:tblHeader")
repeat.set(qn("val"), "true")
properties.append(repeat)
def _prevent_row_split(row: Any) -> None:
from docx.oxml import OxmlElement
properties = row._tr.get_or_add_trPr()
cannot_split = OxmlElement("w:cantSplit")
properties.append(cannot_split)
def add_table(container: Any, raw_spec: Any) -> Any:
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
spec = expect_object(raw_spec, "table")
rows = expect_list(spec.get("rows"), "table.rows")
if not rows:
raise ValueError("table.rows 不能为空")
normalized_rows = [expect_list(row, "table.rows[]") for row in rows]
column_count = max((len(row) for row in normalized_rows), default=0)
if column_count < 1:
raise ValueError("表格必须至少有一列")
if len(rows) * column_count > MAX_TABLE_CELLS:
raise ValueError("单个表格超过单元格数量限制")
table = container.add_table(rows=len(rows), cols=column_count)
table.style = str(spec.get("style", "Table Grid"))
table.autofit = bool(spec.get("autofit", False))
alignment = str(spec.get("alignment", "center")).lower()
if alignment not in {"left", "center", "right"}:
raise ValueError("table.alignment 仅支持 left、center、right")
table.alignment = {
"left": WD_TABLE_ALIGNMENT.LEFT,
"center": WD_TABLE_ALIGNMENT.CENTER,
"right": WD_TABLE_ALIGNMENT.RIGHT,
}[alignment]
widths = spec.get("column_widths")
if widths is not None:
widths = expect_list(widths, "table.column_widths")
if len(widths) != column_count:
raise ValueError("table.column_widths 数量必须等于列数")
widths = [float(item) for item in widths]
header_rows = int(spec.get("header_rows", 1))
if header_rows < 0 or header_rows > len(rows):
raise ValueError("table.header_rows 超出范围")
header_fill = str(spec.get("header_fill", "1F4E78"))
header_font_color = str(spec.get("header_font_color", "FFFFFF"))
vertical = str(spec.get("vertical_alignment", "center")).lower()
if vertical not in VERTICAL_ALIGNMENTS:
raise ValueError("table.vertical_alignment 无效")
for row_index, (row, values) in enumerate(zip(table.rows, normalized_rows)):
_prevent_row_split(row)
if row_index < header_rows:
_repeat_table_row(row)
for column_index, cell in enumerate(row.cells):
if widths:
_set_cell_width(cell, widths[column_index])
cell.vertical_alignment = {
0: WD_CELL_VERTICAL_ALIGNMENT.TOP,
1: WD_CELL_VERTICAL_ALIGNMENT.CENTER,
3: WD_CELL_VERTICAL_ALIGNMENT.BOTTOM,
}[VERTICAL_ALIGNMENTS[vertical]]
if column_index >= len(values):
continue
value = values[column_index]
paragraph = cell.paragraphs[0]
if isinstance(value, dict):
value_spec = expect_object(value, "table cell")
add_runs(
paragraph,
text=str(value_spec["text"]) if "text" in value_spec else None,
runs=value_spec.get("runs"),
)
apply_paragraph_format(paragraph, value_spec)
else:
add_runs(paragraph, text="" if value is None else str(value))
if row_index < header_rows:
_set_cell_shading(cell, header_fill)
for run in paragraph.runs:
run.bold = True
from docx.shared import RGBColor
run.font.color.rgb = RGBColor.from_string(
color(header_font_color, "header_font_color")
)
for raw_merge in spec.get("merges", []):
merge = expect_object(raw_merge, "table.merges[]")
start = expect_list(merge.get("start"), "table.merge.start")
end = expect_list(merge.get("end"), "table.merge.end")
if len(start) != 2 or len(end) != 2:
raise ValueError("table merge 坐标必须是 [row, column]")
table.cell(int(start[0]), int(start[1])).merge(
table.cell(int(end[0]), int(end[1]))
)
return table
def _add_page_number(
paragraph: Any,
*,
prefix: str = "",
suffix: str = "",
) -> None:
from docx.oxml import OxmlElement
if prefix:
paragraph.add_run(prefix)
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
instruction.text = " PAGE "
separate = OxmlElement("w:fldChar")
separate.set(qn("fldCharType"), "separate")
value = OxmlElement("w:t")
value.text = "1"
end = OxmlElement("w:fldChar")
end.set(qn("fldCharType"), "end")
run._r.extend([begin, instruction, separate, value, end])
if suffix:
paragraph.add_run(suffix)
def _add_toc(container: Any, raw_spec: Any) -> Any:
from docx.oxml import OxmlElement
spec = expect_object(raw_spec, "toc")
levels = str(spec.get("levels", "1-3"))
if spec.get("title"):
title = container.add_paragraph(str(spec["title"]))
title.style = str(spec.get("title_style", "Heading 1"))
title.paragraph_format.keep_with_next = True
paragraph = container.add_paragraph()
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("fldCharType"), "begin")
instruction = OxmlElement("w:instrText")
instruction.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
instruction.text = f'TOC \\o "{levels}" \\h \\z \\u'
separate = OxmlElement("w:fldChar")
separate.set(qn("fldCharType"), "separate")
placeholder = OxmlElement("w:t")
placeholder.text = "目录将在打开文档时更新"
end = OxmlElement("w:fldChar")
end.set(qn("fldCharType"), "end")
run._r.extend([begin, instruction, separate, placeholder, end])
return paragraph
def _add_horizontal_rule(container: Any, raw_spec: Any) -> Any:
from docx.oxml import OxmlElement
spec = expect_object(raw_spec, "horizontal_rule")
paragraph = container.add_paragraph()
properties = paragraph._p.get_or_add_pPr()
borders = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("val"), str(spec.get("style", "single")))
bottom.set(qn("sz"), str(int(spec.get("size", 6))))
bottom.set(qn("space"), str(int(spec.get("space", 1))))
bottom.set(qn("color"), color(spec.get("color", "808080"), "rule.color"))
borders.append(bottom)
properties.append(borders)
return paragraph
def _add_image(container: Any, raw_spec: Any) -> Any:
from docx.enum.text import WD_ALIGN_PARAGRAPH
spec = expect_object(raw_spec, "image")
image_path = input_file(
str(spec.get("path", "")),
{".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"},
)
paragraph = container.add_paragraph()
alignment = str(spec.get("alignment", "center")).lower()
if alignment not in ALIGNMENTS:
raise ValueError("image.alignment 无效")
paragraph.alignment = ALIGNMENTS[alignment]
run = paragraph.add_run()
kwargs: dict[str, Any] = {}
if "width_inches" in spec:
kwargs["width"] = _inches(float(spec["width_inches"]))
if "height_inches" in spec:
kwargs["height"] = _inches(float(spec["height_inches"]))
run.add_picture(str(image_path), **kwargs)
if spec.get("caption"):
caption = container.add_paragraph(str(spec["caption"]))
caption.style = str(spec.get("caption_style", "Caption"))
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
return paragraph
def _add_section(document: Any, raw_spec: Any) -> Any:
from docx.enum.section import WD_SECTION
spec = expect_object(raw_spec, "section_break")
break_type = str(spec.get("break_type", "new_page")).lower()
mapping = {
"continuous": WD_SECTION.CONTINUOUS,
"new_column": WD_SECTION.NEW_COLUMN,
"new_page": WD_SECTION.NEW_PAGE,
"even_page": WD_SECTION.EVEN_PAGE,
"odd_page": WD_SECTION.ODD_PAGE,
}
if break_type not in mapping:
raise ValueError(f"section_break.break_type 无效:{break_type}")
section = document.add_section(mapping[break_type])
apply_page_settings(section, spec)
return section
def add_blocks(document: Any, container: Any, raw_blocks: Any) -> dict[str, int]:
from docx.enum.text import WD_BREAK
blocks = expect_list(raw_blocks, "blocks")
if len(blocks) > MAX_BLOCKS:
raise ValueError(f"blocks 不能超过 {MAX_BLOCKS}")
counts = {
"paragraphs": 0,
"tables": 0,
"images": 0,
"page_breaks": 0,
"sections": 0,
}
for index, raw_block in enumerate(blocks):
block = expect_object(raw_block, f"blocks[{index}]")
kind = str(block.get("type", "paragraph"))
if kind == "paragraph":
add_paragraph_from_spec(container, block)
counts["paragraphs"] += 1
elif kind == "heading":
level = int(block.get("level", 1))
if level < 1 or level > 9:
raise ValueError("heading.level 必须在 1 到 9 之间")
paragraph = container.add_paragraph(style=f"Heading {level}")
add_runs(
paragraph,
text=str(block["text"]) if "text" in block else None,
runs=block.get("runs"),
)
apply_paragraph_format(paragraph, block)
paragraph.paragraph_format.keep_with_next = True
counts["paragraphs"] += 1
elif kind in {"bullet_list", "numbered_list"}:
items = expect_list(block.get("items"), f"{kind}.items")
base_style = "List Bullet" if kind == "bullet_list" else "List Number"
level = int(block.get("level", 0))
style = base_style if level == 0 else f"{base_style} {min(level + 1, 3)}"
for item in items:
add_paragraph_from_spec(container, item, style=style)
counts["paragraphs"] += 1
elif kind == "table":
add_table(container, block)
counts["tables"] += 1
elif kind == "image":
if counts["images"] >= MAX_IMAGES:
raise ValueError(f"图片数量不能超过 {MAX_IMAGES}")
_add_image(container, block)
counts["images"] += 1
counts["paragraphs"] += 1
elif kind == "page_break":
paragraph = container.add_paragraph()
paragraph.add_run().add_break(WD_BREAK.PAGE)
counts["page_breaks"] += 1
counts["paragraphs"] += 1
elif kind == "horizontal_rule":
_add_horizontal_rule(container, block)
counts["paragraphs"] += 1
elif kind == "toc":
_add_toc(container, block)
counts["paragraphs"] += 1
elif kind == "section_break":
if container is not document:
raise ValueError("页眉或页脚中不能添加 section_break")
_add_section(document, block)
counts["sections"] += 1
elif kind == "spacer":
paragraph = container.add_paragraph()
paragraph.paragraph_format.space_after = _points(
float(block.get("points", 6))
)
counts["paragraphs"] += 1
else:
raise ValueError(f"不支持的 block.type{kind}")
return counts
def _points(value: float) -> Any:
from docx.shared import Pt
return Pt(value)
def apply_page_settings(section: Any, raw_spec: Any) -> None:
from docx.enum.section import WD_ORIENT
from docx.shared import Cm, Inches, Mm
spec = expect_object(raw_spec, "page")
size = str(spec.get("size", "A4")).upper()
if size == "A4":
width, height = Mm(210), Mm(297)
elif size == "LETTER":
width, height = Inches(8.5), Inches(11)
elif size == "LEGAL":
width, height = Inches(8.5), Inches(14)
else:
if "width_inches" not in spec or "height_inches" not in spec:
raise ValueError("自定义纸张必须提供 width_inches 和 height_inches")
width = Inches(float(spec["width_inches"]))
height = Inches(float(spec["height_inches"]))
orientation = str(spec.get("orientation", "portrait")).lower()
if orientation not in {"portrait", "landscape"}:
raise ValueError("page.orientation 只能是 portrait 或 landscape")
if orientation == "landscape":
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = height, width
else:
section.orientation = WD_ORIENT.PORTRAIT
section.page_width, section.page_height = width, height
margin_defaults = {
"top": 0.85,
"bottom": 0.85,
"left": 0.9,
"right": 0.9,
"header": 0.35,
"footer": 0.35,
}
margins = expect_object(spec.get("margins", {}), "page.margins")
for key, default in margin_defaults.items():
value = float(margins.get(key, default))
if value < 0 or value > 5:
raise ValueError(f"page.margins.{key} 超出合理范围")
target = f"{key}_margin" if key in {"top", "bottom", "left", "right"} else f"{key}_distance"
setattr(section, target, Inches(value))
if "gutter" in margins:
section.gutter = Inches(float(margins["gutter"]))
def apply_document_defaults(document: Any, raw_spec: Any) -> None:
from docx.shared import Pt, RGBColor
spec = expect_object(raw_spec, "default_font")
name = str(spec.get("name", "Arial"))
east_asia = str(spec.get("east_asia", "Noto Sans CJK SC"))
size = float(spec.get("size", 10.5))
normal = document.styles["Normal"]
normal.font.name = name
normal.font.size = Pt(size)
normal_fonts = normal._element.get_or_add_rPr().get_or_add_rFonts()
normal_fonts.set(qn("ascii"), name)
normal_fonts.set(qn("hAnsi"), name)
normal_fonts.set(qn("eastAsia"), east_asia)
normal.paragraph_format.space_after = Pt(float(spec.get("space_after", 6)))
normal.paragraph_format.line_spacing = float(spec.get("line_spacing", 1.15))
if "color" in spec:
normal.font.color.rgb = RGBColor.from_string(
color(spec["color"], "default_font.color")
)
def apply_named_styles(document: Any, raw_spec: Any) -> None:
from docx.shared import Pt, RGBColor
styles = expect_object(raw_spec, "styles")
for style_name, raw_style in styles.items():
if style_name not in document.styles:
raise ValueError(f"文档样式不存在:{style_name}")
spec = expect_object(raw_style, f"styles.{style_name}")
style = document.styles[style_name]
if "font" in spec:
font_name = str(spec["font"])
style.font.name = font_name
fonts = style._element.get_or_add_rPr().get_or_add_rFonts()
fonts.set(qn("ascii"), font_name)
fonts.set(qn("hAnsi"), font_name)
if "east_asia_font" in spec:
style._element.get_or_add_rPr().get_or_add_rFonts().set(
qn("eastAsia"),
str(spec["east_asia_font"]),
)
if "size" in spec:
style.font.size = Pt(float(spec["size"]))
if "bold" in spec:
style.font.bold = bool(spec["bold"])
if "italic" in spec:
style.font.italic = bool(spec["italic"])
if "color" in spec:
style.font.color.rgb = RGBColor.from_string(
color(spec["color"], f"styles.{style_name}.color")
)
if "space_before" in spec:
style.paragraph_format.space_before = Pt(float(spec["space_before"]))
if "space_after" in spec:
style.paragraph_format.space_after = Pt(float(spec["space_after"]))
if "keep_with_next" in spec:
style.paragraph_format.keep_with_next = bool(spec["keep_with_next"])
def apply_core_properties(document: Any, raw_spec: Any) -> None:
spec = expect_object(raw_spec, "properties")
allowed = {
"title",
"subject",
"author",
"last_modified_by",
"category",
"keywords",
"comments",
"language",
"identifier",
"version",
}
unknown = set(spec) - allowed
if unknown:
raise ValueError(f"properties 包含未知字段:{sorted(unknown)}")
properties = document.core_properties
for key, value in spec.items():
setattr(properties, key, value)
def _clear_container(container: Any) -> None:
for paragraph in list(container.paragraphs):
container._element.remove(paragraph._element)
for table in list(container.tables):
container._element.remove(table._element)
def apply_header_footer(document: Any, raw_header: Any, raw_footer: Any) -> None:
for section in document.sections:
if raw_header is not None:
spec = expect_object(raw_header, "header")
_clear_container(section.header)
if "blocks" in spec:
add_blocks(document, section.header, spec["blocks"])
else:
paragraph = section.header.add_paragraph()
add_runs(
paragraph,
text=str(spec.get("text", "")),
runs=spec.get("runs"),
)
apply_paragraph_format(paragraph, spec)
if raw_footer is not None:
spec = expect_object(raw_footer, "footer")
_clear_container(section.footer)
if "blocks" in spec:
add_blocks(document, section.footer, spec["blocks"])
else:
paragraph = section.footer.add_paragraph()
add_runs(
paragraph,
text=str(spec.get("text", "")),
runs=spec.get("runs"),
)
apply_paragraph_format(paragraph, spec)
if spec.get("page_number"):
_add_page_number(
paragraph,
prefix=str(spec.get("page_number_prefix", "")),
suffix=str(spec.get("page_number_suffix", "")),
)
def set_update_fields(document: Any) -> None:
from docx.oxml import OxmlElement
settings = document.settings._element
existing = settings.find(qn("updateFields"))
if existing is None:
existing = OxmlElement("w:updateFields")
settings.append(existing)
existing.set(qn("val"), "true")

View File

@ -0,0 +1,438 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shutil
import stat
import subprocess
import tempfile
import zipfile
from datetime import date, datetime, time
from pathlib import Path, PurePosixPath
from typing import Any, Callable, NoReturn, Optional
from urllib.parse import quote
DOCX_INPUT_SUFFIXES = {".docx", ".dotx"}
WORD_INPUT_SUFFIXES = DOCX_INPUT_SUFFIXES | {".doc"}
DOCX_OUTPUT_SUFFIXES = {".docx", ".dotx"}
DOCUMENT_OUTPUT_SUFFIXES = DOCX_OUTPUT_SUFFIXES | {".pdf", ".txt", ".md"}
MAX_ARCHIVE_MEMBERS = 20_000
MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024
MAX_MEMBER_BYTES = 128 * 1024 * 1024
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
XML_NS = "http://www.w3.org/XML/1998/namespace"
NS = {"w": W_NS, "r": R_NS}
class SkillArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
raise ValueError(f"参数错误:{message}")
def emit(payload: dict[str, Any]) -> None:
print(json.dumps(payload, ensure_ascii=False, default=json_default))
def json_default(value: Any) -> Any:
if isinstance(value, (datetime, date, time)):
return value.isoformat()
if isinstance(value, Path):
return str(value)
return str(value)
def failure_message(exc: Exception) -> str:
if isinstance(exc, (FileExistsError, FileNotFoundError)):
return str(exc)
if isinstance(exc, PermissionError):
return "文件处理失败:没有目标路径的访问权限"
if isinstance(exc, subprocess.TimeoutExpired):
return f"外部程序执行超时({exc.timeout} 秒)"
if isinstance(exc, subprocess.CalledProcessError):
stderr = (exc.stderr or "").strip()
return f"外部程序执行失败:{stderr or exc}"
if isinstance(exc, zipfile.BadZipFile):
return "Word 文件不是有效的 OOXML ZIP 压缩包"
if isinstance(exc, OSError):
return f"文件处理失败:{exc}"
return str(exc)
def run_cli(action: Callable[[], dict[str, Any]]) -> int:
try:
result = action()
except Exception as exc:
emit({"ok": False, "error": failure_message(exc)})
return 1
emit({"ok": True, **result})
return 0
def input_file(value: str, suffixes: Optional[set[str]] = None) -> Path:
path = Path(value).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"输入文件不存在:{path}")
if not path.is_file():
raise ValueError(f"输入路径不是文件:{path}")
if path.stat().st_size <= 0:
raise ValueError(f"输入文件为空:{path}")
if suffixes is not None and path.suffix.lower() not in suffixes:
expected = "".join(sorted(suffixes))
raise ValueError(f"不支持的输入格式 {path.suffix};允许:{expected}")
return path
def output_file(
value: str,
suffixes: Optional[set[str]] = None,
*,
overwrite: bool = False,
) -> Path:
path = Path(value).expanduser().resolve()
if suffixes is not None and path.suffix.lower() not in suffixes:
expected = "".join(sorted(suffixes))
raise ValueError(f"不支持的输出格式 {path.suffix};允许:{expected}")
if path.exists() and not overwrite:
raise FileExistsError(f"目标文件已存在:{path}")
if path.exists() and not path.is_file():
raise ValueError(f"目标路径不是文件:{path}")
path.parent.mkdir(parents=True, exist_ok=True)
return path
def output_directory(value: str) -> Path:
path = Path(value).expanduser().resolve()
if path.exists() and not path.is_dir():
raise ValueError(f"输出路径不是目录:{path}")
path.mkdir(parents=True, exist_ok=True)
return path
def publish_file(source: Path, destination: Path, *, overwrite: bool) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if overwrite:
os.replace(source, destination)
return
try:
os.link(source, destination)
except FileExistsError as exc:
raise FileExistsError(f"目标文件已存在:{destination}") from exc
except OSError:
if destination.exists():
raise FileExistsError(f"目标文件已存在:{destination}")
shutil.copy2(source, destination)
finally:
if source.exists():
source.unlink()
def load_json_argument(
inline_value: Optional[str],
file_value: Optional[str],
*,
label: str,
max_bytes: int = 2 * 1024 * 1024,
) -> dict[str, Any]:
if bool(inline_value) == bool(file_value):
raise ValueError(f"{label} 必须且只能通过内联 JSON 或 JSON 文件提供一次")
if file_value:
source = input_file(file_value, {".json"})
if source.stat().st_size > max_bytes:
raise ValueError(f"{label} JSON 文件超过 {max_bytes} 字节限制")
raw = source.read_text(encoding="utf-8")
else:
raw = inline_value or ""
if len(raw.encode("utf-8")) > max_bytes:
raise ValueError(f"{label} 内联 JSON 超过 {max_bytes} 字节限制")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(
f"{label} JSON 无效:第 {exc.lineno} 行第 {exc.colno} 列,{exc.msg}"
) from exc
if not isinstance(parsed, dict):
raise ValueError(f"{label} JSON 顶层必须是对象")
return parsed
def find_program(*names: str) -> str:
for name in names:
resolved = shutil.which(name)
if resolved:
return resolved
raise FileNotFoundError(f"运行环境缺少命令:{' / '.join(names)}")
def office_profile_uri(profile: Path) -> str:
return "file://" + quote(str(profile.resolve()), safe="/:")
def run_program(
args: list[str],
*,
timeout: int,
cwd: Optional[Path] = None,
env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess[str]:
if timeout < 1 or timeout > 900:
raise ValueError("timeout 必须在 1 到 900 秒之间")
completed = subprocess.run(
args,
cwd=str(cwd) if cwd else None,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
)
if completed.returncode != 0:
raise subprocess.CalledProcessError(
completed.returncode,
args,
output=completed.stdout,
stderr=completed.stderr,
)
return completed
def run_soffice_convert(
source: Path,
*,
target_format: str,
output_dir: Path,
timeout: int,
filter_name: Optional[str] = None,
) -> tuple[Path, dict[str, str]]:
soffice = find_program("soffice", "libreoffice")
output_dir.mkdir(parents=True, exist_ok=True)
profile = Path(tempfile.mkdtemp(prefix="docx-soffice-profile-"))
try:
cache_dir = profile / "cache"
cache_dir.mkdir()
process_env = os.environ.copy()
process_env["XDG_CACHE_HOME"] = str(cache_dir)
convert_arg = target_format
if filter_name:
convert_arg = f"{target_format}:{filter_name}"
completed = run_program(
[
soffice,
f"-env:UserInstallation={office_profile_uri(profile)}",
"--headless",
"--nologo",
"--nodefault",
"--nolockcheck",
"--nofirststartwizard",
"--convert-to",
convert_arg,
"--outdir",
str(output_dir),
str(source),
],
timeout=timeout,
env=process_env,
)
expected = output_dir / f"{source.stem}.{target_format}"
if not expected.exists():
candidates = sorted(output_dir.glob(f"{source.stem}.*"))
if len(candidates) == 1:
expected = candidates[0]
else:
raise RuntimeError(
"LibreOffice 未生成预期文件;"
f"stdout={completed.stdout[-1000:]!r} "
f"stderr={completed.stderr[-1000:]!r}"
)
return expected, {
"stdout": completed.stdout[-2000:],
"stderr": completed.stderr[-2000:],
}
finally:
shutil.rmtree(profile, ignore_errors=True)
def _safe_archive_name(name: str) -> PurePosixPath:
candidate = PurePosixPath(name)
if (
candidate.is_absolute()
or not candidate.parts
or ".." in candidate.parts
or candidate.parts[0].endswith(":")
):
raise ValueError(f"OOXML 压缩包含不安全路径:{name}")
return candidate
def _zip_member_is_symlink(info: zipfile.ZipInfo) -> bool:
mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_ISLNK(mode)
def inspect_archive(path: Path) -> dict[str, Any]:
total_size = 0
xml_count = 0
media_count = 0
with zipfile.ZipFile(path) as archive:
infos = archive.infolist()
if len(infos) > MAX_ARCHIVE_MEMBERS:
raise ValueError(
f"OOXML 压缩包成员过多:{len(infos)} > {MAX_ARCHIVE_MEMBERS}"
)
seen: set[str] = set()
duplicates: list[str] = []
for info in infos:
_safe_archive_name(info.filename)
if _zip_member_is_symlink(info):
raise ValueError(f"OOXML 压缩包含符号链接:{info.filename}")
if info.file_size > MAX_MEMBER_BYTES:
raise ValueError(f"OOXML 成员过大:{info.filename}")
total_size += info.file_size
if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
raise ValueError("OOXML 解压后总大小超过安全限制")
if info.filename in seen:
duplicates.append(info.filename)
seen.add(info.filename)
if info.filename.endswith((".xml", ".rels")):
xml_count += 1
if info.filename.startswith("word/media/") and not info.is_dir():
media_count += 1
required = {
"[Content_Types].xml",
"_rels/.rels",
"word/document.xml",
}
missing = sorted(required - seen)
return {
"member_count": len(infos),
"uncompressed_bytes": total_size,
"xml_part_count": xml_count,
"media_count": media_count,
"duplicate_members": duplicates,
"missing_required_parts": missing,
}
def safe_extract_docx(source: Path, destination: Path) -> dict[str, Any]:
archive_info = inspect_archive(source)
if archive_info["missing_required_parts"]:
raise ValueError(
"Word 文件缺少必要部件:"
+ "".join(archive_info["missing_required_parts"])
)
destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(source) as archive:
for info in archive.infolist():
relative = _safe_archive_name(info.filename)
target = destination.joinpath(*relative.parts)
resolved = target.resolve()
if destination.resolve() not in resolved.parents and resolved != destination.resolve():
raise ValueError(f"OOXML 成员逃逸输出目录:{info.filename}")
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info, "r") as source_handle, target.open("wb") as target_handle:
shutil.copyfileobj(source_handle, target_handle)
return archive_info
def pack_docx_directory(source_dir: Path, destination: Path) -> dict[str, Any]:
source_dir = source_dir.expanduser().resolve()
if not source_dir.exists() or not source_dir.is_dir():
raise ValueError(f"待打包目录不存在或不是目录:{source_dir}")
required = {
source_dir / "[Content_Types].xml",
source_dir / "_rels" / ".rels",
source_dir / "word" / "document.xml",
}
missing = sorted(str(path.relative_to(source_dir)) for path in required if not path.is_file())
if missing:
raise ValueError("待打包目录缺少必要部件:" + "".join(missing))
files: list[Path] = []
total_size = 0
for path in sorted(source_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"待打包目录包含符号链接:{path}")
if path.is_file():
total_size += path.stat().st_size
if path.stat().st_size > MAX_MEMBER_BYTES:
raise ValueError(f"待打包文件过大:{path}")
if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
raise ValueError("待打包文件总大小超过安全限制")
files.append(path)
if len(files) > MAX_ARCHIVE_MEMBERS:
raise ValueError("待打包文件数量超过安全限制")
with zipfile.ZipFile(
destination,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as archive:
for path in files:
archive.write(path, path.relative_to(source_dir).as_posix())
return inspect_archive(destination)
def rewrite_docx_parts(
source: Path,
destination: Path,
replacements: dict[str, bytes],
) -> None:
inspect_archive(source)
with zipfile.ZipFile(source, "r") as incoming, zipfile.ZipFile(
destination,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as outgoing:
existing = set()
for info in incoming.infolist():
existing.add(info.filename)
payload = replacements.get(info.filename)
if payload is None:
payload = incoming.read(info.filename)
outgoing.writestr(info, payload)
for name, payload in replacements.items():
if name not in existing:
outgoing.writestr(name, payload)
def qn(local_name: str) -> str:
return f"{{{W_NS}}}{local_name}"
def parse_xml_bytes(payload: bytes, *, label: str = "XML") -> Any:
try:
from defusedxml import ElementTree as ET
from defusedxml.common import DefusedXmlException
try:
return ET.fromstring(payload)
except (ET.ParseError, DefusedXmlException, ValueError) as exc:
raise ValueError(f"{label} 解析失败:{exc}") from exc
except ModuleNotFoundError:
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
recover=False,
huge_tree=False,
remove_comments=False,
)
try:
return etree.fromstring(payload, parser=parser)
except (etree.XMLSyntaxError, ValueError) as exc:
raise ValueError(f"{label} 解析失败:{exc}") from exc

View File

@ -0,0 +1,371 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from _docx_common import (
DOCX_INPUT_SUFFIXES,
SkillArgumentParser,
find_program,
input_file,
inspect_archive,
office_profile_uri,
output_file,
parse_xml_bytes,
publish_file,
rewrite_docx_parts,
run_cli,
run_program,
)
MACRO = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<script:module xmlns:script="http://openoffice.org/2000/script"
script:name="Module1" script:language="StarBasic">
Sub AcceptAllTrackedChanges()
Dim frame As Object
Dim dispatcher As Object
frame = ThisComponent.CurrentController.Frame
dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")
dispatcher.executeDispatch(frame, ".uno:AcceptAllTrackedChanges", "", 0, Array())
ThisComponent.store()
ThisComponent.close(True)
End Sub
</script:module>
"""
SCRIPT_XLB = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
<library:library xmlns:library="http://openoffice.org/2000/library"
library:name="Standard" library:readonly="false" library:passwordprotected="false">
<library:element library:name="Module1"/>
</library:library>
"""
SCRIPT_XLC = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:libraries PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "libraries.dtd">
<library:libraries xmlns:library="http://openoffice.org/2000/library"
xmlns:xlink="http://www.w3.org/1999/xlink">
<library:library library:name="Standard"
xlink:href="$(USER)/basic/Standard/script.xlb/"
xlink:type="simple" library:link="false"/>
</library:libraries>
"""
def _timeout_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")[-2000:]
return str(value)[-2000:]
def _unwrap(element: Any) -> None:
parent = element.getparent()
if parent is None:
return
index = parent.index(element)
for child in list(element):
element.remove(child)
parent.insert(index, child)
index += 1
parent.remove(element)
def _remove(element: Any) -> None:
parent = element.getparent()
if parent is not None:
parent.remove(element)
def _accept_revisions_in_xml(payload: bytes) -> tuple[bytes, int]:
from lxml import etree
namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
ns = {"w": namespace}
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
recover=False,
huge_tree=False,
remove_comments=False,
)
root = etree.fromstring(payload, parser=parser)
changes = 0
# 接受被删除的表格行和单元格;插入标记稍后作为普通 w:ins 解包。
for row in list(root.xpath(".//w:tr[w:trPr/w:del]", namespaces=ns)):
_remove(row)
changes += 1
for cell in list(root.xpath(".//w:tc[w:tcPr/w:cellDel]", namespaces=ns)):
_remove(cell)
changes += 1
# 删除段落标记表示把当前段落与下一段合并。
while True:
paragraphs = root.xpath(".//w:p[w:pPr/w:rPr/w:del]", namespaces=ns)
if not paragraphs:
break
paragraph = paragraphs[0]
marker = paragraph.find("./w:pPr/w:rPr/w:del", namespaces=ns)
next_paragraph = paragraph.getnext()
if (
next_paragraph is not None
and next_paragraph.tag == f"{{{namespace}}}p"
):
for child in list(next_paragraph):
if child.tag == f"{{{namespace}}}pPr":
continue
next_paragraph.remove(child)
paragraph.append(child)
_remove(next_paragraph)
if marker is not None:
_remove(marker)
changes += 1
# 删除修订内容和移动来源。
for expression in (".//w:del", ".//w:moveFrom"):
for element in list(root.xpath(expression, namespaces=ns)):
_remove(element)
changes += 1
# 保留插入内容和移动目标,去掉外层修订容器。
for expression in (".//w:ins", ".//w:moveTo"):
for element in list(root.xpath(expression, namespaces=ns)):
_unwrap(element)
changes += 1
removable_names = {
"pPrChange",
"rPrChange",
"tblPrChange",
"tblGridChange",
"trPrChange",
"tcPrChange",
"sectPrChange",
"numberingChange",
"cellIns",
"cellDel",
"cellMerge",
"moveFromRangeStart",
"moveFromRangeEnd",
"moveToRangeStart",
"moveToRangeEnd",
"customXmlInsRangeStart",
"customXmlInsRangeEnd",
"customXmlDelRangeStart",
"customXmlDelRangeEnd",
}
for local_name in removable_names:
for element in list(
root.xpath(f".//w:{local_name}", namespaces=ns)
):
_remove(element)
changes += 1
# malformed producers sometimes leave delText outside w:del; accepted view treats it as text.
for element in root.xpath(".//w:delText", namespaces=ns):
element.tag = f"{{{namespace}}}t"
changes += 1
return (
etree.tostring(
root,
xml_declaration=True,
encoding="UTF-8",
standalone=True,
),
changes,
)
def _accept_with_ooxml(source: Path, destination: Path) -> int:
replacements: dict[str, bytes] = {}
changes = 0
with zipfile.ZipFile(source) as archive:
for name in archive.namelist():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
rewritten, part_changes = _accept_revisions_in_xml(archive.read(name))
if part_changes:
replacements[name] = rewritten
changes += part_changes
rewrite_docx_parts(source, destination, replacements)
return changes
def _revision_count(path: Path) -> int:
revision_tags = {
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+ name
for name in (
"ins",
"del",
"moveFrom",
"moveTo",
"pPrChange",
"rPrChange",
"tblPrChange",
"trPrChange",
"tcPrChange",
"sectPrChange",
)
}
count = 0
with zipfile.ZipFile(path) as archive:
for name in archive.namelist():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
root = parse_xml_bytes(archive.read(name), label=name)
count += sum(1 for element in root.iter() if element.tag in revision_tags)
return count
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="通过固定 LibreOffice 宏接受 Word 文档中的全部修订。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--timeout", type=int, default=90)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
destination = output_file(
args.output,
{".docx"},
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("输出路径不能与输入文件相同")
before = _revision_count(source)
with tempfile.TemporaryDirectory(prefix="docx-accept-") as temp_name:
temp_dir = Path(temp_name)
staged = temp_dir / "accepted.docx"
shutil.copy2(source, staged)
stdout = ""
stderr = ""
timed_out_after_save = False
macro_failed = False
engine = "copy"
if before:
engine = "libreoffice"
soffice = find_program("soffice", "libreoffice")
profile = temp_dir / "profile"
profile.mkdir()
cache_dir = profile / "cache"
cache_dir.mkdir()
process_env = os.environ.copy()
process_env["XDG_CACHE_HOME"] = str(cache_dir)
try:
run_program(
[
soffice,
f"-env:UserInstallation={office_profile_uri(profile)}",
"--headless",
"--nologo",
"--nodefault",
"--nolockcheck",
"--nofirststartwizard",
"--terminate_after_init",
],
timeout=min(args.timeout, 30),
env=process_env,
)
except Exception:
# 部分 LibreOffice 构建不支持 terminate_after_init后续宏调用仍可初始化配置。
pass
macro_dir = profile / "user" / "basic" / "Standard"
macro_dir.mkdir(parents=True, exist_ok=True)
(macro_dir / "Module1.xba").write_text(MACRO, encoding="utf-8")
(macro_dir / "script.xlb").write_text(
SCRIPT_XLB,
encoding="utf-8",
)
(profile / "user" / "basic" / "script.xlc").write_text(
SCRIPT_XLC,
encoding="utf-8",
)
command = [
soffice,
f"-env:UserInstallation={office_profile_uri(profile)}",
"--headless",
"--nologo",
"--nodefault",
"--nolockcheck",
"--nofirststartwizard",
"vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges"
"?language=Basic&location=application",
str(staged),
]
try:
completed = subprocess.run(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=min(args.timeout, 30),
check=False,
env=process_env,
)
stdout = completed.stdout[-2000:]
stderr = completed.stderr[-2000:]
if completed.returncode != 0:
macro_failed = True
except subprocess.TimeoutExpired as exc:
stdout = _timeout_text(exc.stdout)
stderr = _timeout_text(exc.stderr)
timed_out_after_save = True
after = _revision_count(staged)
if after:
fallback = temp_dir / "accepted-ooxml.docx"
_accept_with_ooxml(staged, fallback)
shutil.copy2(fallback, staged)
engine = "ooxml-fallback"
after = _revision_count(staged)
if after:
raise RuntimeError(
f"接受修订后仍检测到 {after} 个修订标记;未发布结果"
)
archive = inspect_archive(staged)
Document(str(staged))
publish_source = temp_dir / "publish.docx"
shutil.copy2(staged, publish_source)
publish_file(publish_source, destination, overwrite=args.overwrite)
return {
"path": str(destination),
"source": str(source),
"revision_markers_before": before,
"revision_markers_after": after,
"status": "success",
"engine": engine,
"libreoffice_macro_failed": macro_failed,
"office_timed_out_after_save": timed_out_after_save,
"office_stdout": stdout,
"office_stderr": stderr,
"archive": archive,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,221 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Iterable, Optional
from _docx_common import (
DOCX_INPUT_SUFFIXES,
SkillArgumentParser,
input_file,
inspect_archive,
output_file,
publish_file,
run_cli,
)
def _iter_table_paragraphs(table: Any) -> Iterable[Any]:
for row in table.rows:
for cell in row.cells:
yield from cell.paragraphs
for nested in cell.tables:
yield from _iter_table_paragraphs(nested)
def _paragraphs(document: Any, scope: str) -> list[Any]:
if scope not in {"body", "tables", "headers", "footers", "all"}:
raise ValueError("scope 仅支持 body、tables、headers、footers、all")
result: list[Any] = []
if scope in {"body", "all"}:
result.extend(document.paragraphs)
if scope in {"tables", "all"}:
for table in document.tables:
result.extend(_iter_table_paragraphs(table))
if scope in {"headers", "all"}:
for section in document.sections:
result.extend(section.header.paragraphs)
if scope in {"footers", "all"}:
for section in document.sections:
result.extend(section.footer.paragraphs)
deduplicated: list[Any] = []
seen: set[int] = set()
for paragraph in result:
identity = id(paragraph._p)
if identity not in seen:
seen.add(identity)
deduplicated.append(paragraph)
return deduplicated
def _split_run(run: Any, offset: int) -> tuple[Optional[Any], Optional[Any]]:
from docx.text.run import Run
text = run.text
if offset <= 0:
return None, run
if offset >= len(text):
return run, None
original = deepcopy(run._r)
left_text = text[:offset]
right_text = text[offset:]
run.text = left_text
original.text = right_text
run._r.addnext(original)
return run, Run(original, run._parent)
def _selected_runs(paragraph: Any, start: int, end: int) -> list[Any]:
runs = paragraph.runs
spans: list[tuple[int, int]] = []
offset = 0
for run in runs:
spans.append((offset, offset + len(run.text)))
offset += len(run.text)
start_index = next(
index for index, (_, run_end) in enumerate(spans) if start < run_end
)
end_index = next(
index
for index, (run_start, run_end) in enumerate(spans)
if end <= run_end and end > run_start
)
start_run = runs[start_index]
end_run = runs[end_index]
start_offset = start - spans[start_index][0]
end_offset = end - spans[end_index][0]
if start_run is end_run:
match_with_prefix, _ = _split_run(start_run, end_offset)
if match_with_prefix is None:
raise ValueError("无法为零长度文本添加批注")
_, selected = _split_run(match_with_prefix, start_offset)
if selected is None:
raise ValueError("无法定位批注文本")
return [selected]
_, start_selected = _split_run(start_run, start_offset)
end_selected, _ = _split_run(end_run, end_offset)
if start_selected is None or end_selected is None:
raise ValueError("无法定位跨 Run 的批注文本")
refreshed = paragraph.runs
start_position = next(
index for index, run in enumerate(refreshed) if run._r is start_selected._r
)
end_position = next(
index for index, run in enumerate(refreshed) if run._r is end_selected._r
)
return refreshed[start_position : end_position + 1]
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="在 Word 文档中的精确文本范围添加批注。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--find", required=True, help="要锚定批注的文本")
parser.add_argument("--comment", required=True, help="批注正文")
parser.add_argument("--author", default="AI")
parser.add_argument("--initials", default="AI")
parser.add_argument(
"--scope",
default="all",
choices=["body", "tables", "headers", "footers", "all"],
)
parser.add_argument("--ignore-case", action="store_true")
parser.add_argument(
"--occurrence",
type=int,
default=1,
help="为全文第几个匹配添加批注,从 1 开始",
)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
if not args.find:
raise ValueError("find 不能为空")
if not args.comment:
raise ValueError("comment 不能为空")
if args.occurrence < 1 or args.occurrence > 10_000:
raise ValueError("occurrence 必须在 1 到 10000 之间")
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
destination = output_file(
args.output,
{".docx"},
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("输出路径不能与输入文件相同")
document = Document(str(source))
flags = re.IGNORECASE if args.ignore_case else 0
pattern = re.compile(re.escape(args.find), flags)
seen = 0
selected_paragraph: Optional[Any] = None
selected_span: Optional[tuple[int, int]] = None
for paragraph in _paragraphs(document, args.scope):
for match in pattern.finditer(paragraph.text):
seen += 1
if seen == args.occurrence:
selected_paragraph = paragraph
selected_span = match.span()
break
if selected_paragraph is not None:
break
if selected_paragraph is None or selected_span is None:
raise ValueError(
f"全文只找到 {seen} 个匹配,无法选择第 {args.occurrence}"
)
runs = _selected_runs(
selected_paragraph,
selected_span[0],
selected_span[1],
)
document.add_comment(
runs,
text=args.comment,
author=args.author,
initials=args.initials,
)
descriptor, temp_name = tempfile.mkstemp(
prefix=f".{destination.stem}.",
suffix=".docx",
dir=str(destination.parent),
)
os.close(descriptor)
temp_path = Path(temp_name)
try:
document.save(temp_path)
archive = inspect_archive(temp_path)
Document(str(temp_path))
publish_file(temp_path, destination, overwrite=args.overwrite)
finally:
if temp_path.exists():
temp_path.unlink()
return {
"path": str(destination),
"source": str(source),
"matched_occurrence": args.occurrence,
"anchored_text": args.find,
"author": args.author,
"comment_count": len(document.comments),
"archive": archive,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
from _docx_common import (
DOCUMENT_OUTPUT_SUFFIXES,
WORD_INPUT_SUFFIXES,
SkillArgumentParser,
find_program,
input_file,
output_file,
publish_file,
run_cli,
run_program,
run_soffice_convert,
)
def _plain_text(document: Any) -> str:
chunks: list[str] = []
for paragraph in document.paragraphs:
chunks.append(paragraph.text)
for table_index, table in enumerate(document.tables, start=1):
chunks.append(f"\n[表格 {table_index}]")
for row in table.rows:
chunks.append("\t".join(cell.text for cell in row.cells))
return "\n".join(chunks).rstrip() + "\n"
def _markdown(document: Any) -> str:
chunks: list[str] = []
for paragraph in document.paragraphs:
style_name = paragraph.style.name if paragraph.style else ""
if style_name.startswith("Heading "):
try:
level = int(style_name.split()[-1])
except ValueError:
level = 1
chunks.append(f"{'#' * min(max(level, 1), 6)} {paragraph.text}")
elif style_name.startswith("List Bullet"):
chunks.append(f"- {paragraph.text}")
elif style_name.startswith("List Number"):
chunks.append(f"1. {paragraph.text}")
else:
chunks.append(paragraph.text)
chunks.append("")
for table in document.tables:
rows = [[cell.text.replace("|", "\\|") for cell in row.cells] for row in table.rows]
if not rows:
continue
chunks.append("| " + " | ".join(rows[0]) + " |")
chunks.append("| " + " | ".join("---" for _ in rows[0]) + " |")
for row in rows[1:]:
chunks.append("| " + " | ".join(row) + " |")
chunks.append("")
return "\n".join(chunks).rstrip() + "\n"
def _extract_text(
source: Path,
destination: Path,
*,
markdown: bool,
track_changes: str,
timeout: int,
) -> str:
pandoc = shutil.which("pandoc")
if pandoc:
target = "gfm" if markdown else "plain"
completed = run_program(
[
pandoc,
f"--track-changes={track_changes}",
"-t",
target,
"-o",
str(destination),
str(source),
],
timeout=timeout,
)
return "pandoc"
from docx import Document
if track_changes != "accept":
raise FileNotFoundError(
"运行环境缺少 pandoc纯 Python 回退只支持 --track-changes accept"
)
document = Document(str(source))
text = _markdown(document) if markdown else _plain_text(document)
destination.write_text(text, encoding="utf-8")
return "python-docx"
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="把 Word 文档转换为 DOCX、PDF、Markdown 或纯文本。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument(
"--track-changes",
default="accept",
choices=["accept", "reject", "all"],
)
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
source = input_file(args.input, WORD_INPUT_SUFFIXES)
destination = output_file(
args.output,
DOCUMENT_OUTPUT_SUFFIXES,
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("输出路径不能与输入文件相同")
source_suffix = source.suffix.lower()
output_suffix = destination.suffix.lower()
engine = ""
office_output: dict[str, str] = {"stdout": "", "stderr": ""}
with tempfile.TemporaryDirectory(prefix="docx-convert-") as temp_name:
temp_dir = Path(temp_name)
working_source = source
if source_suffix in {".doc", ".dotx"}:
converted, office_output = run_soffice_convert(
source,
target_format="docx",
output_dir=temp_dir / "docx",
timeout=args.timeout,
filter_name="Office Open XML Text",
)
working_source = converted
source_suffix = ".docx"
descriptor, temp_output_name = tempfile.mkstemp(
prefix="converted-",
suffix=output_suffix,
dir=str(destination.parent),
)
os.close(descriptor)
temp_output = Path(temp_output_name)
temp_output.unlink()
try:
if output_suffix == ".docx":
if source.suffix.lower() == ".docx":
raise ValueError("输入和输出都是 .docx无需转换")
shutil.copy2(working_source, temp_output)
engine = "libreoffice"
elif output_suffix == ".pdf":
converted, office_output = run_soffice_convert(
working_source,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
shutil.copy2(converted, temp_output)
engine = "libreoffice"
elif output_suffix in {".txt", ".md"}:
engine = _extract_text(
working_source,
temp_output,
markdown=output_suffix == ".md",
track_changes=args.track_changes,
timeout=args.timeout,
)
else:
raise ValueError("不支持的目标格式")
publish_file(temp_output, destination, overwrite=args.overwrite)
finally:
if temp_output.exists():
temp_output.unlink()
return {
"path": str(destination),
"source": str(source),
"source_format": source.suffix.lower(),
"output_format": output_suffix,
"engine": engine,
"track_changes": args.track_changes,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from typing import Any
from _document_builder import (
add_blocks,
apply_core_properties,
apply_document_defaults,
apply_header_footer,
apply_named_styles,
apply_page_settings,
expect_list,
set_update_fields,
)
from _docx_common import (
SkillArgumentParser,
inspect_archive,
load_json_argument,
output_file,
publish_file,
run_cli,
)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="依据受控 JSON 说明创建专业 Word DOCX 文档。"
)
parser.add_argument("--output", required=True)
parser.add_argument("--spec", help="内联 JSON 文档说明")
parser.add_argument("--spec-file", help="JSON 文档说明文件")
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
destination = output_file(
args.output,
{".docx"},
overwrite=args.overwrite,
)
spec = load_json_argument(args.spec, args.spec_file, label="文档说明")
allowed = {
"properties",
"page",
"default_font",
"styles",
"header",
"footer",
"blocks",
}
unknown = set(spec) - allowed
if unknown:
raise ValueError(f"文档说明包含未知顶层字段:{sorted(unknown)}")
blocks = expect_list(spec.get("blocks"), "blocks")
if not blocks:
raise ValueError("blocks 不能为空")
document = Document()
apply_document_defaults(document, spec.get("default_font", {}))
if "styles" in spec:
apply_named_styles(document, spec["styles"])
if "properties" in spec:
apply_core_properties(document, spec["properties"])
apply_page_settings(document.sections[0], spec.get("page", {}))
counts = add_blocks(document, document, blocks)
apply_header_footer(
document,
spec.get("header"),
spec.get("footer"),
)
set_update_fields(document)
descriptor, temp_name = tempfile.mkstemp(
prefix=f".{destination.stem}.",
suffix=".docx",
dir=str(destination.parent),
)
os.close(descriptor)
temp_path = Path(temp_name)
try:
document.save(temp_path)
archive = inspect_archive(temp_path)
if archive["missing_required_parts"]:
raise ValueError(
"生成文档缺少必要部件:"
+ "".join(archive["missing_required_parts"])
)
Document(str(temp_path))
publish_file(temp_path, destination, overwrite=args.overwrite)
finally:
if temp_path.exists():
temp_path.unlink()
return {
"path": str(destination),
"properties": {
"title": document.core_properties.title,
"author": document.core_properties.author,
"subject": document.core_properties.subject,
},
"section_count": len(document.sections),
"paragraph_count": len(document.paragraphs),
"table_count": len(document.tables),
"counts": counts,
"archive": archive,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,311 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import socket
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, NoReturn, Optional
from _docx_common import (
WORD_INPUT_SUFFIXES,
emit,
failure_message,
inspect_archive,
output_file,
parse_xml_bytes,
publish_file,
)
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_BYTES = 100 * 1024 * 1024
MAX_ALLOWED_BYTES = 512 * 1024 * 1024
CHUNK_SIZE = 1024 * 1024
USER_AGENT = "wechat-robot-docx-skill/1.0"
OLE_COMPOUND_MAGIC = bytes.fromhex("D0CF11E0A1B11AE1")
CONTENT_TYPES_NS = (
"http://schemas.openxmlformats.org/package/2006/content-types"
)
DOCUMENT_CONTENT_TYPES = {
"application/vnd.openxmlformats-officedocument."
"wordprocessingml.document.main+xml": ".docx",
"application/vnd.openxmlformats-officedocument."
"wordprocessingml.template.main+xml": ".dotx",
}
class SkillArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
raise ValueError(f"参数错误:{message}")
def _validate_https_url(value: str) -> str:
url = value.strip()
if not url:
raise ValueError("Word URL 不能为空")
if any(character.isspace() or ord(character) < 32 for character in url):
raise ValueError("Word URL 不能包含空白字符或控制字符")
parsed = urllib.parse.urlsplit(url)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise ValueError("Word URL 必须是有效的 HTTPS 地址")
if parsed.username is not None or parsed.password is not None:
raise ValueError("Word URL 不允许包含用户名或密码")
try:
parsed.port
except ValueError as exc:
raise ValueError("Word URL 端口格式不正确") from exc
return url
class HTTPSOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
safe_url = _validate_https_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, safe_url)
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = SkillArgumentParser(description="下载并校验远程 HTTPS Word 文件")
parser.add_argument(
"--url",
"--word-url",
"--word_url",
dest="url",
required=True,
help="远程 HTTPS DOCX、DOTX 或 DOC 地址",
)
parser.add_argument(
"--output",
required=True,
help="本地输出路径;扩展名必须是 .docx、.dotx 或 .doc",
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT_SECONDS,
help=f"连接和读取超时秒数,默认 {DEFAULT_TIMEOUT_SECONDS}",
)
parser.add_argument(
"--max-bytes",
"--max_bytes",
dest="max_bytes",
type=int,
default=DEFAULT_MAX_BYTES,
help=f"最大下载字节数,默认 {DEFAULT_MAX_BYTES}",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="允许覆盖本次任务已存在的缓存文件",
)
args = parser.parse_args(argv)
args.url = _validate_https_url(args.url)
if args.timeout < 1 or args.timeout > 600:
raise ValueError("timeout 必须在 1 到 600 秒之间")
if args.max_bytes < 1 or args.max_bytes > MAX_ALLOWED_BYTES:
raise ValueError(
f"max-bytes 必须在 1 到 {MAX_ALLOWED_BYTES} 之间"
)
args.output = output_file(
args.output,
WORD_INPUT_SUFFIXES,
overwrite=args.overwrite,
)
return args
def _detect_ooxml_format(path: Path) -> str:
try:
with zipfile.ZipFile(path) as archive:
content_types = archive.read("[Content_Types].xml")
except KeyError as exc:
raise ValueError("下载内容缺少 Word 内容类型定义") from exc
except zipfile.BadZipFile as exc:
raise ValueError("下载内容不是有效的 Word OOXML 压缩包") from exc
root = parse_xml_bytes(content_types, label="Word 内容类型 XML")
for element in root.iter(f"{{{CONTENT_TYPES_NS}}}Override"):
if element.attrib.get("PartName") == "/word/document.xml":
detected = DOCUMENT_CONTENT_TYPES.get(
element.attrib.get("ContentType", "")
)
if detected is not None:
return detected
raise ValueError(
"下载内容是当前 Skill 不支持的 Word OOXML 类型"
)
raise ValueError("下载内容缺少 Word 主文档内容类型")
def _validate_ooxml_document(
path: Path,
expected_suffix: str,
) -> dict[str, Any]:
archive_info = inspect_archive(path)
missing = archive_info["missing_required_parts"]
if missing:
raise ValueError(
"下载内容不是有效的 Word OOXML 文件;缺少:"
+ "".join(missing)
)
detected_suffix = _detect_ooxml_format(path)
if detected_suffix != expected_suffix:
raise ValueError(
"下载内容的实际格式为 "
f"{detected_suffix},但 output 使用了 {expected_suffix}"
)
try:
from docx import Document
except ImportError as exc:
raise RuntimeError("当前 Python 未加载环境预置的 python-docx 模块") from exc
try:
document = Document(str(path))
paragraph_count = len(document.paragraphs)
table_count = len(document.tables)
section_count = len(document.sections)
except Exception as exc:
raise ValueError("下载内容不是可解析的 Word 文档") from exc
return {
"format": detected_suffix.lstrip("."),
"paragraph_count": paragraph_count,
"table_count": table_count,
"section_count": section_count,
"archive_member_count": archive_info["member_count"],
"archive_uncompressed_bytes": archive_info["uncompressed_bytes"],
"validation": "ooxml-and-python-docx",
}
def _validate_legacy_doc(path: Path) -> dict[str, Any]:
with path.open("rb") as stream:
magic = stream.read(len(OLE_COMPOUND_MAGIC))
if magic != OLE_COMPOUND_MAGIC:
raise ValueError("下载内容不是有效的旧版 Word 复合文件")
return {
"format": "doc",
"validation": "ole-compound-signature",
}
def _validate_document(path: Path, suffix: str) -> dict[str, Any]:
if suffix == ".doc":
return _validate_legacy_doc(path)
return _validate_ooxml_document(path, suffix)
def _download(args: argparse.Namespace) -> dict[str, Any]:
output: Path = args.output
request = urllib.request.Request(
args.url,
headers={
"Accept": (
"application/vnd.openxmlformats-officedocument."
"wordprocessingml.document,"
"application/msword,application/octet-stream;q=0.9,"
"*/*;q=0.1"
),
"Accept-Encoding": "identity",
"User-Agent": USER_AGENT,
},
method="GET",
)
opener = urllib.request.build_opener(HTTPSOnlyRedirectHandler())
temp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{output.stem}.",
suffix=f".part{output.suffix}",
dir=str(output.parent),
delete=False,
) as temp_file:
temp_path = Path(temp_file.name)
with opener.open(request, timeout=args.timeout) as response:
_validate_https_url(response.geturl())
content_length = response.headers.get("Content-Length")
if content_length:
try:
expected_bytes = int(content_length)
except ValueError:
expected_bytes = 0
if expected_bytes > args.max_bytes:
raise ValueError(
"远程文件超过大小限制:"
f"最多允许 {args.max_bytes} 字节"
)
downloaded_bytes = 0
while True:
chunk = response.read(CHUNK_SIZE)
if not chunk:
break
downloaded_bytes += len(chunk)
if downloaded_bytes > args.max_bytes:
raise ValueError(
"远程文件超过大小限制:"
f"最多允许 {args.max_bytes} 字节"
)
temp_file.write(chunk)
temp_file.flush()
os.fsync(temp_file.fileno())
if downloaded_bytes == 0:
raise ValueError("远程服务器返回了空文件")
details = _validate_document(temp_path, output.suffix.lower())
publish_file(temp_path, output, overwrite=args.overwrite)
temp_path = None
return {
"path": str(output),
"size_bytes": downloaded_bytes,
**details,
}
finally:
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
def _failure_message(exc: Exception) -> str:
if isinstance(exc, urllib.error.HTTPError):
return f"下载失败:远程服务器返回 HTTP {exc.code}"
if isinstance(exc, (TimeoutError, socket.timeout)):
return "下载失败:连接或读取超时"
if isinstance(exc, urllib.error.URLError):
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
return "下载失败:连接或读取超时"
return "下载失败:无法访问远程服务器"
return failure_message(exc)
def main(argv: Optional[list[str]] = None) -> int:
try:
args = _parse_args(sys.argv[1:] if argv is None else argv)
result = _download(args)
except Exception as exc:
emit({"ok": False, "error": _failure_message(exc)})
return 1
emit({"ok": True, **result})
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,459 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Iterable, Optional
from _document_builder import (
add_blocks,
apply_core_properties,
apply_header_footer,
apply_page_settings,
expect_list,
expect_object,
set_update_fields,
)
from _docx_common import (
DOCX_INPUT_SUFFIXES,
SkillArgumentParser,
W_NS,
input_file,
inspect_archive,
load_json_argument,
output_file,
parse_xml_bytes,
publish_file,
run_cli,
)
MAX_OPERATIONS = 200
MAX_REPLACEMENTS = 10_000
def _has_revisions(source: Path) -> bool:
revision_tags = {
f"{{{W_NS}}}{name}"
for name in (
"ins",
"del",
"moveFrom",
"moveTo",
"pPrChange",
"rPrChange",
"tblPrChange",
"trPrChange",
"tcPrChange",
"sectPrChange",
)
}
with zipfile.ZipFile(source) as archive:
for name in archive.namelist():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
root = parse_xml_bytes(archive.read(name), label=name)
if any(element.tag in revision_tags for element in root.iter()):
return True
return False
def _iter_table_paragraphs(table: Any) -> Iterable[Any]:
for row in table.rows:
for cell in row.cells:
yield from cell.paragraphs
for nested_table in cell.tables:
yield from _iter_table_paragraphs(nested_table)
def _iter_scope_paragraphs(document: Any, scope: str) -> list[Any]:
if scope not in {"body", "tables", "headers", "footers", "all"}:
raise ValueError("scope 仅支持 body、tables、headers、footers、all")
result: list[Any] = []
if scope in {"body", "all"}:
result.extend(document.paragraphs)
if scope in {"tables", "all"}:
for table in document.tables:
result.extend(_iter_table_paragraphs(table))
if scope in {"headers", "all"}:
for section in document.sections:
result.extend(section.header.paragraphs)
for table in section.header.tables:
result.extend(_iter_table_paragraphs(table))
if scope in {"footers", "all"}:
for section in document.sections:
result.extend(section.footer.paragraphs)
for table in section.footer.tables:
result.extend(_iter_table_paragraphs(table))
deduplicated: list[Any] = []
seen: set[int] = set()
for paragraph in result:
identity = id(paragraph._p)
if identity not in seen:
seen.add(identity)
deduplicated.append(paragraph)
return deduplicated
def _matches(
text: str,
find: str,
*,
match_case: bool,
whole_word: bool,
) -> list[re.Match[str]]:
pattern = re.escape(find)
if whole_word:
pattern = rf"(?<!\w){pattern}(?!\w)"
flags = 0 if match_case else re.IGNORECASE
return list(re.finditer(pattern, text, flags))
def _replace_in_paragraph(
paragraph: Any,
*,
find: str,
replacement: str,
match_case: bool,
whole_word: bool,
remaining: int,
) -> int:
runs = paragraph.runs
if not runs:
return 0
texts = [run.text for run in runs]
full_text = "".join(texts)
found = _matches(
full_text,
find,
match_case=match_case,
whole_word=whole_word,
)
if remaining >= 0:
found = found[:remaining]
if not found:
return 0
spans: list[tuple[int, int]] = []
offset = 0
for text in texts:
spans.append((offset, offset + len(text)))
offset += len(text)
for match in reversed(found):
start, end = match.span()
start_index = next(
index
for index, (_, run_end) in enumerate(spans)
if start < run_end or (start == 0 and run_end == 0)
)
end_index = next(
index
for index, (run_start, run_end) in enumerate(spans)
if end <= run_end and end > run_start
)
start_run_start, _ = spans[start_index]
end_run_start, _ = spans[end_index]
start_offset = start - start_run_start
end_offset = end - end_run_start
if start_index == end_index:
current = runs[start_index].text
runs[start_index].text = (
current[:start_offset] + replacement + current[end_offset:]
)
else:
start_text = runs[start_index].text
end_text = runs[end_index].text
runs[start_index].text = start_text[:start_offset] + replacement
for index in range(start_index + 1, end_index):
runs[index].text = ""
runs[end_index].text = end_text[end_offset:]
return len(found)
def _op_replace_text(document: Any, op: dict[str, Any]) -> dict[str, Any]:
find = str(op.get("find", ""))
if not find:
raise ValueError("replace_text.find 不能为空")
replacement = str(op.get("replace", ""))
limit = int(op.get("count", 0))
if limit < 0 or limit > MAX_REPLACEMENTS:
raise ValueError(f"replace_text.count 必须在 0 到 {MAX_REPLACEMENTS} 之间")
scope = str(op.get("scope", "all"))
paragraphs = _iter_scope_paragraphs(document, scope)
total = 0
affected: list[int] = []
for index, paragraph in enumerate(paragraphs):
remaining = -1 if limit == 0 else limit - total
if remaining == 0:
break
count = _replace_in_paragraph(
paragraph,
find=find,
replacement=replacement,
match_case=bool(op.get("match_case", True)),
whole_word=bool(op.get("whole_word", False)),
remaining=remaining,
)
if count:
total += count
if len(affected) < 100:
affected.append(index)
if bool(op.get("required", True)) and total == 0:
raise ValueError(f"未找到要替换的文本:{find!r}")
return {
"type": "replace_text",
"replacement_count": total,
"affected_paragraph_indexes": affected,
}
def _op_append_blocks(document: Any, op: dict[str, Any]) -> dict[str, Any]:
counts = add_blocks(document, document, op.get("blocks"))
return {"type": "append_blocks", "counts": counts}
def _find_body_paragraph(document: Any, text: str, match: str) -> Any:
for paragraph in document.paragraphs:
if match == "exact" and paragraph.text == text:
return paragraph
if match == "contains" and text in paragraph.text:
return paragraph
raise ValueError(f"未找到锚点段落:{text!r}")
def _op_insert_blocks_after(document: Any, op: dict[str, Any]) -> dict[str, Any]:
anchor_text = str(op.get("find", ""))
if not anchor_text:
raise ValueError("insert_blocks_after.find 不能为空")
match = str(op.get("match", "contains"))
if match not in {"exact", "contains"}:
raise ValueError("insert_blocks_after.match 仅支持 exact、contains")
anchor = _find_body_paragraph(document, anchor_text, match)
body = document.element.body
before_elements = set(body.iterchildren())
counts = add_blocks(document, document, op.get("blocks"))
new_elements = [
child
for child in body.iterchildren()
if child not in before_elements and child.tag != f"{{{W_NS}}}sectPr"
]
insertion_point = anchor._p
for element in new_elements:
body.remove(element)
insertion_point.addnext(element)
insertion_point = element
return {"type": "insert_blocks_after", "counts": counts}
def _op_remove_paragraphs(document: Any, op: dict[str, Any]) -> dict[str, Any]:
text = str(op.get("text", ""))
if not text:
raise ValueError("remove_paragraphs.text 不能为空")
match = str(op.get("match", "contains"))
if match not in {"exact", "contains"}:
raise ValueError("remove_paragraphs.match 仅支持 exact、contains")
limit = int(op.get("count", 0))
removed = 0
for paragraph in list(document.paragraphs):
matched = paragraph.text == text if match == "exact" else text in paragraph.text
if not matched:
continue
paragraph._element.getparent().remove(paragraph._element)
removed += 1
if limit and removed >= limit:
break
if bool(op.get("required", True)) and removed == 0:
raise ValueError(f"未找到要删除的段落:{text!r}")
return {"type": "remove_paragraphs", "removed_count": removed}
def _op_set_paragraph_style(document: Any, op: dict[str, Any]) -> dict[str, Any]:
style = str(op.get("style", ""))
if not style:
raise ValueError("set_paragraph_style.style 不能为空")
if style not in document.styles:
raise ValueError(f"文档样式不存在:{style}")
indexes = op.get("indexes")
changed = 0
if indexes is not None:
for raw_index in expect_list(indexes, "set_paragraph_style.indexes"):
index = int(raw_index)
if index < 0 or index >= len(document.paragraphs):
raise ValueError(f"段落索引超出范围:{index}")
document.paragraphs[index].style = style
changed += 1
else:
text = str(op.get("contains", ""))
if not text:
raise ValueError("需要提供 indexes 或 contains")
for paragraph in document.paragraphs:
if text in paragraph.text:
paragraph.style = style
changed += 1
if bool(op.get("required", True)) and changed == 0:
raise ValueError("没有段落应用到指定样式")
return {"type": "set_paragraph_style", "changed_count": changed}
def _op_set_properties(document: Any, op: dict[str, Any]) -> dict[str, Any]:
apply_core_properties(document, op.get("properties"))
return {"type": "set_properties"}
def _op_set_page(document: Any, op: dict[str, Any]) -> dict[str, Any]:
selection = op.get("section", "all")
if selection == "all":
sections = list(document.sections)
else:
index = int(selection)
if index < 0 or index >= len(document.sections):
raise ValueError(f"section 索引超出范围:{index}")
sections = [document.sections[index]]
for section in sections:
apply_page_settings(section, op.get("page"))
return {"type": "set_page", "section_count": len(sections)}
def _op_set_header_footer(document: Any, op: dict[str, Any]) -> dict[str, Any]:
apply_header_footer(
document,
op.get("header"),
op.get("footer"),
)
return {"type": "set_header_footer", "section_count": len(document.sections)}
def _op_remove_tables(document: Any, op: dict[str, Any]) -> dict[str, Any]:
indexes = sorted(
{int(item) for item in expect_list(op.get("indexes"), "remove_tables.indexes")},
reverse=True,
)
for index in indexes:
if index < 0 or index >= len(document.tables):
raise ValueError(f"表格索引超出范围:{index}")
table = document.tables[index]
table._element.getparent().remove(table._element)
return {"type": "remove_tables", "removed_count": len(indexes)}
def _apply_operation(document: Any, raw_op: Any) -> dict[str, Any]:
op = expect_object(raw_op, "operations[]")
kind = str(op.get("type", ""))
handlers = {
"replace_text": _op_replace_text,
"append_blocks": _op_append_blocks,
"insert_blocks_after": _op_insert_blocks_after,
"remove_paragraphs": _op_remove_paragraphs,
"set_paragraph_style": _op_set_paragraph_style,
"set_properties": _op_set_properties,
"set_page": _op_set_page,
"set_header_footer": _op_set_header_footer,
"remove_tables": _op_remove_tables,
}
handler = handlers.get(kind)
if handler is None:
raise ValueError(
f"不支持的操作 {kind!r};可选:{''.join(sorted(handlers))}"
)
return handler(document, op)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="依据受控 JSON 操作编辑现有 Word DOCX 文档。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--spec", help="内联 JSON 编辑说明")
parser.add_argument("--spec-file", help="JSON 编辑说明文件")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument(
"--allow-existing-revisions",
action="store_true",
help="明确允许编辑含现有修订的文档",
)
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
destination = output_file(
args.output,
{".docx"},
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("输出路径不能与输入文件相同;请保留原始文档")
spec = load_json_argument(args.spec, args.spec_file, label="编辑说明")
unknown = set(spec) - {"operations"}
if unknown:
raise ValueError(f"编辑说明包含未知顶层字段:{sorted(unknown)}")
operations = expect_list(spec.get("operations"), "operations")
if not operations:
raise ValueError("operations 不能为空")
if len(operations) > MAX_OPERATIONS:
raise ValueError(f"operations 不能超过 {MAX_OPERATIONS}")
has_revisions = _has_revisions(source)
if has_revisions and not args.allow_existing_revisions:
raise ValueError(
"输入文档含修订记录。为避免把未接受修订静默改坏,"
"请先使用 accept_changes.py 生成干净副本,"
"或在用户明确要求保留现有修订时传 --allow-existing-revisions"
)
document = Document(str(source))
results: list[dict[str, Any]] = []
for index, operation in enumerate(operations):
try:
results.append(_apply_operation(document, operation))
except Exception as exc:
raise ValueError(f"{index + 1} 个操作失败:{exc}") from exc
set_update_fields(document)
descriptor, temp_name = tempfile.mkstemp(
prefix=f".{destination.stem}.",
suffix=".docx",
dir=str(destination.parent),
)
os.close(descriptor)
temp_path = Path(temp_name)
try:
document.save(temp_path)
archive = inspect_archive(temp_path)
if archive["missing_required_parts"]:
raise ValueError(
"编辑结果缺少必要部件:"
+ "".join(archive["missing_required_parts"])
)
Document(str(temp_path))
publish_file(temp_path, destination, overwrite=args.overwrite)
finally:
if temp_path.exists():
temp_path.unlink()
return {
"path": str(destination),
"source": str(source),
"operation_count": len(operations),
"operation_results": results,
"paragraph_count": len(document.paragraphs),
"table_count": len(document.tables),
"section_count": len(document.sections),
"source_had_revisions": has_revisions,
"archive": archive,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,351 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import zipfile
from pathlib import Path
from typing import Any, Optional
from _docx_common import (
DOCX_INPUT_SUFFIXES,
NS,
SkillArgumentParser,
W_NS,
input_file,
inspect_archive,
parse_xml_bytes,
run_cli,
)
def _length_inches(value: Any) -> Optional[float]:
if value is None:
return None
return round(float(value.inches), 4)
def _paragraph_payload(
paragraph: Any,
index: int,
include_runs: bool,
accepted_text: Optional[str] = None,
) -> dict[str, Any]:
style = paragraph.style
visible_text = paragraph.text if accepted_text is None else accepted_text
payload: dict[str, Any] = {
"index": index,
"text": visible_text,
"style": style.name if style else None,
"alignment": (
str(paragraph.alignment) if paragraph.alignment is not None else None
),
}
if visible_text != paragraph.text:
payload["python_docx_text"] = paragraph.text
if include_runs:
payload["runs"] = [
{
"text": run.text,
"bold": run.bold,
"italic": run.italic,
"underline": run.underline,
"font": run.font.name,
"size_pt": run.font.size.pt if run.font.size else None,
"style": run.style.name if run.style else None,
}
for run in paragraph.runs[:200]
]
payload["runs_truncated"] = max(0, len(paragraph.runs) - 200)
return payload
def _accepted_body_paragraphs(source: Path) -> list[str]:
def rendered_text(element: Any, deleted: bool = False) -> str:
if element.tag in {
f"{{{W_NS}}}del",
f"{{{W_NS}}}moveFrom",
}:
deleted = True
if deleted:
return ""
if element.tag == f"{{{W_NS}}}t":
return element.text or ""
if element.tag == f"{{{W_NS}}}tab":
return "\t"
if element.tag in {
f"{{{W_NS}}}br",
f"{{{W_NS}}}cr",
}:
return "\n"
return "".join(rendered_text(child, deleted) for child in list(element))
with zipfile.ZipFile(source) as archive:
root = parse_xml_bytes(
archive.read("word/document.xml"),
label="word/document.xml",
)
body = root.find(f"{{{W_NS}}}body")
if body is None:
return []
return [
rendered_text(paragraph)
for paragraph in list(body)
if paragraph.tag == f"{{{W_NS}}}p"
]
def _table_payload(table: Any, index: int, max_cells: int) -> tuple[dict[str, Any], int]:
rows: list[list[str]] = []
used = 0
truncated = False
for row in table.rows:
row_values: list[str] = []
for cell in row.cells:
if used >= max_cells:
truncated = True
break
row_values.append(cell.text)
used += 1
rows.append(row_values)
if truncated:
break
return (
{
"index": index,
"style": table.style.name if table.style else None,
"row_count": len(table.rows),
"column_count": len(table.columns),
"rows": rows,
"truncated": truncated,
},
used,
)
def _revision_summary(source: Path) -> dict[str, Any]:
counts = {
"insertions": 0,
"deletions": 0,
"moves_from": 0,
"moves_to": 0,
"property_changes": 0,
}
authors: set[str] = set()
property_change_tags = {
f"{{{W_NS}}}{name}"
for name in (
"pPrChange",
"rPrChange",
"tblPrChange",
"tblGridChange",
"trPrChange",
"tcPrChange",
"sectPrChange",
"numberingChange",
)
}
with zipfile.ZipFile(source) as archive:
for name in archive.namelist():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
root = parse_xml_bytes(archive.read(name), label=name)
for element in root.iter():
is_revision = False
if element.tag == f"{{{W_NS}}}ins":
counts["insertions"] += 1
is_revision = True
elif element.tag == f"{{{W_NS}}}del":
counts["deletions"] += 1
is_revision = True
elif element.tag == f"{{{W_NS}}}moveFrom":
counts["moves_from"] += 1
is_revision = True
elif element.tag == f"{{{W_NS}}}moveTo":
counts["moves_to"] += 1
is_revision = True
elif element.tag in property_change_tags:
counts["property_changes"] += 1
is_revision = True
if is_revision:
author = element.attrib.get(f"{{{W_NS}}}author")
if author:
authors.add(author)
return {
**counts,
"total": sum(counts.values()),
"authors": sorted(authors),
}
def _comments(document: Any) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
comments = getattr(document, "comments", None)
if comments is None:
return result
for comment in comments:
result.append(
{
"comment_id": comment.comment_id,
"author": comment.author,
"initials": comment.initials,
"text": comment.text,
"timestamp": comment.timestamp,
}
)
return result
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="分段读取 Word 文档的正文、表格、样式、批注和修订信息。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--start-paragraph", type=int, default=0)
parser.add_argument("--max-paragraphs", type=int, default=80)
parser.add_argument("--start-table", type=int, default=0)
parser.add_argument("--max-tables", type=int, default=10)
parser.add_argument("--max-table-cells", type=int, default=1000)
parser.add_argument("--max-chars", type=int, default=40_000)
parser.add_argument("--include-runs", action="store_true")
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
if args.start_paragraph < 0 or args.start_table < 0:
raise ValueError("start-paragraph 和 start-table 不能为负数")
if args.max_paragraphs < 1 or args.max_paragraphs > 300:
raise ValueError("max-paragraphs 必须在 1 到 300 之间")
if args.max_tables < 0 or args.max_tables > 50:
raise ValueError("max-tables 必须在 0 到 50 之间")
if args.max_table_cells < 1 or args.max_table_cells > 10_000:
raise ValueError("max-table-cells 必须在 1 到 10000 之间")
if args.max_chars < 1000 or args.max_chars > 200_000:
raise ValueError("max-chars 必须在 1000 到 200000 之间")
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
archive = inspect_archive(source)
if archive["missing_required_parts"]:
raise ValueError(
"文档缺少必要部件:" + "".join(archive["missing_required_parts"])
)
document = Document(str(source))
all_paragraphs = document.paragraphs
accepted_paragraphs = _accepted_body_paragraphs(source)
paragraphs: list[dict[str, Any]] = []
char_count = 0
next_paragraph: Optional[int] = None
for index in range(args.start_paragraph, len(all_paragraphs)):
if len(paragraphs) >= args.max_paragraphs:
next_paragraph = index
break
payload = _paragraph_payload(
all_paragraphs[index],
index,
args.include_runs,
(
accepted_paragraphs[index]
if index < len(accepted_paragraphs)
else None
),
)
payload_chars = len(payload["text"])
if paragraphs and char_count + payload_chars > args.max_chars:
next_paragraph = index
break
paragraphs.append(payload)
char_count += payload_chars
tables: list[dict[str, Any]] = []
cells_remaining = args.max_table_cells
next_table: Optional[int] = None
table_end = min(len(document.tables), args.start_table + args.max_tables)
for index in range(args.start_table, table_end):
if cells_remaining <= 0:
next_table = index
break
payload, used = _table_payload(
document.tables[index],
index,
cells_remaining,
)
tables.append(payload)
cells_remaining -= used
if payload["truncated"]:
next_table = index
break
if next_table is None and table_end < len(document.tables):
next_table = table_end
sections = []
for index, section in enumerate(document.sections):
sections.append(
{
"index": index,
"orientation": str(section.orientation),
"page_width_inches": _length_inches(section.page_width),
"page_height_inches": _length_inches(section.page_height),
"margins_inches": {
"top": _length_inches(section.top_margin),
"bottom": _length_inches(section.bottom_margin),
"left": _length_inches(section.left_margin),
"right": _length_inches(section.right_margin),
"header": _length_inches(section.header_distance),
"footer": _length_inches(section.footer_distance),
},
"header_text": "\n".join(
paragraph.text for paragraph in section.header.paragraphs
),
"footer_text": "\n".join(
paragraph.text for paragraph in section.footer.paragraphs
),
}
)
properties = document.core_properties
revisions = _revision_summary(source)
comments = _comments(document)
total_text = "\n".join(
accepted_paragraphs[index]
if index < len(accepted_paragraphs)
else paragraph.text
for index, paragraph in enumerate(all_paragraphs)
)
return {
"path": str(source),
"format": source.suffix.lower(),
"archive": archive,
"properties": {
"title": properties.title,
"subject": properties.subject,
"author": properties.author,
"last_modified_by": properties.last_modified_by,
"category": properties.category,
"keywords": properties.keywords,
"comments": properties.comments,
"created": properties.created,
"modified": properties.modified,
"language": properties.language,
},
"paragraph_count": len(all_paragraphs),
"table_count": len(document.tables),
"section_count": len(document.sections),
"character_count": len(total_text),
"word_count_estimate": len(total_text.split()),
"sections": sections,
"comments": comments,
"tracked_changes": revisions,
"paragraphs": paragraphs,
"tables": tables,
"has_more": next_paragraph is not None or next_table is not None,
"next_paragraph": next_paragraph,
"next_table": next_table,
"needs_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from typing import Any
from _docx_common import (
SkillArgumentParser,
inspect_archive,
output_file,
pack_docx_directory,
publish_file,
run_cli,
)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="把安全解包并编辑后的 OOXML 目录重新打包为 DOCX。"
)
parser.add_argument("--input-dir", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
source_dir = Path(args.input_dir).expanduser().resolve()
destination = output_file(
args.output,
{".docx"},
overwrite=args.overwrite,
)
if destination == source_dir or source_dir in destination.parents:
raise ValueError("输出 DOCX 不能放在待打包目录内部")
descriptor, temp_name = tempfile.mkstemp(
prefix=f".{destination.stem}.",
suffix=".docx",
dir=str(destination.parent),
)
os.close(descriptor)
temp_path = Path(temp_name)
try:
archive = pack_docx_directory(source_dir, temp_path)
if archive["missing_required_parts"]:
raise ValueError(
"打包结果缺少必要部件:"
+ "".join(archive["missing_required_parts"])
)
Document(str(temp_path))
publish_file(temp_path, destination, overwrite=args.overwrite)
finally:
if temp_path.exists():
temp_path.unlink()
return {
"path": str(destination),
"input_dir": str(source_dir),
"archive": inspect_archive(destination),
"requires_validation": True,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import tempfile
from pathlib import Path
from typing import Any, Optional
from _docx_common import (
WORD_INPUT_SUFFIXES,
SkillArgumentParser,
find_program,
input_file,
output_directory,
publish_file,
run_cli,
run_program,
run_soffice_convert,
)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="通过 LibreOffice 和 Poppler 把 Word 文档渲染为逐页 PNG。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument("--end-page", type=int)
parser.add_argument("--max-pages", type=int, default=20)
parser.add_argument("--dpi", type=int, default=150)
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--include-pdf", action="store_true")
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
from pypdf import PdfReader
args = build_parser().parse_args()
if args.start_page < 1:
raise ValueError("start-page 必须大于 0")
if args.end_page is not None and args.end_page < args.start_page:
raise ValueError("end-page 不能小于 start-page")
if args.max_pages < 1 or args.max_pages > 100:
raise ValueError("max-pages 必须在 1 到 100 之间")
if args.dpi < 72 or args.dpi > 300:
raise ValueError("dpi 必须在 72 到 300 之间")
source = input_file(args.input, WORD_INPUT_SUFFIXES)
destination_dir = output_directory(args.output_dir)
with tempfile.TemporaryDirectory(prefix="docx-render-") as temp_name:
temp_dir = Path(temp_name)
staged = temp_dir / f"document{source.suffix.lower()}"
shutil.copy2(source, staged)
pdf, office_output = run_soffice_convert(
staged,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
page_count = len(PdfReader(str(pdf)).pages)
if page_count < 1:
raise ValueError("LibreOffice 生成的 PDF 没有页面")
if args.start_page > page_count:
raise ValueError(f"start-page 超出页面总数 {page_count}")
requested_end = page_count if args.end_page is None else args.end_page
if requested_end > page_count:
raise ValueError(f"end-page 超出页面总数 {page_count}")
actual_end = min(
requested_end,
args.start_page + args.max_pages - 1,
)
raw_prefix = temp_dir / "raw-page"
run_program(
[
find_program("pdftoppm"),
"-png",
"-r",
str(args.dpi),
"-f",
str(args.start_page),
"-l",
str(actual_end),
str(pdf),
str(raw_prefix),
],
timeout=args.timeout,
)
raw_pages = sorted(
temp_dir.glob("raw-page-*.png"),
key=lambda path: int(path.stem.rsplit("-", 1)[1]),
)
expected_count = actual_end - args.start_page + 1
if len(raw_pages) != expected_count:
raise RuntimeError(
f"Poppler 应生成 {expected_count} 页,实际生成 {len(raw_pages)}"
)
destinations = [
destination_dir / f"page-{page_number:04d}.png"
for page_number in range(args.start_page, actual_end + 1)
]
if args.include_pdf:
destinations.append(destination_dir / "document.pdf")
if not args.overwrite:
existing = [str(path) for path in destinations if path.exists()]
if existing:
raise FileExistsError(
"以下渲染目标已存在:" + "".join(existing)
)
output_paths: list[str] = []
for page_number, raw_page in zip(
range(args.start_page, actual_end + 1),
raw_pages,
):
destination = destination_dir / f"page-{page_number:04d}.png"
publish_file(raw_page, destination, overwrite=args.overwrite)
output_paths.append(str(destination))
pdf_output: Optional[str] = None
if args.include_pdf:
destination_pdf = destination_dir / "document.pdf"
staged_pdf = temp_dir / "publish.pdf"
shutil.copy2(pdf, staged_pdf)
publish_file(
staged_pdf,
destination_pdf,
overwrite=args.overwrite,
)
pdf_output = str(destination_pdf)
next_page = actual_end + 1 if actual_end < requested_end else None
return {
"source": str(source),
"page_count": page_count,
"start_page": args.start_page,
"end_page": actual_end,
"rendered_pages": output_paths,
"pdf": pdf_output,
"has_more": next_page is not None,
"next_page": next_page,
"dpi": args.dpi,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,48 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
from _docx_common import (
DOCX_INPUT_SUFFIXES,
SkillArgumentParser,
input_file,
safe_extract_docx,
run_cli,
)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="安全解包 DOCX拒绝路径穿越、符号链接和压缩炸弹。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output-dir", required=True)
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
destination = Path(args.output_dir).expanduser().resolve()
if destination.exists():
if not destination.is_dir():
raise ValueError(f"输出路径不是目录:{destination}")
if any(destination.iterdir()):
raise FileExistsError(f"输出目录已存在且不为空:{destination}")
else:
destination.mkdir(parents=True)
archive = safe_extract_docx(source, destination)
return {
"source": str(source),
"output_dir": str(destination),
"archive": archive,
"document_xml": str(destination / "word" / "document.xml"),
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,386 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import posixpath
import shutil
import tempfile
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from urllib.parse import unquote
from _docx_common import (
CONTENT_TYPES_NS,
DOCX_INPUT_SUFFIXES,
REL_NS,
SkillArgumentParser,
W_NS,
input_file,
inspect_archive,
parse_xml_bytes,
run_cli,
run_soffice_convert,
)
def _parse_xml_parts(
archive: zipfile.ZipFile,
) -> tuple[dict[str, Any], list[dict[str, str]]]:
roots: dict[str, Any] = {}
issues: list[dict[str, str]] = []
for name in archive.namelist():
if not name.endswith((".xml", ".rels")):
continue
try:
roots[name] = parse_xml_bytes(archive.read(name), label=name)
except ValueError as exc:
issues.append(
{
"type": "xml_parse_error",
"part": name,
"message": str(exc),
}
)
return roots, issues
def _relationship_source(rels_name: str) -> PurePosixPath:
path = PurePosixPath(rels_name)
if rels_name == "_rels/.rels":
return PurePosixPath("")
if path.parent.name != "_rels" or not path.name.endswith(".rels"):
return PurePosixPath("")
source_name = path.name[: -len(".rels")]
return path.parent.parent / source_name
def _resolve_target(source_part: PurePosixPath, target: str) -> Optional[str]:
if not target or target.startswith("#"):
return None
target = unquote(target).replace("\\", "/")
if target.startswith("/"):
normalized = posixpath.normpath(target.lstrip("/"))
else:
base = str(source_part.parent) if str(source_part) else ""
normalized = posixpath.normpath(posixpath.join(base, target))
if normalized == ".." or normalized.startswith("../"):
raise ValueError("关系目标逃逸 OOXML 包根目录")
return normalized.lstrip("./")
def _validate_relationships(
roots: dict[str, Any],
member_names: set[str],
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
relationship_tag = f"{{{REL_NS}}}Relationship"
for name, root in roots.items():
if not name.endswith(".rels"):
continue
source = _relationship_source(name)
seen_ids: set[str] = set()
for relationship in root.findall(relationship_tag):
relationship_id = relationship.attrib.get("Id", "")
if not relationship_id:
issues.append(
{
"type": "relationship_missing_id",
"part": name,
"message": "Relationship 缺少 Id",
}
)
elif relationship_id in seen_ids:
issues.append(
{
"type": "duplicate_relationship_id",
"part": name,
"message": relationship_id,
}
)
seen_ids.add(relationship_id)
if relationship.attrib.get("TargetMode") == "External":
continue
try:
target = _resolve_target(
source,
relationship.attrib.get("Target", ""),
)
except ValueError as exc:
issues.append(
{
"type": "unsafe_relationship_target",
"part": name,
"message": str(exc),
}
)
continue
if target and target not in member_names:
issues.append(
{
"type": "missing_relationship_target",
"part": name,
"message": target,
}
)
return issues
def _validate_content_types(
roots: dict[str, Any],
member_names: set[str],
) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
root = roots.get("[Content_Types].xml")
if root is None:
return issues
override_tag = f"{{{CONTENT_TYPES_NS}}}Override"
seen_parts: set[str] = set()
for override in root.findall(override_tag):
part = override.attrib.get("PartName", "").lstrip("/")
if not part:
issues.append(
{
"type": "content_type_missing_part",
"part": "[Content_Types].xml",
"message": "Override 缺少 PartName",
}
)
continue
if part in seen_parts:
issues.append(
{
"type": "duplicate_content_type_override",
"part": "[Content_Types].xml",
"message": part,
}
)
seen_parts.add(part)
if part not in member_names:
issues.append(
{
"type": "missing_content_type_part",
"part": "[Content_Types].xml",
"message": part,
}
)
return issues
def _validate_comments(roots: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
comments_root = roots.get("word/comments.xml")
document_root = roots.get("word/document.xml")
if document_root is None:
return issues
comment_ids: set[str] = set()
if comments_root is not None:
for comment in comments_root.iter(f"{{{W_NS}}}comment"):
comment_id = comment.attrib.get(f"{{{W_NS}}}id")
if comment_id:
comment_ids.add(comment_id)
starts: list[str] = []
ends: list[str] = []
references: list[str] = []
for element in document_root.iter():
comment_id = element.attrib.get(f"{{{W_NS}}}id")
if element.tag == f"{{{W_NS}}}commentRangeStart" and comment_id:
starts.append(comment_id)
elif element.tag == f"{{{W_NS}}}commentRangeEnd" and comment_id:
ends.append(comment_id)
elif element.tag == f"{{{W_NS}}}commentReference" and comment_id:
references.append(comment_id)
for comment_id in sorted(set(starts + ends + references)):
if comment_id not in comment_ids:
issues.append(
{
"type": "undefined_comment_reference",
"part": "word/document.xml",
"message": comment_id,
}
)
if starts.count(comment_id) != ends.count(comment_id):
issues.append(
{
"type": "unbalanced_comment_range",
"part": "word/document.xml",
"message": comment_id,
}
)
if references.count(comment_id) == 0:
issues.append(
{
"type": "missing_comment_reference",
"part": "word/document.xml",
"message": comment_id,
}
)
return issues
def _revision_metadata(roots: dict[str, Any]) -> dict[str, Any]:
revision_tags = {
f"{{{W_NS}}}ins",
f"{{{W_NS}}}del",
f"{{{W_NS}}}moveFrom",
f"{{{W_NS}}}moveTo",
}
total = 0
missing_author = 0
missing_date = 0
authors: set[str] = set()
for name, root in roots.items():
if not name.startswith("word/") or not name.endswith(".xml"):
continue
for element in root.iter():
if element.tag not in revision_tags:
continue
total += 1
author = element.attrib.get(f"{{{W_NS}}}author")
date = element.attrib.get(f"{{{W_NS}}}date")
if author:
authors.add(author)
else:
missing_author += 1
if not date:
missing_date += 1
return {
"total": total,
"authors": sorted(authors),
"missing_author_count": missing_author,
"missing_date_count": missing_date,
}
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="校验 DOCX 压缩包、XML、关系、批注和 LibreOffice 可打开性。"
)
parser.add_argument("--input", required=True)
parser.add_argument(
"--check-convert",
action="store_true",
help="额外用 LibreOffice 转 PDF验证文档可渲染",
)
parser.add_argument("--timeout", type=int, default=120)
return parser
def main() -> dict[str, Any]:
from docx import Document
args = build_parser().parse_args()
source = input_file(args.input, DOCX_INPUT_SUFFIXES)
archive_info = inspect_archive(source)
issues: list[dict[str, str]] = []
for part in archive_info["missing_required_parts"]:
issues.append(
{
"type": "missing_required_part",
"part": part,
"message": part,
}
)
for part in archive_info["duplicate_members"]:
issues.append(
{
"type": "duplicate_zip_member",
"part": part,
"message": part,
}
)
with zipfile.ZipFile(source) as archive:
member_names = set(archive.namelist())
roots, xml_issues = _parse_xml_parts(archive)
issues.extend(xml_issues)
issues.extend(_validate_relationships(roots, member_names))
issues.extend(_validate_content_types(roots, member_names))
issues.extend(_validate_comments(roots))
revisions = _revision_metadata(roots)
if revisions["missing_author_count"]:
issues.append(
{
"type": "revision_missing_author",
"part": "word/*.xml",
"message": str(revisions["missing_author_count"]),
}
)
if revisions["missing_date_count"]:
issues.append(
{
"type": "revision_missing_date",
"part": "word/*.xml",
"message": str(revisions["missing_date_count"]),
}
)
document_summary: dict[str, Any] = {}
try:
document = Document(str(source))
document_summary = {
"paragraph_count": len(document.paragraphs),
"table_count": len(document.tables),
"section_count": len(document.sections),
"comment_count": len(document.comments),
}
except Exception as exc:
issues.append(
{
"type": "python_docx_open_error",
"part": str(source),
"message": str(exc),
}
)
render_check: Optional[dict[str, Any]] = None
if args.check_convert:
try:
from pypdf import PdfReader
with tempfile.TemporaryDirectory(prefix="docx-validate-") as temp_name:
temp_dir = Path(temp_name)
staged = temp_dir / "document.docx"
shutil.copy2(source, staged)
pdf, office_output = run_soffice_convert(
staged,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
page_count = len(PdfReader(str(pdf)).pages)
if page_count < 1:
raise ValueError("转换结果没有页面")
render_check = {
"success": True,
"page_count": page_count,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
except Exception as exc:
issues.append(
{
"type": "libreoffice_render_error",
"part": str(source),
"message": str(exc),
}
)
render_check = {"success": False, "error": str(exc)}
return {
"path": str(source),
"status": "valid" if not issues else "invalid",
"issue_count": len(issues),
"issues": issues[:200],
"issues_truncated": max(0, len(issues) - 200),
"archive": archive_info,
"document": document_summary,
"tracked_changes": revisions,
"render_check": render_check,
"requires_visual_review": True,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

222
skills/xlsx/SKILL.md Normal file
View File

@ -0,0 +1,222 @@
---
name: xlsx
description: "创建、读取、编辑、修复、转换、重算、校验和渲染本地或远程 HTTPS Excel 工作簿及表格数据。用户提到 Excel、电子表格、工作簿、工作表、单元格、公式、图表、数据清洗或提供 HTTPS Excel/CSV/TSV 地址、.xlsx、.xlsm、.xltx、.xls、.csv、.tsv 文件时使用;支持安全下载、保留现有样式、批量写入、公式与缓存值检查、表格/图表/图片/数据验证/条件格式、旧格式转换和逐页视觉检查。最终交付物必须是电子表格文件;若主要交付物是 Word、PDF、HTML、数据库程序或在线 Google Sheets则不要使用。"
---
# Excel 工作簿处理
## 强制执行规则
当前智能体不能直接执行 shell、任意 Python 代码或系统命令。只能通过 `execute_skill_script` 调用本 Skill 中真实存在的固定 Python 脚本。
- 只调用下表列出的可执行脚本,不执行 `scripts/` 目录或内部模块 `scripts/_xlsx_common.py`
- 不把 `python3`、`soffice`、`libreoffice`、`pdftoppm`、`zip`、`unzip`、`rm` 或其他系统命令作为脚本参数。
- 外部程序只允许由固定脚本在内部以无 shell 参数数组方式调用。
- 每次检查脚本返回的 JSON只有 `ok``true` 时才继续。`status: errors_found` 虽然表示脚本成功运行,但工作簿不合格,必须修复。
- 远程地址只交给 `download_workbook.py`;不要在回复、日志摘要或文件名中复述可能含敏感查询参数的完整 URL。
- 不覆盖用户提供的源文件。创建或编辑结果写入 `output/xlsx/`,中间产物写入 `tmp/xlsx/<任务名>/`
- 环境已预置依赖,不安装软件包,也不提示用户安装依赖。
## 脚本清单
| 脚本 | 用途 | 底层能力 |
| --- | --- | --- |
| `scripts/download_workbook.py` | 下载并校验远程 HTTPS Excel/CSV/TSV | Python `urllib`、安全 OOXML 解析、`openpyxl` |
| `scripts/inspect_workbook.py` | 分段读取结构、公式、缓存值和样式 | `openpyxl`、Python `csv` |
| `scripts/apply_workbook.py` | 按受控 JSON 创建或编辑工作簿 | `openpyxl`、Pillow |
| `scripts/convert_workbook.py` | 转换 `.xls/.csv/.tsv/.xlsx/.xlsm/.xltx` | `openpyxl`、LibreOffice |
| `scripts/recalculate_workbook.py` | 重算公式并检查公式错误 | LibreOffice、`openpyxl` |
| `scripts/render_workbook.py` | 把工作簿渲染为逐页 PNG/PDF | LibreOffice、Poppler |
## 标准流程
1. 输入是 HTTPS 地址时,先调用 `download_workbook.py` 下载到本次任务临时目录;本地文件直接进入下一步。
2. 检查输入格式。旧版 `.xls` 先调用 `convert_workbook.py` 转为 `.xlsx`
3. 编辑现有文件前先调用 `inspect_workbook.py`;读取公式和缓存值,确认工作表名称、输入区域、合并区域、表格、图表、隐藏工作表和外部链接。
4. 用 `apply_workbook.py` 创建或编辑新文件。只修改用户要求的单元格或结构,保留未涉及的公式和样式。
5. 只要结果中 `formula_count > 0``requires_recalculation: true`,必须调用 `recalculate_workbook.py`,并确保 `status: success`、`total_errors: 0`。
6. 先抽查 23 个关键公式的引用和计算逻辑,再调用 `inspect_workbook.py` 读取重算后的公式与缓存值;“没有公式错误”不等于“公式逻辑正确”。
7. 创建或修改后调用 `render_workbook.py`,检查全部页面或按游标分批检查,确认没有裁切、异常分页、乱码、重叠、空白页或不可读图表。
8. 只有结构检查、公式检查和视觉检查都通过后才交付最终工作簿。
## 下载远程工作簿
只接受 HTTPS 地址。完整保留 URL 及查询参数传给脚本,但不要在回复、日志摘要或输出文件名中复述敏感参数。
调用 `scripts/download_workbook.py`
```text
--url 'https://example.com/report.xlsx?signature=...' --output 'tmp/xlsx/<任务名>/source.xlsx'
```
可选参数:
- `--timeout <1-600>`:连接和读取超时秒数,默认 `60`
- `--max-bytes <字节数>`:默认 `104857600`100 MiB最高 `536870912`512 MiB
- `--overwrite`:只在目标是本次任务生成的旧缓存时使用。
`output` 扩展名必须是 `.xlsx`、`.xlsm`、`.xltx`、`.xltm`、`.xls`、`.csv` 或 `.tsv`。脚本阻止 HTTPS 重定向降级到 HTTP流式限制大小先写同目录临时文件再原子发布OOXML 会检查 ZIP 路径、成员大小、内容类型并用 `openpyxl` 打开CSV/TSV 会拒绝二进制或网页响应。实际 OOXML 格式与 `output` 扩展名不一致时,根据错误中的实际格式更正缓存扩展名,再调用同一脚本。
成功结果包含 `path`、`size_bytes`、`format` 和 `validation`OOXML 还包含 `sheet_count`。后续脚本只使用返回的本地 `path`,不再访问原 URL。
## 检查工作簿
调用 `scripts/inspect_workbook.py`
```text
--input 'source.xlsx'
```
可选参数:
- `--sheet <名称>`:选择工作表;默认活动工作表。
- `--start-row <行>`、`--start-column <>`:读取起点,均从 `1` 开始。
- `--max-rows <1-200>`、`--max-columns <1-100>`:限制单次输出,默认 `40 × 20`
结果同时给出公式字符串和缓存值:
- `formula`:原始公式。
- `cached_value`Excel/LibreOffice 上次计算后保存的结果。
- `has_external_links`:为 `true` 时,编辑或重算可能破坏外部链接缓存;默认停止并向用户说明。
- `selection.has_more`、`next_row`、`next_column`:用于继续读取大表,不要一次返回整本工作簿。
CSV/TSV 只返回行数据,不存在工作表。`.xls` 必须先转换。
## 创建或编辑
调用 `scripts/apply_workbook.py`,新建时省略 `--input`,编辑时提供源文件:
```text
--output 'output/xlsx/result.xlsx' --spec '<JSON对象>'
```
或:
```text
--input 'source.xlsx' --output 'output/xlsx/result.xlsx' --spec-file 'tmp/xlsx/task/operations.json'
```
目标已存在且确认是本次任务的旧产物时才传 `--overwrite`。输入含外部链接时脚本默认拒绝保存;只有用户明确接受缓存值可能丢失的风险时才传 `--allow-external-links`。`.xlsm` 必须继续输出 `.xlsm` 才能保留宏;只有用户明确同意丢弃宏时才输出 `.xlsx` 并传 `--drop-macros`
操作说明顶层字段:
```json
{
"properties": {
"title": "销售分析",
"creator": "示例公司"
},
"calculation_mode": "auto",
"active_sheet": "汇总",
"operations": []
}
```
支持的 `operations[].type`
| 类型 | 关键字段 |
| --- | --- |
| `add_sheet` | `name`,可选 `index` |
| `remove_sheet` | `sheet` |
| `rename_sheet` | `sheet`、`name` |
| `set_cells` | `sheet`、`cells[]` |
| `write_rows` | `sheet`、`start_cell`、`rows[][]`,可选统一 `style` |
| `append_rows` | `sheet`、`rows[][]` |
| `style_range` | `sheet`、`range`、`style` |
| `clear_range` | `sheet`、`range`,可选 `values/styles/comments/hyperlinks` |
| `insert_rows` / `delete_rows` | `sheet`、`index`、`amount` |
| `insert_columns` / `delete_columns` | `sheet`、`index`、`amount` |
| `merge_cells` / `unmerge_cells` | `sheet`、`range` |
| `set_column_widths` | `sheet`、`widths`,如 `{"A": 18, "B:D": 12}` |
| `set_row_heights` | `sheet`、`heights`,如 `{"1": 28, "2:5": 20}` |
| `freeze_panes` | `sheet`、`cell`;传空值取消冻结 |
| `set_auto_filter` | `sheet`、`range`;传空值取消筛选 |
| `add_table` | `sheet`、`range`、`name`,可选 `style` |
| `add_chart` | `sheet`、`chart_type`、`data_range`、`anchor`;可选 `categories_range/title` |
| `add_image` | `sheet`、`path`、`anchor`;可选像素 `width/height` |
| `add_data_validation` | `sheet`、`range`、`validation_type`、`formula1` |
| `add_conditional_format` | `sheet`、`range`、`rule_type` 及对应规则参数 |
| `set_print` | `sheet`,可选 `print_area/orientation/paper_size/fit_to_width/margins` |
| `set_named_range` | `sheet`、`name`、`range` |
`set_cells.cells[]` 中每项使用:
```json
{
"cell": "B2",
"formula": "=SUM(B3:B10)",
"style": {
"font": {"name": "Arial", "size": 11, "bold": true, "color": "FFFFFF"},
"fill": {"color": "1F4E78"},
"alignment": {"horizontal": "center", "vertical": "center", "wrap_text": true},
"number_format": "#,##0.00",
"border": {
"bottom": {"style": "thin", "color": "808080"}
},
"protection": {"locked": true}
},
"comment": {"author": "AI", "text": "来源:用户提供的 2026 年预算"},
"hyperlink": "https://example.com/source"
}
```
同一单元格不能同时传 `value``formula`。`formula` 必须以 `=` 开头。`write_rows.rows[][]` 可直接传值,也可在某个位置传带 `value/formula/style/comment/hyperlink` 的对象。
## 转换文件
调用 `scripts/convert_workbook.py`
```text
--input 'legacy.xls' --output 'tmp/xlsx/task/source.xlsx'
```
常见用法:
- CSV/TSV → XLSX可传 `--sheet-name <名称>`;默认所有字段按文本保留,确认可以推断数字/布尔值时才传 `--infer-types`
- XLSX/XLSM → CSV/TSV可传 `--sheet <名称>`;默认导出缓存结果,明确需要公式字符串时传 `--formulas`
- Excel → PDF输出路径使用 `.pdf`;该 PDF 仅用于预览或用户明确要求的转换,不替代工作簿交付。
- 中文旧系统文本可传 `--encoding gb18030`;默认 `utf-8-sig`
## 公式重算
含公式的工作簿必须调用 `scripts/recalculate_workbook.py`
```text
--input 'output/xlsx/result.xlsx' --output 'output/xlsx/result-recalculated.xlsx'
```
检查返回值:
- `status: success``total_errors: 0`:公式可被 LibreOffice 计算。
- `status: errors_found`:根据 `error_summary` 中的工作表、单元格和公式修复,再重算。
- `missing_cached_value_count > 0`:可能是公式结果为空字符串,也可能未正确计算;逐个抽查。
优先使用 Excel 2007 时代即可稳定重算的函数,如 `SUMIFS`、`INDEX`、`MATCH`、`IFERROR`、`SUMPRODUCT`。避免 `XLOOKUP`、`XMATCH`、`SORT`、`FILTER`、`UNIQUE`、`SEQUENCE` 等动态数组或新函数;脚本会提示但不能证明其结果完整。
## 渲染与视觉检查
调用 `scripts/render_workbook.py`
```text
--input 'output/xlsx/result-recalculated.xlsx' --output-dir 'tmp/xlsx/task/rendered'
```
默认 150 DPI、单次最多 20 页。可传:
- `--start-page`、`--end-page`、`--max-pages`:分批渲染。
- `--dpi <72-300>`:小字或复杂图表可提高到 180220。
- `--include-pdf`:同时保留 `workbook.pdf`
- `--overwrite`:只覆盖本次任务旧渲染。
`has_more: true`,用 `next_page` 继续。通过可用的图片查看工具逐页检查返回的 PNG。
## 质量要求
- 默认使用专业字体:中文使用 `Noto Sans CJK SC` 或与原文件一致的字体,拉丁文字使用 Arial编辑现有文件时原有规范优先。
- 表头、单位、日期、货币、百分比和负数格式必须明确;百分比按小数存储,例如 `0.15` 显示为 `15.0%`
- 可计算结果使用公式,不把当前结果硬编码进单元格;假设值单独放在有标签的输入单元格中。
- 每个外部数据、假设和硬编码数字都用批注或邻近单元格说明来源。
- 新建供他人填写的模板要包含填写说明和一行格式示例;编辑现有文件时不要擅自插入示例行。
- 精确遵循用户指定的工作表名、表头、公式和输出格式,不擅自重构业务逻辑。
- 合并单元格只写左上角锚点;编辑 `.xlsm` 时保留宏;不要用 `data_only=True` 读取后再保存。
- 公式重算、关键值抽查和全部页面视觉检查全部通过后再交付。

View File

@ -0,0 +1,4 @@
interface:
display_name: "Excel 工作簿"
short_description: "安全下载、创建、编辑、重算、校验并渲染 Excel 工作簿"
default_prompt: "使用 $xlsx 创建或处理本地文件或 HTTPS 链接中的 Excel 工作簿,并完成公式与版式校验。"

View File

@ -0,0 +1,321 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import tempfile
from datetime import date, datetime, time
from decimal import Decimal
from pathlib import Path
from typing import Any, Callable, NoReturn, Optional
from urllib.parse import quote
EXCEL_INPUT_SUFFIXES = {".xlsx", ".xlsm", ".xltx", ".xltm"}
TABULAR_INPUT_SUFFIXES = EXCEL_INPUT_SUFFIXES | {".xls", ".csv", ".tsv"}
EXCEL_OUTPUT_SUFFIXES = {".xlsx", ".xlsm"}
FORMULA_ERROR_VALUES = {
"#NULL!",
"#DIV/0!",
"#VALUE!",
"#REF!",
"#NAME?",
"#NUM!",
"#N/A",
"#GETTING_DATA",
"#SPILL!",
"#CALC!",
"#FIELD!",
"#BLOCKED!",
"#UNKNOWN!",
}
CELL_REFERENCE_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,6}$")
CELL_RANGE_RE = re.compile(
r"^(?P<start>[A-Z]{1,3}[1-9][0-9]{0,6}):"
r"(?P<end>[A-Z]{1,3}[1-9][0-9]{0,6})$"
)
class SkillArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
raise ValueError(f"参数错误:{message}")
def emit(payload: dict[str, Any]) -> None:
print(json.dumps(payload, ensure_ascii=False, default=json_default))
def json_default(value: Any) -> Any:
if isinstance(value, (datetime, date, time)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, Path):
return str(value)
return str(value)
def failure_message(exc: Exception) -> str:
if isinstance(exc, FileExistsError):
return str(exc)
if isinstance(exc, FileNotFoundError):
return str(exc)
if isinstance(exc, PermissionError):
return "文件处理失败:没有目标路径的访问权限"
if isinstance(exc, subprocess.TimeoutExpired):
return f"外部程序执行超时({exc.timeout} 秒)"
if isinstance(exc, subprocess.CalledProcessError):
stderr = (exc.stderr or "").strip()
return f"外部程序执行失败:{stderr or exc}"
if isinstance(exc, OSError):
return f"文件处理失败:{exc}"
return str(exc)
def run_cli(action: Callable[[], dict[str, Any]]) -> int:
try:
result = action()
except Exception as exc:
emit({"ok": False, "error": failure_message(exc)})
return 1
emit({"ok": True, **result})
return 0
def input_file(value: str, suffixes: Optional[set[str]] = None) -> Path:
path = Path(value).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"输入文件不存在:{path}")
if not path.is_file():
raise ValueError(f"输入路径不是文件:{path}")
if path.stat().st_size <= 0:
raise ValueError(f"输入文件为空:{path}")
if suffixes is not None and path.suffix.lower() not in suffixes:
expected = "".join(sorted(suffixes))
raise ValueError(f"不支持的输入格式 {path.suffix};允许:{expected}")
return path
def output_file(
value: str,
suffixes: Optional[set[str]] = None,
*,
overwrite: bool = False,
) -> Path:
path = Path(value).expanduser().resolve()
if suffixes is not None and path.suffix.lower() not in suffixes:
expected = "".join(sorted(suffixes))
raise ValueError(f"不支持的输出格式 {path.suffix};允许:{expected}")
if path.exists() and not overwrite:
raise FileExistsError(f"目标文件已存在:{path}")
if path.exists() and not path.is_file():
raise ValueError(f"目标路径不是文件:{path}")
path.parent.mkdir(parents=True, exist_ok=True)
return path
def output_directory(value: str) -> Path:
path = Path(value).expanduser().resolve()
if path.exists() and not path.is_dir():
raise ValueError(f"输出路径不是目录:{path}")
path.mkdir(parents=True, exist_ok=True)
return path
def publish_file(source: Path, destination: Path, *, overwrite: bool) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if overwrite:
os.replace(source, destination)
return
try:
os.link(source, destination)
except FileExistsError as exc:
raise FileExistsError(f"目标文件已存在:{destination}") from exc
except OSError:
if destination.exists():
raise FileExistsError(f"目标文件已存在:{destination}")
shutil.copy2(source, destination)
finally:
if source.exists():
source.unlink()
def load_json_argument(
inline_value: Optional[str],
file_value: Optional[str],
*,
label: str,
max_bytes: int = 2 * 1024 * 1024,
) -> dict[str, Any]:
if bool(inline_value) == bool(file_value):
raise ValueError(f"{label} 必须且只能通过内联 JSON 或 JSON 文件提供一次")
if file_value:
source = input_file(file_value, {".json"})
if source.stat().st_size > max_bytes:
raise ValueError(f"{label} JSON 文件超过 {max_bytes} 字节限制")
raw = source.read_text(encoding="utf-8")
else:
raw = inline_value or ""
if len(raw.encode("utf-8")) > max_bytes:
raise ValueError(f"{label} 内联 JSON 超过 {max_bytes} 字节限制")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(
f"{label} JSON 无效:第 {exc.lineno} 行第 {exc.colno} 列,{exc.msg}"
) from exc
if not isinstance(parsed, dict):
raise ValueError(f"{label} JSON 顶层必须是对象")
return parsed
def validate_cell_reference(value: str, *, label: str = "单元格") -> str:
normalized = value.strip().upper()
if not CELL_REFERENCE_RE.fullmatch(normalized):
raise ValueError(f"{label}引用无效:{value}")
return normalized
def validate_cell_range(value: str, *, label: str = "区域") -> str:
normalized = value.replace("$", "").strip().upper()
if CELL_REFERENCE_RE.fullmatch(normalized):
return normalized
if not CELL_RANGE_RE.fullmatch(normalized):
raise ValueError(f"{label}引用无效:{value}")
return normalized
def find_program(*names: str) -> str:
for name in names:
resolved = shutil.which(name)
if resolved:
return resolved
raise FileNotFoundError(f"运行环境缺少命令:{' / '.join(names)}")
def office_profile_uri(profile: Path) -> str:
return "file://" + quote(str(profile.resolve()), safe="/:")
def run_program(
args: list[str],
*,
timeout: int,
cwd: Optional[Path] = None,
env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess[str]:
if timeout < 1 or timeout > 900:
raise ValueError("timeout 必须在 1 到 900 秒之间")
completed = subprocess.run(
args,
cwd=str(cwd) if cwd else None,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
)
if completed.returncode != 0:
raise subprocess.CalledProcessError(
completed.returncode,
args,
output=completed.stdout,
stderr=completed.stderr,
)
return completed
def run_soffice_convert(
source: Path,
*,
target_format: str,
output_dir: Path,
timeout: int,
filter_name: Optional[str] = None,
) -> tuple[Path, dict[str, str]]:
soffice = find_program("soffice", "libreoffice")
output_dir.mkdir(parents=True, exist_ok=True)
profile = Path(tempfile.mkdtemp(prefix="xlsx-soffice-profile-"))
try:
cache_dir = profile / "cache"
cache_dir.mkdir()
process_env = os.environ.copy()
process_env["XDG_CACHE_HOME"] = str(cache_dir)
convert_arg = target_format
if filter_name:
convert_arg = f"{target_format}:{filter_name}"
completed = run_program(
[
soffice,
f"-env:UserInstallation={office_profile_uri(profile)}",
"--headless",
"--nologo",
"--nodefault",
"--nolockcheck",
"--nofirststartwizard",
"--convert-to",
convert_arg,
"--outdir",
str(output_dir),
str(source),
],
timeout=timeout,
env=process_env,
)
expected = output_dir / f"{source.stem}.{target_format}"
if not expected.exists():
candidates = sorted(output_dir.glob(f"{source.stem}.*"))
if len(candidates) == 1:
expected = candidates[0]
else:
raise RuntimeError(
"LibreOffice 未生成预期文件;"
f"stdout={completed.stdout[-1000:]!r} "
f"stderr={completed.stderr[-1000:]!r}"
)
return expected, {
"stdout": completed.stdout[-2000:],
"stderr": completed.stderr[-2000:],
}
finally:
shutil.rmtree(profile, ignore_errors=True)
def workbook_has_external_links(path: Path) -> bool:
if path.suffix.lower() not in EXCEL_INPUT_SUFFIXES:
return False
import zipfile
try:
with zipfile.ZipFile(path) as archive:
return any(
name.startswith("xl/externalLinks/") and name.endswith(".xml")
for name in archive.namelist()
)
except zipfile.BadZipFile as exc:
raise ValueError(f"Excel 文件不是有效的 OOXML 压缩包:{path}") from exc
def openpyxl_load_options(path: Path) -> dict[str, Any]:
keep_vba = path.suffix.lower() in {".xlsm", ".xltm"}
return {
"read_only": False,
"keep_vba": keep_vba,
"keep_links": True,
}
def normalize_formula_error(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip().upper()
if normalized in FORMULA_ERROR_VALUES:
return normalized
return None

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,298 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import os
import tempfile
from datetime import date, datetime
from pathlib import Path
from typing import Any, Iterable, Optional
from _xlsx_common import (
TABULAR_INPUT_SUFFIXES,
SkillArgumentParser,
input_file,
openpyxl_load_options,
output_file,
publish_file,
run_cli,
run_soffice_convert,
)
OUTPUT_SUFFIXES = {".xlsx", ".csv", ".tsv", ".pdf"}
ENCODINGS = {"utf-8", "utf-8-sig", "gb18030", "gbk"}
def _coerce_value(value: str) -> Any:
stripped = value.strip()
if not stripped:
return ""
lowered = stripped.lower()
if lowered in {"true", "false"}:
return lowered == "true"
try:
if not (
(stripped.startswith("0") and len(stripped) > 1 and "." not in stripped)
or (stripped.startswith("-0") and len(stripped) > 2 and "." not in stripped)
):
return int(stripped)
except ValueError:
pass
try:
return float(stripped)
except ValueError:
return value
def _read_delimited(
source: Path,
*,
encoding: str,
infer_types: bool,
) -> list[list[Any]]:
delimiter = "\t" if source.suffix.lower() == ".tsv" else ","
rows: list[list[Any]] = []
with source.open("r", encoding=encoding, newline="") as handle:
for row in csv.reader(handle, delimiter=delimiter):
rows.append(
[_coerce_value(value) for value in row] if infer_types else row
)
return rows
def _delimited_to_xlsx(
source: Path,
destination: Path,
*,
sheet_name: str,
encoding: str,
infer_types: bool,
) -> int:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
rows = _read_delimited(
source,
encoding=encoding,
infer_types=infer_types,
)
workbook = Workbook()
worksheet = workbook.active
worksheet.title = sheet_name[:31] or "Sheet1"
for row in rows:
worksheet.append(row)
if rows:
header_fill = PatternFill(fill_type="solid", fgColor="1F4E78")
for cell in worksheet[1]:
cell.font = Font(name="Arial", bold=True, color="FFFFFF")
cell.fill = header_fill
cell.alignment = Alignment(vertical="center", wrap_text=True)
worksheet.freeze_panes = "A2"
worksheet.auto_filter.ref = worksheet.dimensions
for row in worksheet.iter_rows():
for cell in row:
if cell.row != 1:
cell.font = Font(name="Arial", size=10)
if isinstance(cell.value, (datetime, date)):
cell.number_format = "yyyy-mm-dd"
workbook.calculation.calcMode = "auto"
workbook.calculation.fullCalcOnLoad = True
workbook.save(destination)
return len(rows)
def _xlsx_to_delimited(
source: Path,
destination: Path,
*,
sheet_name: Optional[str],
formulas: bool,
encoding: str,
delimiter: str,
) -> tuple[str, int]:
from openpyxl import load_workbook
workbook = load_workbook(
source,
data_only=not formulas,
read_only=True,
**{
key: value
for key, value in openpyxl_load_options(source).items()
if key != "read_only"
},
)
if sheet_name:
if sheet_name not in workbook.sheetnames:
raise ValueError(
f"工作表不存在:{sheet_name};可选:{''.join(workbook.sheetnames)}"
)
worksheet = workbook[sheet_name]
else:
worksheet = workbook.active
row_count = 0
with destination.open("w", encoding=encoding, newline="") as handle:
writer = csv.writer(handle, delimiter=delimiter)
for row in worksheet.iter_rows(values_only=True):
writer.writerow(["" if value is None else value for value in row])
row_count += 1
workbook.close()
return worksheet.title, row_count
def _convert_legacy_to_xlsx(
source: Path,
directory: Path,
*,
timeout: int,
) -> Path:
converted, _ = run_soffice_convert(
source,
target_format="xlsx",
output_dir=directory,
timeout=timeout,
filter_name="Calc MS Excel 2007 XML",
)
return converted
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="在 Excel、CSV、TSV 和 PDF 之间执行受控转换。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--sheet", help="导出 CSV/TSV 时选择工作表")
parser.add_argument(
"--sheet-name",
default="Sheet1",
help="CSV/TSV 转 Excel 时使用的工作表名称",
)
parser.add_argument("--encoding", default="utf-8-sig")
parser.add_argument("--infer-types", action="store_true")
parser.add_argument(
"--formulas",
action="store_true",
help="导出 CSV/TSV 时输出公式字符串而不是缓存结果",
)
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
if args.encoding.lower() not in ENCODINGS:
raise ValueError(f"encoding 仅支持:{''.join(sorted(ENCODINGS))}")
encoding = args.encoding.lower()
source = input_file(args.input, TABULAR_INPUT_SUFFIXES)
destination = output_file(
args.output,
OUTPUT_SUFFIXES,
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("输出路径不能与输入文件相同")
source_suffix = source.suffix.lower()
destination_suffix = destination.suffix.lower()
row_count: Optional[int] = None
selected_sheet: Optional[str] = None
with tempfile.TemporaryDirectory(prefix="xlsx-convert-") as temp_name:
temp_dir = Path(temp_name)
working_source = source
if source_suffix == ".xls":
working_source = _convert_legacy_to_xlsx(
source,
temp_dir / "legacy",
timeout=args.timeout,
)
source_suffix = ".xlsx"
descriptor, temp_output_name = tempfile.mkstemp(
prefix="converted-",
suffix=destination_suffix,
dir=str(destination.parent),
)
os.close(descriptor)
temp_output = Path(temp_output_name)
temp_output.unlink()
try:
if destination_suffix == ".xlsx":
if source_suffix in {".csv", ".tsv"}:
row_count = _delimited_to_xlsx(
working_source,
temp_output,
sheet_name=args.sheet_name,
encoding=encoding,
infer_types=args.infer_types,
)
selected_sheet = args.sheet_name[:31] or "Sheet1"
elif source_suffix in {".xltx", ".xltm", ".xlsm"}:
converted, _ = run_soffice_convert(
working_source,
target_format="xlsx",
output_dir=temp_dir / "xlsx",
timeout=args.timeout,
filter_name="Calc MS Excel 2007 XML",
)
converted.replace(temp_output)
elif source_suffix == ".xlsx":
raise ValueError("输入和输出都是 .xlsx无需转换")
else:
raise ValueError("该输入格式不能转换为 .xlsx")
elif destination_suffix in {".csv", ".tsv"}:
if source_suffix in {".csv", ".tsv"}:
raise ValueError(
"CSV 与 TSV 互转不属于 Excel 工作簿转换;请先转为 .xlsx"
)
selected_sheet, row_count = _xlsx_to_delimited(
working_source,
temp_output,
sheet_name=args.sheet,
formulas=args.formulas,
encoding=encoding,
delimiter="\t" if destination_suffix == ".tsv" else ",",
)
elif destination_suffix == ".pdf":
if source_suffix in {".csv", ".tsv"}:
intermediate = temp_dir / "source.xlsx"
_delimited_to_xlsx(
working_source,
intermediate,
sheet_name=args.sheet_name,
encoding=encoding,
infer_types=args.infer_types,
)
working_source = intermediate
converted, _ = run_soffice_convert(
working_source,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
converted.replace(temp_output)
else:
raise ValueError("不支持的目标格式")
publish_file(temp_output, destination, overwrite=args.overwrite)
finally:
if temp_output.exists():
temp_output.unlink()
return {
"path": str(destination),
"source": str(source),
"source_format": source.suffix.lower(),
"output_format": destination_suffix,
"sheet": selected_sheet,
"row_count": row_count,
"formulas_exported": bool(args.formulas),
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,424 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import socket
import stat
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any, NoReturn, Optional
from _xlsx_common import (
TABULAR_INPUT_SUFFIXES,
emit,
failure_message,
output_file,
publish_file,
)
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_BYTES = 100 * 1024 * 1024
MAX_ALLOWED_BYTES = 512 * 1024 * 1024
CHUNK_SIZE = 1024 * 1024
MAX_ARCHIVE_MEMBERS = 20_000
MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024
MAX_ARCHIVE_MEMBER_BYTES = 128 * 1024 * 1024
USER_AGENT = "wechat-robot-xlsx-skill/1.0"
OLE_COMPOUND_MAGIC = bytes.fromhex("D0CF11E0A1B11AE1")
CONTENT_TYPES_NS = (
"http://schemas.openxmlformats.org/package/2006/content-types"
)
WORKBOOK_CONTENT_TYPES = {
"application/vnd.openxmlformats-officedocument."
"spreadsheetml.sheet.main+xml": ".xlsx",
"application/vnd.ms-excel.sheet.macroEnabled.main+xml": ".xlsm",
"application/vnd.openxmlformats-officedocument."
"spreadsheetml.template.main+xml": ".xltx",
"application/vnd.ms-excel.template.macroEnabled.main+xml": ".xltm",
}
class SkillArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
raise ValueError(f"参数错误:{message}")
def _validate_https_url(value: str) -> str:
url = value.strip()
if not url:
raise ValueError("Excel URL 不能为空")
if any(character.isspace() or ord(character) < 32 for character in url):
raise ValueError("Excel URL 不能包含空白字符或控制字符")
parsed = urllib.parse.urlsplit(url)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise ValueError("Excel URL 必须是有效的 HTTPS 地址")
if parsed.username is not None or parsed.password is not None:
raise ValueError("Excel URL 不允许包含用户名或密码")
try:
parsed.port
except ValueError as exc:
raise ValueError("Excel URL 端口格式不正确") from exc
return url
class HTTPSOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
safe_url = _validate_https_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, safe_url)
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = SkillArgumentParser(description="下载并校验远程 HTTPS Excel 文件")
parser.add_argument(
"--url",
"--excel-url",
"--excel_url",
dest="url",
required=True,
help="远程 HTTPS Excel、CSV 或 TSV 地址",
)
parser.add_argument(
"--output",
required=True,
help=(
"本地输出路径;扩展名必须是 "
".xlsx、.xlsm、.xltx、.xltm、.xls、.csv 或 .tsv"
),
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT_SECONDS,
help=f"连接和读取超时秒数,默认 {DEFAULT_TIMEOUT_SECONDS}",
)
parser.add_argument(
"--max-bytes",
"--max_bytes",
dest="max_bytes",
type=int,
default=DEFAULT_MAX_BYTES,
help=f"最大下载字节数,默认 {DEFAULT_MAX_BYTES}",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="允许覆盖本次任务已存在的缓存文件",
)
args = parser.parse_args(argv)
args.url = _validate_https_url(args.url)
if args.timeout < 1 or args.timeout > 600:
raise ValueError("timeout 必须在 1 到 600 秒之间")
if args.max_bytes < 1 or args.max_bytes > MAX_ALLOWED_BYTES:
raise ValueError(
f"max-bytes 必须在 1 到 {MAX_ALLOWED_BYTES} 之间"
)
args.output = output_file(
args.output,
TABULAR_INPUT_SUFFIXES,
overwrite=args.overwrite,
)
return args
def _safe_archive_name(name: str) -> None:
candidate = PurePosixPath(name)
if (
candidate.is_absolute()
or not candidate.parts
or ".." in candidate.parts
or candidate.parts[0].endswith(":")
):
raise ValueError(f"Excel 压缩包含不安全路径:{name}")
def _zip_member_is_symlink(info: zipfile.ZipInfo) -> bool:
mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_ISLNK(mode)
def _parse_content_types(payload: bytes) -> Any:
try:
from defusedxml import ElementTree as ET
from defusedxml.common import DefusedXmlException
try:
return ET.fromstring(payload)
except (ET.ParseError, DefusedXmlException, ValueError) as exc:
raise ValueError(f"Excel 内容类型 XML 解析失败:{exc}") from exc
except ModuleNotFoundError:
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
recover=False,
huge_tree=False,
)
try:
return etree.fromstring(payload, parser=parser)
except (etree.XMLSyntaxError, ValueError) as exc:
raise ValueError(f"Excel 内容类型 XML 解析失败:{exc}") from exc
def _validate_ooxml_workbook(
path: Path,
expected_suffix: str,
) -> dict[str, Any]:
try:
with zipfile.ZipFile(path) as archive:
infos = archive.infolist()
if len(infos) > MAX_ARCHIVE_MEMBERS:
raise ValueError("Excel 压缩包成员数量超过安全限制")
names: set[str] = set()
total_size = 0
for info in infos:
_safe_archive_name(info.filename)
if _zip_member_is_symlink(info):
raise ValueError(
f"Excel 压缩包含符号链接:{info.filename}"
)
if info.file_size > MAX_ARCHIVE_MEMBER_BYTES:
raise ValueError(
f"Excel 压缩包成员过大:{info.filename}"
)
total_size += info.file_size
if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
raise ValueError("Excel 解压后总大小超过安全限制")
if info.filename in names:
raise ValueError(
f"Excel 压缩包含重复成员:{info.filename}"
)
names.add(info.filename)
required = {"[Content_Types].xml", "xl/workbook.xml"}
missing = sorted(required - names)
if missing:
raise ValueError(
"下载内容不是有效的 Excel OOXML 文件;缺少:"
+ "".join(missing)
)
content_types = archive.read("[Content_Types].xml")
except zipfile.BadZipFile as exc:
raise ValueError("下载内容不是有效的 Excel OOXML 压缩包") from exc
root = _parse_content_types(content_types)
detected_suffix: Optional[str] = None
for element in root.iter(f"{{{CONTENT_TYPES_NS}}}Override"):
if element.attrib.get("PartName") == "/xl/workbook.xml":
detected_suffix = WORKBOOK_CONTENT_TYPES.get(
element.attrib.get("ContentType", "")
)
break
if detected_suffix is None:
raise ValueError("下载内容不是受支持的 Excel OOXML 工作簿")
if detected_suffix != expected_suffix:
raise ValueError(
"下载内容的实际格式为 "
f"{detected_suffix},但 output 使用了 {expected_suffix}"
)
try:
from openpyxl import load_workbook
except ImportError as exc:
raise RuntimeError("当前 Python 未加载环境预置的 openpyxl 模块") from exc
workbook = None
try:
workbook = load_workbook(
path,
read_only=True,
data_only=False,
keep_vba=detected_suffix in {".xlsm", ".xltm"},
keep_links=False,
)
sheet_count = len(workbook.sheetnames)
if sheet_count < 1:
raise ValueError("Excel 工作簿不包含工作表")
except ValueError:
raise
except Exception as exc:
raise ValueError("下载内容不是可解析的 Excel 工作簿") from exc
finally:
if workbook is not None:
workbook.close()
return {
"format": detected_suffix.lstrip("."),
"sheet_count": sheet_count,
"archive_member_count": len(infos),
"archive_uncompressed_bytes": total_size,
"validation": "ooxml-and-openpyxl",
}
def _validate_legacy_xls(path: Path) -> dict[str, Any]:
with path.open("rb") as stream:
magic = stream.read(len(OLE_COMPOUND_MAGIC))
if magic != OLE_COMPOUND_MAGIC:
raise ValueError("下载内容不是有效的旧版 Excel 复合文件")
return {
"format": "xls",
"validation": "ole-compound-signature",
}
def _validate_delimited_text(path: Path, suffix: str) -> dict[str, Any]:
with path.open("rb") as stream:
prefix = stream.read(1024 * 1024)
if not prefix:
raise ValueError("下载内容为空")
if prefix.startswith((b"\xff\xfe", b"\xfe\xff")):
encoding = "utf-16"
try:
text = prefix.decode(encoding)
except UnicodeDecodeError as exc:
raise ValueError("下载内容不是可解析的 UTF-16 表格文本") from exc
else:
if b"\x00" in prefix:
raise ValueError("下载内容包含二进制空字节,不是文本表格")
for encoding in ("utf-8-sig", "gb18030"):
try:
text = prefix.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError("下载内容不是 UTF-8 或 GB18030 表格文本")
normalized = text.lstrip("\ufeff\r\n\t ").lower()
if not normalized:
raise ValueError("下载的表格文本不包含有效内容")
if normalized.startswith(("<!doctype html", "<html", "<?xml")):
raise ValueError("远程服务器返回了网页或 XML而不是表格文件")
return {
"format": suffix.lstrip("."),
"encoding": encoding,
"validation": "delimited-text",
}
def _validate_workbook(path: Path, suffix: str) -> dict[str, Any]:
if suffix in {".xlsx", ".xlsm", ".xltx", ".xltm"}:
return _validate_ooxml_workbook(path, suffix)
if suffix == ".xls":
return _validate_legacy_xls(path)
return _validate_delimited_text(path, suffix)
def _download(args: argparse.Namespace) -> dict[str, Any]:
output: Path = args.output
request = urllib.request.Request(
args.url,
headers={
"Accept": (
"application/vnd.openxmlformats-officedocument."
"spreadsheetml.sheet,"
"application/vnd.ms-excel,text/csv,text/tab-separated-values,"
"application/octet-stream;q=0.9,*/*;q=0.1"
),
"Accept-Encoding": "identity",
"User-Agent": USER_AGENT,
},
method="GET",
)
opener = urllib.request.build_opener(HTTPSOnlyRedirectHandler())
temp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{output.stem}.",
suffix=f".part{output.suffix}",
dir=str(output.parent),
delete=False,
) as temp_file:
temp_path = Path(temp_file.name)
with opener.open(request, timeout=args.timeout) as response:
_validate_https_url(response.geturl())
content_length = response.headers.get("Content-Length")
if content_length:
try:
expected_bytes = int(content_length)
except ValueError:
expected_bytes = 0
if expected_bytes > args.max_bytes:
raise ValueError(
"远程文件超过大小限制:"
f"最多允许 {args.max_bytes} 字节"
)
downloaded_bytes = 0
while True:
chunk = response.read(CHUNK_SIZE)
if not chunk:
break
downloaded_bytes += len(chunk)
if downloaded_bytes > args.max_bytes:
raise ValueError(
"远程文件超过大小限制:"
f"最多允许 {args.max_bytes} 字节"
)
temp_file.write(chunk)
temp_file.flush()
os.fsync(temp_file.fileno())
if downloaded_bytes == 0:
raise ValueError("远程服务器返回了空文件")
details = _validate_workbook(temp_path, output.suffix.lower())
publish_file(temp_path, output, overwrite=args.overwrite)
temp_path = None
return {
"path": str(output),
"size_bytes": downloaded_bytes,
**details,
}
finally:
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
def _failure_message(exc: Exception) -> str:
if isinstance(exc, urllib.error.HTTPError):
return f"下载失败:远程服务器返回 HTTP {exc.code}"
if isinstance(exc, (TimeoutError, socket.timeout)):
return "下载失败:连接或读取超时"
if isinstance(exc, urllib.error.URLError):
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
return "下载失败:连接或读取超时"
return "下载失败:无法访问远程服务器"
return failure_message(exc)
def main(argv: Optional[list[str]] = None) -> int:
try:
args = _parse_args(sys.argv[1:] if argv is None else argv)
result = _download(args)
except Exception as exc:
emit({"ok": False, "error": _failure_message(exc)})
return 1
emit({"ok": True, **result})
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,325 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from typing import Any, Optional
from _xlsx_common import (
EXCEL_INPUT_SUFFIXES,
TABULAR_INPUT_SUFFIXES,
SkillArgumentParser,
input_file,
normalize_formula_error,
openpyxl_load_options,
run_cli,
workbook_has_external_links,
)
def _color_value(color: Any) -> Optional[str]:
if color is None:
return None
color_type = getattr(color, "type", None)
if color_type == "rgb":
return getattr(color, "rgb", None)
if color_type == "theme":
theme = getattr(color, "theme", None)
tint = getattr(color, "tint", 0)
return f"theme:{theme}:tint:{tint}"
if color_type == "indexed":
return f"indexed:{getattr(color, 'indexed', None)}"
return None
def _cell_payload(formula_cell: Any, cached_cell: Any) -> Optional[dict[str, Any]]:
formula_value = formula_cell.value
cached_value = cached_cell.value
if formula_value is None and cached_value is None and not formula_cell.has_style:
return None
is_formula = formula_cell.data_type == "f" or (
isinstance(formula_value, str) and formula_value.startswith("=")
)
value = cached_value if is_formula else formula_value
error = normalize_formula_error(value)
payload: dict[str, Any] = {
"cell": formula_cell.coordinate,
"value": value,
"type": formula_cell.data_type,
}
if is_formula:
payload["formula"] = formula_value
payload["cached_value"] = cached_value
if error:
payload["error"] = error
if formula_cell.has_style:
payload["style"] = {
"style_id": formula_cell.style_id,
"number_format": formula_cell.number_format,
"font": formula_cell.font.name,
"font_size": formula_cell.font.sz,
"bold": bool(formula_cell.font.bold),
"italic": bool(formula_cell.font.italic),
"font_color": _color_value(formula_cell.font.color),
"fill_color": _color_value(formula_cell.fill.fgColor),
"horizontal": formula_cell.alignment.horizontal,
"vertical": formula_cell.alignment.vertical,
"wrap_text": bool(formula_cell.alignment.wrap_text),
"locked": bool(formula_cell.protection.locked),
}
if formula_cell.comment:
payload["comment"] = {
"author": formula_cell.comment.author,
"text": formula_cell.comment.text,
}
if formula_cell.hyperlink:
payload["hyperlink"] = formula_cell.hyperlink.target
return payload
def _defined_names(workbook: Any) -> list[dict[str, Any]]:
names: list[dict[str, Any]] = []
try:
values = workbook.defined_names.values()
except AttributeError:
values = workbook.defined_names.definedName
for item in values:
names.append(
{
"name": getattr(item, "name", None),
"value": getattr(item, "attr_text", None),
"local_sheet_id": getattr(item, "localSheetId", None),
"hidden": bool(getattr(item, "hidden", False)),
}
)
return names
def inspect_excel(
source: Path,
*,
sheet_name: Optional[str],
start_row: int,
start_column: int,
max_rows: int,
max_columns: int,
) -> dict[str, Any]:
from openpyxl import load_workbook
options = openpyxl_load_options(source)
formulas = load_workbook(source, data_only=False, **options)
cached = load_workbook(source, data_only=True, **options)
summaries: list[dict[str, Any]] = []
total_formulas = 0
total_errors = 0
for worksheet in formulas.worksheets:
formula_count = 0
error_count = 0
for cell in worksheet._cells.values():
if cell.data_type == "f" or (
isinstance(cell.value, str) and cell.value.startswith("=")
):
formula_count += 1
if normalize_formula_error(cell.value):
error_count += 1
total_formulas += formula_count
total_errors += error_count
summaries.append(
{
"name": worksheet.title,
"state": worksheet.sheet_state,
"max_row": worksheet.max_row,
"max_column": worksheet.max_column,
"freeze_panes": (
str(worksheet.freeze_panes) if worksheet.freeze_panes else None
),
"auto_filter": worksheet.auto_filter.ref,
"merged_ranges": [str(item) for item in worksheet.merged_cells.ranges],
"tables": list(worksheet.tables.keys()),
"chart_count": len(worksheet._charts),
"image_count": len(worksheet._images),
"formula_count": formula_count,
"literal_error_count": error_count,
"print_area": str(worksheet.print_area) if worksheet.print_area else None,
}
)
if sheet_name:
if sheet_name not in formulas.sheetnames:
raise ValueError(
f"工作表不存在:{sheet_name};可选:{''.join(formulas.sheetnames)}"
)
selected_name = sheet_name
else:
selected_name = formulas.active.title
formula_sheet = formulas[selected_name]
cached_sheet = cached[selected_name]
end_row = min(formula_sheet.max_row, start_row + max_rows - 1)
end_column = min(
formula_sheet.max_column,
start_column + max_columns - 1,
)
cells: list[dict[str, Any]] = []
for row in formula_sheet.iter_rows(
min_row=start_row,
max_row=end_row,
min_col=start_column,
max_col=end_column,
):
for formula_cell in row:
cached_cell = cached_sheet[formula_cell.coordinate]
payload = _cell_payload(formula_cell, cached_cell)
if payload:
cells.append(payload)
next_row = end_row + 1 if end_row < formula_sheet.max_row else None
next_column = (
end_column + 1 if end_column < formula_sheet.max_column else None
)
properties = formulas.properties
calculation = getattr(formulas, "calculation", None)
return {
"path": str(source),
"format": source.suffix.lower(),
"macro_enabled": source.suffix.lower() in {".xlsm", ".xltm"},
"has_external_links": workbook_has_external_links(source),
"active_sheet": formulas.active.title,
"sheet_names": formulas.sheetnames,
"sheets": summaries,
"defined_names": _defined_names(formulas),
"properties": {
"title": properties.title,
"subject": properties.subject,
"creator": properties.creator,
"last_modified_by": properties.lastModifiedBy,
"created": properties.created,
"modified": properties.modified,
"category": properties.category,
"keywords": properties.keywords,
"description": properties.description,
},
"calculation": {
"mode": getattr(calculation, "calcMode", None),
"full_calc_on_load": getattr(calculation, "fullCalcOnLoad", None),
"force_full_calc": getattr(calculation, "forceFullCalc", None),
},
"formula_count": total_formulas,
"literal_error_count": total_errors,
"selection": {
"sheet": selected_name,
"start_row": start_row,
"end_row": end_row,
"start_column": start_column,
"end_column": end_column,
"cells": cells,
"has_more": next_row is not None or next_column is not None,
"next_row": next_row,
"next_column": next_column,
},
}
def inspect_delimited(
source: Path,
*,
start_row: int,
start_column: int,
max_rows: int,
max_columns: int,
) -> dict[str, Any]:
delimiter = "\t" if source.suffix.lower() == ".tsv" else ","
rows: list[list[str]] = []
total_rows = 0
max_seen_columns = 0
with source.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle, delimiter=delimiter)
for index, row in enumerate(reader, start=1):
total_rows = index
max_seen_columns = max(max_seen_columns, len(row))
if index < start_row or len(rows) >= max_rows:
continue
rows.append(row[start_column - 1 : start_column - 1 + max_columns])
return {
"path": str(source),
"format": source.suffix.lower(),
"delimiter": delimiter,
"row_count": total_rows,
"max_column_count": max_seen_columns,
"selection": {
"start_row": start_row,
"end_row": min(total_rows, start_row + len(rows) - 1),
"start_column": start_column,
"end_column": min(
max_seen_columns, start_column + max_columns - 1
),
"rows": rows,
"has_more": (
start_row + len(rows) - 1 < total_rows
or start_column + max_columns - 1 < max_seen_columns
),
"next_row": (
start_row + len(rows)
if start_row + len(rows) - 1 < total_rows
else None
),
},
}
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="读取 Excel、CSV 或 TSV 的结构、公式、缓存值和局部单元格。"
)
parser.add_argument("--input", required=True, help="输入文件路径")
parser.add_argument("--sheet", help="要读取的工作表;默认活动工作表")
parser.add_argument("--start-row", type=int, default=1)
parser.add_argument("--start-column", type=int, default=1)
parser.add_argument("--max-rows", type=int, default=40)
parser.add_argument("--max-columns", type=int, default=20)
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
if args.start_row < 1 or args.start_row > 1_048_576:
raise ValueError("start-row 必须在 1 到 1048576 之间")
if args.start_column < 1 or args.start_column > 16_384:
raise ValueError("start-column 必须在 1 到 16384 之间")
if args.max_rows < 1 or args.max_rows > 200:
raise ValueError("max-rows 必须在 1 到 200 之间")
if args.max_columns < 1 or args.max_columns > 100:
raise ValueError("max-columns 必须在 1 到 100 之间")
source = input_file(args.input, TABULAR_INPUT_SUFFIXES)
if source.suffix.lower() == ".xls":
raise ValueError(
"旧版 .xls 请先使用 convert_workbook.py 转为 .xlsx 后再检查"
)
if source.suffix.lower() in EXCEL_INPUT_SUFFIXES:
return inspect_excel(
source,
sheet_name=args.sheet,
start_row=args.start_row,
start_column=args.start_column,
max_rows=args.max_rows,
max_columns=args.max_columns,
)
if args.sheet:
raise ValueError("CSV/TSV 没有工作表,不能传 --sheet")
return inspect_delimited(
source,
start_row=args.start_row,
start_column=args.start_column,
max_rows=args.max_rows,
max_columns=args.max_columns,
)
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,164 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import tempfile
from collections import defaultdict
from pathlib import Path
from typing import Any
from _xlsx_common import (
EXCEL_OUTPUT_SUFFIXES,
SkillArgumentParser,
input_file,
normalize_formula_error,
openpyxl_load_options,
output_file,
publish_file,
run_cli,
run_soffice_convert,
workbook_has_external_links,
)
def _scan_formula_results(path: Path) -> dict[str, Any]:
from openpyxl import load_workbook
options = openpyxl_load_options(path)
formulas = load_workbook(path, data_only=False, **options)
cached = load_workbook(path, data_only=True, **options)
error_locations: dict[str, list[dict[str, str]]] = defaultdict(list)
total_errors_by_type: dict[str, int] = defaultdict(int)
total_formulas = 0
missing_cached = 0
for formula_sheet in formulas.worksheets:
cached_sheet = cached[formula_sheet.title]
for cell in formula_sheet._cells.values():
is_formula = cell.data_type == "f" or (
isinstance(cell.value, str) and cell.value.startswith("=")
)
if not is_formula:
continue
total_formulas += 1
cached_value = cached_sheet[cell.coordinate].value
error = normalize_formula_error(cached_value)
if error:
total_errors_by_type[error] += 1
if len(error_locations[error]) < 100:
error_locations[error].append(
{
"sheet": formula_sheet.title,
"cell": cell.coordinate,
"formula": str(cell.value),
}
)
elif cached_value is None:
missing_cached += 1
summaries = []
for error_type in sorted(total_errors_by_type):
total = total_errors_by_type[error_type]
locations = error_locations[error_type]
summaries.append(
{
"error": error_type,
"count": total,
"locations": locations,
"locations_truncated": max(0, total - len(locations)),
}
)
formulas.close()
cached.close()
return {
"total_formulas": total_formulas,
"total_errors": sum(total_errors_by_type.values()),
"error_summary": summaries,
"missing_cached_value_count": missing_cached,
}
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="用 LibreOffice 重算工作簿,并检查公式缓存错误。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--overwrite", action="store_true")
parser.add_argument(
"--allow-external-links",
action="store_true",
help="明确接受外部链接可能失效或缓存变化的风险",
)
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
source = input_file(args.input, EXCEL_OUTPUT_SUFFIXES)
destination = output_file(
args.output,
EXCEL_OUTPUT_SUFFIXES,
overwrite=args.overwrite,
)
if source == destination:
raise ValueError("重算结果必须写到新文件,不能覆盖输入源文件")
if source.suffix.lower() != destination.suffix.lower():
raise ValueError("重算必须保持原格式(.xlsx 或 .xlsm不能同时转换格式")
has_external_links = workbook_has_external_links(source)
if has_external_links and not args.allow_external_links:
raise ValueError(
"工作簿包含外部链接,重算可能把缓存值改为 #NAME? 并删除链接;"
"请先固化外部数据,或明确传 --allow-external-links"
)
with tempfile.TemporaryDirectory(prefix="xlsx-recalculate-") as temp_name:
temp_dir = Path(temp_name)
input_dir = temp_dir / "input"
output_dir = temp_dir / "output"
input_dir.mkdir()
output_dir.mkdir()
staged_source = input_dir / f"source{source.suffix.lower()}"
shutil.copy2(source, staged_source)
target_format = source.suffix.lower().lstrip(".")
filter_name = (
"Calc MS Excel 2007 VBA XML"
if target_format == "xlsm"
else "Calc MS Excel 2007 XML"
)
converted, office_output = run_soffice_convert(
staged_source,
target_format=target_format,
output_dir=output_dir,
timeout=args.timeout,
filter_name=filter_name,
)
scan = _scan_formula_results(converted)
staged_result = temp_dir / f"result{destination.suffix.lower()}"
shutil.copy2(converted, staged_result)
publish_file(staged_result, destination, overwrite=args.overwrite)
warnings: list[str] = []
if has_external_links:
warnings.append("工作簿含外部链接,已按明确授权执行重算")
if scan["missing_cached_value_count"]:
warnings.append(
"部分公式缓存值为空;公式结果可能确实为空字符串,也可能需要人工核验"
)
return {
"path": str(destination),
"source": str(source),
"status": "success" if scan["total_errors"] == 0 else "errors_found",
**scan,
"has_external_links": has_external_links,
"warnings": warnings,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))

View File

@ -0,0 +1,163 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import tempfile
from pathlib import Path
from typing import Any, Optional
from _xlsx_common import (
TABULAR_INPUT_SUFFIXES,
SkillArgumentParser,
find_program,
input_file,
output_directory,
publish_file,
run_cli,
run_program,
run_soffice_convert,
)
def _pdf_page_count(path: Path) -> int:
from pypdf import PdfReader
return len(PdfReader(str(path)).pages)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="通过 LibreOffice 和 Poppler 把工作簿渲染为逐页 PNG。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument("--end-page", type=int)
parser.add_argument("--max-pages", type=int, default=20)
parser.add_argument("--dpi", type=int, default=150)
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--include-pdf", action="store_true")
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> dict[str, Any]:
args = build_parser().parse_args()
if args.start_page < 1:
raise ValueError("start-page 必须大于 0")
if args.end_page is not None and args.end_page < args.start_page:
raise ValueError("end-page 不能小于 start-page")
if args.max_pages < 1 or args.max_pages > 100:
raise ValueError("max-pages 必须在 1 到 100 之间")
if args.dpi < 72 or args.dpi > 300:
raise ValueError("dpi 必须在 72 到 300 之间")
source = input_file(args.input, TABULAR_INPUT_SUFFIXES)
if source.suffix.lower() in {".csv", ".tsv"}:
raise ValueError("CSV/TSV 请先用 convert_workbook.py 转为 .xlsx 后再渲染")
destination_dir = output_directory(args.output_dir)
with tempfile.TemporaryDirectory(prefix="xlsx-render-") as temp_name:
temp_dir = Path(temp_name)
staged_input = temp_dir / f"workbook{source.suffix.lower()}"
shutil.copy2(source, staged_input)
pdf_path, office_output = run_soffice_convert(
staged_input,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
page_count = _pdf_page_count(pdf_path)
if page_count < 1:
raise ValueError("LibreOffice 生成的 PDF 没有页面")
if args.start_page > page_count:
raise ValueError(f"start-page 超出页面总数 {page_count}")
requested_end = page_count if args.end_page is None else args.end_page
if requested_end > page_count:
raise ValueError(f"end-page 超出页面总数 {page_count}")
actual_end = min(
requested_end,
args.start_page + args.max_pages - 1,
)
pdftoppm = find_program("pdftoppm")
raw_prefix = temp_dir / "raw-page"
run_program(
[
pdftoppm,
"-png",
"-r",
str(args.dpi),
"-f",
str(args.start_page),
"-l",
str(actual_end),
str(pdf_path),
str(raw_prefix),
],
timeout=args.timeout,
)
raw_pages = sorted(
temp_dir.glob("raw-page-*.png"),
key=lambda path: int(path.stem.rsplit("-", 1)[1]),
)
expected_count = actual_end - args.start_page + 1
if len(raw_pages) != expected_count:
raise RuntimeError(
f"Poppler 应生成 {expected_count} 页,实际生成 {len(raw_pages)}"
)
destinations = [
destination_dir / f"page-{page_number:04d}.png"
for page_number in range(args.start_page, actual_end + 1)
]
if args.include_pdf:
destinations.append(destination_dir / "workbook.pdf")
if not args.overwrite:
existing = [str(path) for path in destinations if path.exists()]
if existing:
raise FileExistsError(
"以下渲染目标已存在:" + "".join(existing)
)
output_paths: list[str] = []
for page_number, raw_page in zip(
range(args.start_page, actual_end + 1),
raw_pages,
):
destination = destination_dir / f"page-{page_number:04d}.png"
publish_file(raw_page, destination, overwrite=args.overwrite)
output_paths.append(str(destination))
pdf_output: Optional[str] = None
if args.include_pdf:
destination_pdf = destination_dir / "workbook.pdf"
staged_pdf = temp_dir / "publish.pdf"
shutil.copy2(pdf_path, staged_pdf)
publish_file(
staged_pdf,
destination_pdf,
overwrite=args.overwrite,
)
pdf_output = str(destination_pdf)
next_page = actual_end + 1 if actual_end < requested_end else None
return {
"source": str(source),
"page_count": page_count,
"start_page": args.start_page,
"end_page": actual_end,
"rendered_pages": output_paths,
"pdf": pdf_output,
"has_more": next_page is not None,
"next_page": next_page,
"dpi": args.dpi,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))