feat: excel word skill
This commit is contained in:
parent
b3207088b7
commit
b81403f199
332
skills/docx/SKILL.md
Normal file
332
skills/docx/SKILL.md
Normal 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`(1–9)、`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>`:小字、复杂表格或页眉脚注可提高到 180–220。
|
||||
- `--include-pdf`:同时保留 `document.pdf`。
|
||||
- `--overwrite`:只覆盖本次任务旧渲染。
|
||||
|
||||
若 `has_more: true`,用 `next_page` 继续。通过可用的图片查看工具逐页检查。
|
||||
|
||||
## 质量要求
|
||||
|
||||
- 默认采用 A4、合理页边距、清晰标题层级;现有文档的规范优先。
|
||||
- 中文使用 `Noto Sans CJK SC` 或原文档字体,拉丁文字使用 Arial;不要依赖运行环境中不存在的专有字体。
|
||||
- 标题必须使用内置 `Heading 1`–`Heading 9` 或具有大纲级别的样式,目录才能收录。
|
||||
- 表格明确设置列宽、重复表头并禁止跨页拆分关键行;不要使用百分比列宽假设不同客户端一致。
|
||||
- 表格底色使用明确填充色;不要用表格模拟水平线。
|
||||
- 页码、目录和交叉引用使用字段,不手写空格或点号对齐。
|
||||
- 图片保持纵横比,注明来源或说明文字,确认没有超出版心。
|
||||
- 不用 `\n` 代替独立段落,不用空格填充对齐,不把分页符直接放在正文字符串中。
|
||||
- 检查孤行孤字、标题落在页尾、表格断裂、图片拉伸、文字裁切、异常空白页、乱码、重叠和页码连续性。
|
||||
- 最终文档必须内容准确、结构有效、可由 LibreOffice 打开,并通过全部页面视觉检查。
|
||||
4
skills/docx/agents/openai.yaml
Normal file
4
skills/docx/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Word 文档"
|
||||
short_description: "安全下载、创建、编辑、校验并渲染专业 Word 文档"
|
||||
default_prompt: "使用 $docx 创建或处理本地文件或 HTTPS 链接中的 Word 文档,并完成内容与版式校验。"
|
||||
767
skills/docx/scripts/_document_builder.py
Normal file
767
skills/docx/scripts/_document_builder.py
Normal 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")
|
||||
438
skills/docx/scripts/_docx_common.py
Normal file
438
skills/docx/scripts/_docx_common.py
Normal 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
|
||||
371
skills/docx/scripts/accept_changes.py
Normal file
371
skills/docx/scripts/accept_changes.py
Normal 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))
|
||||
221
skills/docx/scripts/add_comment.py
Normal file
221
skills/docx/scripts/add_comment.py
Normal 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))
|
||||
199
skills/docx/scripts/convert_document.py
Normal file
199
skills/docx/scripts/convert_document.py
Normal 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))
|
||||
121
skills/docx/scripts/create_document.py
Normal file
121
skills/docx/scripts/create_document.py
Normal 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))
|
||||
311
skills/docx/scripts/download_document.py
Normal file
311
skills/docx/scripts/download_document.py
Normal 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())
|
||||
459
skills/docx/scripts/edit_document.py
Normal file
459
skills/docx/scripts/edit_document.py
Normal 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))
|
||||
351
skills/docx/scripts/inspect_document.py
Normal file
351
skills/docx/scripts/inspect_document.py
Normal 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))
|
||||
72
skills/docx/scripts/pack_document.py
Normal file
72
skills/docx/scripts/pack_document.py
Normal 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))
|
||||
154
skills/docx/scripts/render_document.py
Normal file
154
skills/docx/scripts/render_document.py
Normal 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))
|
||||
48
skills/docx/scripts/unpack_document.py
Normal file
48
skills/docx/scripts/unpack_document.py
Normal 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))
|
||||
386
skills/docx/scripts/validate_document.py
Normal file
386
skills/docx/scripts/validate_document.py
Normal 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
222
skills/xlsx/SKILL.md
Normal 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. 先抽查 2–3 个关键公式的引用和计算逻辑,再调用 `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>`:小字或复杂图表可提高到 180–220。
|
||||
- `--include-pdf`:同时保留 `workbook.pdf`。
|
||||
- `--overwrite`:只覆盖本次任务旧渲染。
|
||||
|
||||
若 `has_more: true`,用 `next_page` 继续。通过可用的图片查看工具逐页检查返回的 PNG。
|
||||
|
||||
## 质量要求
|
||||
|
||||
- 默认使用专业字体:中文使用 `Noto Sans CJK SC` 或与原文件一致的字体,拉丁文字使用 Arial;编辑现有文件时原有规范优先。
|
||||
- 表头、单位、日期、货币、百分比和负数格式必须明确;百分比按小数存储,例如 `0.15` 显示为 `15.0%`。
|
||||
- 可计算结果使用公式,不把当前结果硬编码进单元格;假设值单独放在有标签的输入单元格中。
|
||||
- 每个外部数据、假设和硬编码数字都用批注或邻近单元格说明来源。
|
||||
- 新建供他人填写的模板要包含填写说明和一行格式示例;编辑现有文件时不要擅自插入示例行。
|
||||
- 精确遵循用户指定的工作表名、表头、公式和输出格式,不擅自重构业务逻辑。
|
||||
- 合并单元格只写左上角锚点;编辑 `.xlsm` 时保留宏;不要用 `data_only=True` 读取后再保存。
|
||||
- 公式重算、关键值抽查和全部页面视觉检查全部通过后再交付。
|
||||
4
skills/xlsx/agents/openai.yaml
Normal file
4
skills/xlsx/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Excel 工作簿"
|
||||
short_description: "安全下载、创建、编辑、重算、校验并渲染 Excel 工作簿"
|
||||
default_prompt: "使用 $xlsx 创建或处理本地文件或 HTTPS 链接中的 Excel 工作簿,并完成公式与版式校验。"
|
||||
321
skills/xlsx/scripts/_xlsx_common.py
Normal file
321
skills/xlsx/scripts/_xlsx_common.py
Normal 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
|
||||
1019
skills/xlsx/scripts/apply_workbook.py
Normal file
1019
skills/xlsx/scripts/apply_workbook.py
Normal file
File diff suppressed because it is too large
Load Diff
298
skills/xlsx/scripts/convert_workbook.py
Normal file
298
skills/xlsx/scripts/convert_workbook.py
Normal 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))
|
||||
424
skills/xlsx/scripts/download_workbook.py
Normal file
424
skills/xlsx/scripts/download_workbook.py
Normal 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())
|
||||
325
skills/xlsx/scripts/inspect_workbook.py
Normal file
325
skills/xlsx/scripts/inspect_workbook.py
Normal 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))
|
||||
164
skills/xlsx/scripts/recalculate_workbook.py
Normal file
164
skills/xlsx/scripts/recalculate_workbook.py
Normal 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))
|
||||
163
skills/xlsx/scripts/render_workbook.py
Normal file
163
skills/xlsx/scripts/render_workbook.py
Normal 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))
|
||||
Loading…
Reference in New Issue
Block a user