From b81403f199a71eebaa25d20b789f43b3d4e4e839 Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Sun, 26 Jul 2026 02:45:00 +0800 Subject: [PATCH] feat: excel word skill --- skills/docx/SKILL.md | 332 ++++++ skills/docx/agents/openai.yaml | 4 + skills/docx/scripts/_document_builder.py | 767 ++++++++++++++ skills/docx/scripts/_docx_common.py | 438 ++++++++ skills/docx/scripts/accept_changes.py | 371 +++++++ skills/docx/scripts/add_comment.py | 221 ++++ skills/docx/scripts/convert_document.py | 199 ++++ skills/docx/scripts/create_document.py | 121 +++ skills/docx/scripts/download_document.py | 311 ++++++ skills/docx/scripts/edit_document.py | 459 +++++++++ skills/docx/scripts/inspect_document.py | 351 +++++++ skills/docx/scripts/pack_document.py | 72 ++ skills/docx/scripts/render_document.py | 154 +++ skills/docx/scripts/unpack_document.py | 48 + skills/docx/scripts/validate_document.py | 386 +++++++ skills/xlsx/SKILL.md | 222 ++++ skills/xlsx/agents/openai.yaml | 4 + skills/xlsx/scripts/_xlsx_common.py | 321 ++++++ skills/xlsx/scripts/apply_workbook.py | 1019 +++++++++++++++++++ skills/xlsx/scripts/convert_workbook.py | 298 ++++++ skills/xlsx/scripts/download_workbook.py | 424 ++++++++ skills/xlsx/scripts/inspect_workbook.py | 325 ++++++ skills/xlsx/scripts/recalculate_workbook.py | 164 +++ skills/xlsx/scripts/render_workbook.py | 163 +++ 24 files changed, 7174 insertions(+) create mode 100644 skills/docx/SKILL.md create mode 100644 skills/docx/agents/openai.yaml create mode 100644 skills/docx/scripts/_document_builder.py create mode 100644 skills/docx/scripts/_docx_common.py create mode 100644 skills/docx/scripts/accept_changes.py create mode 100644 skills/docx/scripts/add_comment.py create mode 100644 skills/docx/scripts/convert_document.py create mode 100644 skills/docx/scripts/create_document.py create mode 100644 skills/docx/scripts/download_document.py create mode 100644 skills/docx/scripts/edit_document.py create mode 100644 skills/docx/scripts/inspect_document.py create mode 100644 skills/docx/scripts/pack_document.py create mode 100644 skills/docx/scripts/render_document.py create mode 100644 skills/docx/scripts/unpack_document.py create mode 100644 skills/docx/scripts/validate_document.py create mode 100644 skills/xlsx/SKILL.md create mode 100644 skills/xlsx/agents/openai.yaml create mode 100644 skills/xlsx/scripts/_xlsx_common.py create mode 100644 skills/xlsx/scripts/apply_workbook.py create mode 100644 skills/xlsx/scripts/convert_workbook.py create mode 100644 skills/xlsx/scripts/download_workbook.py create mode 100644 skills/xlsx/scripts/inspect_workbook.py create mode 100644 skills/xlsx/scripts/recalculate_workbook.py create mode 100644 skills/xlsx/scripts/render_workbook.py diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md new file mode 100644 index 0000000..a63b976 --- /dev/null +++ b/skills/docx/SKILL.md @@ -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 写到任务临时目录,再传 `--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 顶层只有 `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 把可见短语拆成多个 `` 的情况,并尽量保留首个匹配 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 `。 +- `--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 --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 打开,并通过全部页面视觉检查。 diff --git a/skills/docx/agents/openai.yaml b/skills/docx/agents/openai.yaml new file mode 100644 index 0000000..eb4b46b --- /dev/null +++ b/skills/docx/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Word 文档" + short_description: "安全下载、创建、编辑、校验并渲染专业 Word 文档" + default_prompt: "使用 $docx 创建或处理本地文件或 HTTPS 链接中的 Word 文档,并完成内容与版式校验。" diff --git a/skills/docx/scripts/_document_builder.py b/skills/docx/scripts/_document_builder.py new file mode 100644 index 0000000..1788ff5 --- /dev/null +++ b/skills/docx/scripts/_document_builder.py @@ -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") diff --git a/skills/docx/scripts/_docx_common.py b/skills/docx/scripts/_docx_common.py new file mode 100644 index 0000000..f1982df --- /dev/null +++ b/skills/docx/scripts/_docx_common.py @@ -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 diff --git a/skills/docx/scripts/accept_changes.py b/skills/docx/scripts/accept_changes.py new file mode 100644 index 0000000..9975e4d --- /dev/null +++ b/skills/docx/scripts/accept_changes.py @@ -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 = """ + + +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_XLB = """ + + + + +""" + +SCRIPT_XLC = """ + + + + +""" + + +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)) diff --git a/skills/docx/scripts/add_comment.py b/skills/docx/scripts/add_comment.py new file mode 100644 index 0000000..c15eeda --- /dev/null +++ b/skills/docx/scripts/add_comment.py @@ -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)) diff --git a/skills/docx/scripts/convert_document.py b/skills/docx/scripts/convert_document.py new file mode 100644 index 0000000..5ca615f --- /dev/null +++ b/skills/docx/scripts/convert_document.py @@ -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)) diff --git a/skills/docx/scripts/create_document.py b/skills/docx/scripts/create_document.py new file mode 100644 index 0000000..d5c4db3 --- /dev/null +++ b/skills/docx/scripts/create_document.py @@ -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)) diff --git a/skills/docx/scripts/download_document.py b/skills/docx/scripts/download_document.py new file mode 100644 index 0000000..d709b53 --- /dev/null +++ b/skills/docx/scripts/download_document.py @@ -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()) diff --git a/skills/docx/scripts/edit_document.py b/skills/docx/scripts/edit_document.py new file mode 100644 index 0000000..2814d36 --- /dev/null +++ b/skills/docx/scripts/edit_document.py @@ -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"(? 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)) diff --git a/skills/docx/scripts/inspect_document.py b/skills/docx/scripts/inspect_document.py new file mode 100644 index 0000000..f439a6c --- /dev/null +++ b/skills/docx/scripts/inspect_document.py @@ -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)) diff --git a/skills/docx/scripts/pack_document.py b/skills/docx/scripts/pack_document.py new file mode 100644 index 0000000..b956b3e --- /dev/null +++ b/skills/docx/scripts/pack_document.py @@ -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)) diff --git a/skills/docx/scripts/render_document.py b/skills/docx/scripts/render_document.py new file mode 100644 index 0000000..a29100a --- /dev/null +++ b/skills/docx/scripts/render_document.py @@ -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)) diff --git a/skills/docx/scripts/unpack_document.py b/skills/docx/scripts/unpack_document.py new file mode 100644 index 0000000..90e810c --- /dev/null +++ b/skills/docx/scripts/unpack_document.py @@ -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)) diff --git a/skills/docx/scripts/validate_document.py b/skills/docx/scripts/validate_document.py new file mode 100644 index 0000000..74d4c75 --- /dev/null +++ b/skills/docx/scripts/validate_document.py @@ -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)) diff --git a/skills/xlsx/SKILL.md b/skills/xlsx/SKILL.md new file mode 100644 index 0000000..8009a41 --- /dev/null +++ b/skills/xlsx/SKILL.md @@ -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 '' +``` + +或: + +```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` 读取后再保存。 +- 公式重算、关键值抽查和全部页面视觉检查全部通过后再交付。 diff --git a/skills/xlsx/agents/openai.yaml b/skills/xlsx/agents/openai.yaml new file mode 100644 index 0000000..1e0d62f --- /dev/null +++ b/skills/xlsx/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Excel 工作簿" + short_description: "安全下载、创建、编辑、重算、校验并渲染 Excel 工作簿" + default_prompt: "使用 $xlsx 创建或处理本地文件或 HTTPS 链接中的 Excel 工作簿,并完成公式与版式校验。" diff --git a/skills/xlsx/scripts/_xlsx_common.py b/skills/xlsx/scripts/_xlsx_common.py new file mode 100644 index 0000000..6f56dc5 --- /dev/null +++ b/skills/xlsx/scripts/_xlsx_common.py @@ -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[A-Z]{1,3}[1-9][0-9]{0,6}):" + r"(?P[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 diff --git a/skills/xlsx/scripts/apply_workbook.py b/skills/xlsx/scripts/apply_workbook.py new file mode 100644 index 0000000..cc07aef --- /dev/null +++ b/skills/xlsx/scripts/apply_workbook.py @@ -0,0 +1,1019 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +import re +import tempfile +from copy import copy +from pathlib import Path +from typing import Any, Iterable, Optional + +from _xlsx_common import ( + EXCEL_INPUT_SUFFIXES, + EXCEL_OUTPUT_SUFFIXES, + SkillArgumentParser, + input_file, + load_json_argument, + normalize_formula_error, + openpyxl_load_options, + output_file, + publish_file, + run_cli, + validate_cell_range, + validate_cell_reference, + workbook_has_external_links, +) + + +MAX_OPERATIONS = 500 +MAX_WRITTEN_CELLS = 100_000 +HEX_COLOR_RE = re.compile(r"^(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") +TABLE_NAME_RE = re.compile(r"^[A-Za-z_\\][A-Za-z0-9_.\\]*$") +RISKY_FORMULA_RE = re.compile( + r"\b(?:XLOOKUP|XMATCH|SORT|FILTER|UNIQUE|SEQUENCE)\s*\(", + re.IGNORECASE, +) + + +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: + if not isinstance(value, str) or not HEX_COLOR_RE.fullmatch(value): + raise ValueError(f"{label} 必须是 6 位或 8 位十六进制颜色") + return value.upper() + + +def _sheet(workbook: Any, name: Any) -> Any: + if not isinstance(name, str) or not name: + raise ValueError("操作必须提供非空 sheet") + if name not in workbook.sheetnames: + raise ValueError(f"工作表不存在:{name}") + return workbook[name] + + +def _iter_range_cells(worksheet: Any, reference: str) -> Iterable[Any]: + normalized = validate_cell_range(reference) + if ":" not in normalized: + yield worksheet[normalized] + return + for row in worksheet[normalized]: + for cell in row: + yield cell + + +def _apply_font(cell: Any, spec: dict[str, Any]) -> None: + font = copy(cell.font) + allowed = { + "name", + "size", + "bold", + "italic", + "underline", + "strike", + "color", + "vert_align", + } + unknown = set(spec) - allowed + if unknown: + raise ValueError(f"font 包含未知字段:{sorted(unknown)}") + if "name" in spec: + font.name = str(spec["name"]) + if "size" in spec: + size = float(spec["size"]) + if size < 1 or size > 200: + raise ValueError("字体大小必须在 1 到 200 之间") + font.sz = size + for source, target in ( + ("bold", "b"), + ("italic", "i"), + ("strike", "strike"), + ): + if source in spec: + setattr(font, target, bool(spec[source])) + if "underline" in spec: + underline = spec["underline"] + if underline is True: + underline = "single" + elif underline is False: + underline = None + if underline not in {None, "single", "double", "singleAccounting", "doubleAccounting"}: + raise ValueError("underline 值无效") + font.u = underline + if "color" in spec: + font.color = _color(spec["color"], "font.color") + if "vert_align" in spec: + value = spec["vert_align"] + if value not in {None, "baseline", "superscript", "subscript"}: + raise ValueError("font.vert_align 值无效") + font.vertAlign = value + cell.font = font + + +def _apply_alignment(cell: Any, spec: dict[str, Any]) -> None: + alignment = copy(cell.alignment) + mapping = { + "horizontal": "horizontal", + "vertical": "vertical", + "wrap_text": "wrap_text", + "shrink_to_fit": "shrink_to_fit", + "text_rotation": "text_rotation", + "indent": "indent", + } + unknown = set(spec) - set(mapping) + if unknown: + raise ValueError(f"alignment 包含未知字段:{sorted(unknown)}") + for source, target in mapping.items(): + if source in spec: + setattr(alignment, target, spec[source]) + cell.alignment = alignment + + +def _apply_border(cell: Any, spec: dict[str, Any]) -> None: + from openpyxl.styles import Border, Side + + allowed_sides = {"left", "right", "top", "bottom", "diagonal"} + unknown = set(spec) - allowed_sides + if unknown: + raise ValueError(f"border 包含未知字段:{sorted(unknown)}") + current = cell.border + sides: dict[str, Any] = { + name: copy(getattr(current, name)) for name in allowed_sides + } + for name, side_spec_raw in spec.items(): + side_spec = _expect_object(side_spec_raw, f"border.{name}") + unknown_side = set(side_spec) - {"style", "color"} + if unknown_side: + raise ValueError( + f"border.{name} 包含未知字段:{sorted(unknown_side)}" + ) + sides[name] = Side( + style=side_spec.get("style"), + color=( + _color(side_spec["color"], f"border.{name}.color") + if side_spec.get("color") + else None + ), + ) + cell.border = Border( + left=sides["left"], + right=sides["right"], + top=sides["top"], + bottom=sides["bottom"], + diagonal=sides["diagonal"], + diagonalUp=current.diagonalUp, + diagonalDown=current.diagonalDown, + outline=current.outline, + vertical=current.vertical, + horizontal=current.horizontal, + ) + + +def _apply_style(cell: Any, raw_spec: Any) -> None: + from openpyxl.styles import PatternFill, Protection + + spec = _expect_object(raw_spec, "style") + allowed = { + "named_style", + "font", + "fill", + "alignment", + "border", + "number_format", + "protection", + } + unknown = set(spec) - allowed + if unknown: + raise ValueError(f"style 包含未知字段:{sorted(unknown)}") + if "named_style" in spec: + cell.style = str(spec["named_style"]) + if "font" in spec: + _apply_font(cell, _expect_object(spec["font"], "font")) + if "fill" in spec: + fill_spec = _expect_object(spec["fill"], "fill") + unknown_fill = set(fill_spec) - {"color", "pattern"} + if unknown_fill: + raise ValueError(f"fill 包含未知字段:{sorted(unknown_fill)}") + pattern = fill_spec.get("pattern", "solid") + color = _color(fill_spec.get("color"), "fill.color") + cell.fill = PatternFill(fill_type=pattern, fgColor=color) + if "alignment" in spec: + _apply_alignment( + cell, + _expect_object(spec["alignment"], "alignment"), + ) + if "border" in spec: + _apply_border(cell, _expect_object(spec["border"], "border")) + if "number_format" in spec: + cell.number_format = str(spec["number_format"]) + if "protection" in spec: + protection = _expect_object(spec["protection"], "protection") + unknown_protection = set(protection) - {"locked", "hidden"} + if unknown_protection: + raise ValueError( + f"protection 包含未知字段:{sorted(unknown_protection)}" + ) + cell.protection = Protection( + locked=bool(protection.get("locked", True)), + hidden=bool(protection.get("hidden", False)), + ) + + +def _set_cell(cell: Any, item: dict[str, Any]) -> None: + from openpyxl.comments import Comment + + if "formula" in item and "value" in item: + raise ValueError(f"{cell.coordinate} 不能同时设置 formula 和 value") + if "formula" in item: + formula = item["formula"] + if not isinstance(formula, str) or not formula.startswith("="): + raise ValueError(f"{cell.coordinate} 的 formula 必须以 = 开头") + cell.value = formula + elif "value" in item: + cell.value = item["value"] + if "style" in item: + _apply_style(cell, item["style"]) + if "comment" in item: + comment = item["comment"] + if comment is None: + cell.comment = None + elif isinstance(comment, str): + cell.comment = Comment(comment, "AI") + else: + comment_spec = _expect_object(comment, "comment") + text = str(comment_spec.get("text", "")) + author = str(comment_spec.get("author", "AI")) + if not text: + raise ValueError("comment.text 不能为空") + cell.comment = Comment(text, author) + if "hyperlink" in item: + cell.hyperlink = item["hyperlink"] + + +def _apply_properties(workbook: Any, raw: Any) -> None: + spec = _expect_object(raw, "properties") + allowed = { + "title", + "subject", + "creator", + "last_modified_by", + "category", + "keywords", + "description", + "language", + } + unknown = set(spec) - allowed + if unknown: + raise ValueError(f"properties 包含未知字段:{sorted(unknown)}") + mapping = { + "last_modified_by": "lastModifiedBy", + } + for key, value in spec.items(): + setattr(workbook.properties, mapping.get(key, key), value) + + +def _op_add_sheet(workbook: Any, op: dict[str, Any]) -> int: + name = str(op.get("name", "")).strip() + if not name: + raise ValueError("add_sheet.name 不能为空") + if name in workbook.sheetnames: + raise ValueError(f"工作表已存在:{name}") + index = op.get("index") + if index is not None: + index = int(index) + if index < 0 or index > len(workbook.worksheets): + raise ValueError("add_sheet.index 超出范围") + workbook.create_sheet(title=name, index=index) + return 0 + + +def _op_remove_sheet(workbook: Any, op: dict[str, Any]) -> int: + if len(workbook.worksheets) <= 1: + raise ValueError("工作簿必须至少保留一个工作表") + worksheet = _sheet(workbook, op.get("sheet")) + workbook.remove(worksheet) + return 0 + + +def _op_rename_sheet(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + new_name = str(op.get("name", "")).strip() + if not new_name: + raise ValueError("rename_sheet.name 不能为空") + if new_name in workbook.sheetnames and new_name != worksheet.title: + raise ValueError(f"工作表已存在:{new_name}") + worksheet.title = new_name + return 0 + + +def _op_set_cells(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + items = _expect_list(op.get("cells"), "set_cells.cells") + if len(items) > MAX_WRITTEN_CELLS: + raise ValueError("单次 set_cells 超过单元格数量限制") + for raw_item in items: + item = _expect_object(raw_item, "set_cells.cells[]") + unknown = set(item) - { + "cell", + "value", + "formula", + "style", + "comment", + "hyperlink", + } + if unknown: + raise ValueError(f"set_cells 单元格包含未知字段:{sorted(unknown)}") + reference = validate_cell_reference(str(item.get("cell", ""))) + _set_cell(worksheet[reference], item) + return len(items) + + +def _op_write_rows(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.utils.cell import coordinate_to_tuple + + worksheet = _sheet(workbook, op.get("sheet")) + start_cell = validate_cell_reference(str(op.get("start_cell", "A1"))) + start_row, start_column = coordinate_to_tuple(start_cell) + rows = _expect_list(op.get("rows"), "write_rows.rows") + cell_count = sum(len(row) if isinstance(row, list) else 0 for row in rows) + if cell_count > MAX_WRITTEN_CELLS: + raise ValueError("单次 write_rows 超过单元格数量限制") + for row_offset, raw_row in enumerate(rows): + row = _expect_list(raw_row, f"write_rows.rows[{row_offset}]") + for column_offset, raw_value in enumerate(row): + cell = worksheet.cell( + row=start_row + row_offset, + column=start_column + column_offset, + ) + if isinstance(raw_value, dict) and any( + key in raw_value + for key in ("value", "formula", "style", "comment", "hyperlink") + ): + _set_cell(cell, raw_value) + else: + cell.value = raw_value + if "style" in op: + style = op["style"] + for row_offset, raw_row in enumerate(rows): + for column_offset in range(len(raw_row)): + _apply_style( + worksheet.cell( + row=start_row + row_offset, + column=start_column + column_offset, + ), + style, + ) + return cell_count + + +def _op_append_rows(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + rows = _expect_list(op.get("rows"), "append_rows.rows") + cell_count = sum(len(row) if isinstance(row, list) else 0 for row in rows) + if cell_count > MAX_WRITTEN_CELLS: + raise ValueError("单次 append_rows 超过单元格数量限制") + for index, raw_row in enumerate(rows): + row = _expect_list(raw_row, f"append_rows.rows[{index}]") + worksheet.append(row) + return cell_count + + +def _op_style_range(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + cells = list(_iter_range_cells(worksheet, reference)) + if len(cells) > MAX_WRITTEN_CELLS: + raise ValueError("style_range 超过单元格数量限制") + for cell in cells: + _apply_style(cell, op.get("style")) + return len(cells) + + +def _op_clear_range(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + cells = list(_iter_range_cells(worksheet, reference)) + if len(cells) > MAX_WRITTEN_CELLS: + raise ValueError("clear_range 超过单元格数量限制") + clear_values = bool(op.get("values", True)) + clear_styles = bool(op.get("styles", False)) + clear_comments = bool(op.get("comments", False)) + clear_hyperlinks = bool(op.get("hyperlinks", False)) + for cell in cells: + if clear_values: + cell.value = None + if clear_styles: + cell._style = copy(worksheet.parent._named_styles["Normal"]._style) + if clear_comments: + cell.comment = None + if clear_hyperlinks: + cell.hyperlink = None + return len(cells) + + +def _op_rows_or_columns(workbook: Any, op: dict[str, Any], kind: str) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + index = int(op.get("index", 0)) + amount = int(op.get("amount", 1)) + if index < 1: + raise ValueError(f"{kind}.index 必须大于 0") + if amount < 1 or amount > 10_000: + raise ValueError(f"{kind}.amount 必须在 1 到 10000 之间") + if kind == "insert_rows": + worksheet.insert_rows(index, amount) + elif kind == "delete_rows": + worksheet.delete_rows(index, amount) + elif kind == "insert_columns": + worksheet.insert_cols(index, amount) + elif kind == "delete_columns": + worksheet.delete_cols(index, amount) + return 0 + + +def _op_merge(workbook: Any, op: dict[str, Any], merge: bool) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + if ":" not in reference: + raise ValueError("合并区域必须包含起止单元格") + if merge: + worksheet.merge_cells(reference) + else: + worksheet.unmerge_cells(reference) + return 0 + + +def _op_set_column_widths(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.utils import get_column_letter + from openpyxl.utils.cell import column_index_from_string + + worksheet = _sheet(workbook, op.get("sheet")) + widths = _expect_object(op.get("widths"), "set_column_widths.widths") + changed = 0 + for raw_range, raw_width in widths.items(): + width = float(raw_width) + if width < 0 or width > 255: + raise ValueError("列宽必须在 0 到 255 之间") + pieces = str(raw_range).upper().split(":", 1) + start = column_index_from_string(pieces[0]) + end = column_index_from_string(pieces[-1]) + if start > end: + raise ValueError(f"列范围无效:{raw_range}") + for column in range(start, end + 1): + worksheet.column_dimensions[get_column_letter(column)].width = width + changed += 1 + return changed + + +def _op_set_row_heights(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + heights = _expect_object(op.get("heights"), "set_row_heights.heights") + changed = 0 + for raw_range, raw_height in heights.items(): + height = float(raw_height) + if height < 0 or height > 409: + raise ValueError("行高必须在 0 到 409 之间") + pieces = str(raw_range).split(":", 1) + start = int(pieces[0]) + end = int(pieces[-1]) + if start < 1 or start > end or end > 1_048_576: + raise ValueError(f"行范围无效:{raw_range}") + for row in range(start, end + 1): + worksheet.row_dimensions[row].height = height + changed += 1 + return changed + + +def _op_add_table(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.worksheet.table import Table, TableStyleInfo + + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + if ":" not in reference: + raise ValueError("表格区域必须包含多于一个单元格") + name = str(op.get("name", "")).strip() + if not name or not TABLE_NAME_RE.fullmatch(name): + raise ValueError("add_table.name 必须是合法的 Excel 表格名称") + if any(name in sheet.tables for sheet in workbook.worksheets): + raise ValueError(f"表格名称已存在:{name}") + table = Table(displayName=name, ref=reference) + table.tableStyleInfo = TableStyleInfo( + name=str(op.get("style", "TableStyleMedium2")), + showFirstColumn=bool(op.get("show_first_column", False)), + showLastColumn=bool(op.get("show_last_column", False)), + showRowStripes=bool(op.get("show_row_stripes", True)), + showColumnStripes=bool(op.get("show_column_stripes", False)), + ) + worksheet.add_table(table) + return 0 + + +def _op_add_chart(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.chart import AreaChart, BarChart, LineChart, PieChart, Reference + from openpyxl.utils.cell import range_boundaries + + worksheet = _sheet(workbook, op.get("sheet")) + chart_type = str(op.get("chart_type", "bar")).lower() + chart_classes = { + "area": AreaChart, + "bar": BarChart, + "column": BarChart, + "line": LineChart, + "pie": PieChart, + } + if chart_type not in chart_classes: + raise ValueError("chart_type 仅支持 area、bar、column、line、pie") + data_range = validate_cell_range(str(op.get("data_range", ""))) + min_col, min_row, max_col, max_row = range_boundaries(data_range) + chart = chart_classes[chart_type]() + if isinstance(chart, BarChart): + chart.type = "bar" if chart_type == "bar" else "col" + data = Reference( + worksheet, + min_col=min_col, + min_row=min_row, + max_col=max_col, + max_row=max_row, + ) + chart.add_data( + data, + titles_from_data=bool(op.get("titles_from_data", True)), + from_rows=bool(op.get("from_rows", False)), + ) + if op.get("categories_range"): + category_range = validate_cell_range(str(op["categories_range"])) + c_min_col, c_min_row, c_max_col, c_max_row = range_boundaries( + category_range + ) + categories = Reference( + worksheet, + min_col=c_min_col, + min_row=c_min_row, + max_col=c_max_col, + max_row=c_max_row, + ) + chart.set_categories(categories) + if "title" in op: + chart.title = str(op["title"]) + if "x_axis_title" in op and hasattr(chart, "x_axis"): + chart.x_axis.title = str(op["x_axis_title"]) + if "y_axis_title" in op and hasattr(chart, "y_axis"): + chart.y_axis.title = str(op["y_axis_title"]) + if "style" in op: + chart.style = int(op["style"]) + if "height" in op: + chart.height = float(op["height"]) + if "width" in op: + chart.width = float(op["width"]) + if "legend_position" in op and chart.legend: + chart.legend.position = str(op["legend_position"]) + anchor = validate_cell_reference(str(op.get("anchor", "E2"))) + worksheet.add_chart(chart, anchor) + return 0 + + +def _op_add_image(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.drawing.image import Image + + worksheet = _sheet(workbook, op.get("sheet")) + image_path = input_file( + str(op.get("path", "")), + {".png", ".jpg", ".jpeg", ".gif", ".bmp"}, + ) + image = Image(str(image_path)) + if "width" in op: + image.width = float(op["width"]) + if "height" in op: + image.height = float(op["height"]) + anchor = validate_cell_reference(str(op.get("anchor", "A1"))) + worksheet.add_image(image, anchor) + return 0 + + +def _op_add_data_validation(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.worksheet.datavalidation import DataValidation + + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + validation_type = str(op.get("validation_type", "list")) + allowed = { + "list", + "whole", + "decimal", + "date", + "time", + "textLength", + "custom", + } + if validation_type not in allowed: + raise ValueError(f"validation_type 不支持:{validation_type}") + validation = DataValidation( + type=validation_type, + formula1=op.get("formula1"), + formula2=op.get("formula2"), + operator=op.get("operator"), + allow_blank=bool(op.get("allow_blank", True)), + showErrorMessage=bool(op.get("show_error_message", True)), + errorTitle=op.get("error_title"), + error=op.get("error_message"), + promptTitle=op.get("prompt_title"), + prompt=op.get("prompt_message"), + showInputMessage=bool(op.get("show_input_message", True)), + ) + worksheet.add_data_validation(validation) + validation.add(reference) + return 0 + + +def _op_add_conditional_format(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.formatting.rule import CellIsRule, ColorScaleRule, FormulaRule + from openpyxl.styles import Font, PatternFill + + worksheet = _sheet(workbook, op.get("sheet")) + reference = validate_cell_range(str(op.get("range", ""))) + rule_type = str(op.get("rule_type", "cell")) + if rule_type == "color_scale": + colors = op.get("colors", ["F8696B", "FFEB84", "63BE7B"]) + if not isinstance(colors, list) or len(colors) not in {2, 3}: + raise ValueError("color_scale.colors 必须包含 2 或 3 个颜色") + colors = [_color(item, "colors[]") for item in colors] + if len(colors) == 2: + rule = ColorScaleRule( + start_type="min", + start_color=colors[0], + end_type="max", + end_color=colors[1], + ) + else: + rule = ColorScaleRule( + start_type="min", + start_color=colors[0], + mid_type="percentile", + mid_value=50, + mid_color=colors[1], + end_type="max", + end_color=colors[2], + ) + else: + formulas = op.get("formula") + if isinstance(formulas, str): + formulas = [formulas] + formulas = _expect_list(formulas, "conditional_format.formula") + fill = ( + PatternFill( + fill_type="solid", + fgColor=_color(op["fill_color"], "fill_color"), + ) + if op.get("fill_color") + else None + ) + font = ( + Font(color=_color(op["font_color"], "font_color")) + if op.get("font_color") + else None + ) + if rule_type == "cell": + rule = CellIsRule( + operator=str(op.get("operator", "equal")), + formula=formulas, + fill=fill, + font=font, + stopIfTrue=bool(op.get("stop_if_true", False)), + ) + elif rule_type == "formula": + rule = FormulaRule( + formula=formulas, + fill=fill, + font=font, + stopIfTrue=bool(op.get("stop_if_true", False)), + ) + else: + raise ValueError("rule_type 仅支持 cell、formula、color_scale") + worksheet.conditional_formatting.add(reference, rule) + return 0 + + +def _op_set_print(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + if "print_area" in op: + worksheet.print_area = validate_cell_range(str(op["print_area"])) + if "orientation" in op: + orientation = str(op["orientation"]).lower() + if orientation not in {"portrait", "landscape"}: + raise ValueError("orientation 只能是 portrait 或 landscape") + worksheet.page_setup.orientation = orientation + if "paper_size" in op: + paper = str(op["paper_size"]).upper() + paper_map = { + "A4": worksheet.PAPERSIZE_A4, + "LETTER": worksheet.PAPERSIZE_LETTER, + "LEGAL": worksheet.PAPERSIZE_LEGAL, + } + if paper not in paper_map: + raise ValueError("paper_size 仅支持 A4、LETTER、LEGAL") + worksheet.page_setup.paperSize = paper_map[paper] + if "fit_to_width" in op: + worksheet.page_setup.fitToWidth = int(op["fit_to_width"]) + worksheet.sheet_properties.pageSetUpPr.fitToPage = True + if "fit_to_height" in op: + worksheet.page_setup.fitToHeight = int(op["fit_to_height"]) + worksheet.sheet_properties.pageSetUpPr.fitToPage = True + if "repeat_rows" in op: + worksheet.print_title_rows = str(op["repeat_rows"]) + if "repeat_columns" in op: + worksheet.print_title_cols = str(op["repeat_columns"]) + margins = op.get("margins") + if margins is not None: + margins_spec = _expect_object(margins, "set_print.margins") + for key in ("left", "right", "top", "bottom", "header", "footer"): + if key in margins_spec: + setattr(worksheet.page_margins, key, float(margins_spec[key])) + if "show_gridlines" in op: + worksheet.sheet_view.showGridLines = bool(op["show_gridlines"]) + if "center_horizontally" in op: + worksheet.print_options.horizontalCentered = bool( + op["center_horizontally"] + ) + return 0 + + +def _op_set_named_range(workbook: Any, op: dict[str, Any]) -> int: + from openpyxl.workbook.defined_name import DefinedName + + worksheet = _sheet(workbook, op.get("sheet")) + name = str(op.get("name", "")).strip() + if not name or not TABLE_NAME_RE.fullmatch(name): + raise ValueError("set_named_range.name 无效") + reference = validate_cell_range(str(op.get("range", ""))) + quoted_sheet = worksheet.title.replace("'", "''") + attr_text = f"'{quoted_sheet}'!{reference}" + defined = DefinedName(name, attr_text=attr_text) + try: + workbook.defined_names.add(defined) + except AttributeError: + workbook.defined_names.append(defined) + return 0 + + +def _op_freeze_panes(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + value = op.get("cell") + worksheet.freeze_panes = ( + validate_cell_reference(str(value)) if value not in {None, ""} else None + ) + return 0 + + +def _op_auto_filter(workbook: Any, op: dict[str, Any]) -> int: + worksheet = _sheet(workbook, op.get("sheet")) + value = op.get("range") + worksheet.auto_filter.ref = ( + validate_cell_range(str(value)) if value not in {None, ""} else None + ) + return 0 + + +def _apply_operation(workbook: Any, raw_op: Any) -> int: + op = _expect_object(raw_op, "operations[]") + kind = str(op.get("type", "")).strip() + handlers = { + "add_sheet": _op_add_sheet, + "remove_sheet": _op_remove_sheet, + "rename_sheet": _op_rename_sheet, + "set_cells": _op_set_cells, + "write_rows": _op_write_rows, + "append_rows": _op_append_rows, + "style_range": _op_style_range, + "clear_range": _op_clear_range, + "set_column_widths": _op_set_column_widths, + "set_row_heights": _op_set_row_heights, + "add_table": _op_add_table, + "add_chart": _op_add_chart, + "add_image": _op_add_image, + "add_data_validation": _op_add_data_validation, + "add_conditional_format": _op_add_conditional_format, + "set_print": _op_set_print, + "set_named_range": _op_set_named_range, + "freeze_panes": _op_freeze_panes, + "set_auto_filter": _op_auto_filter, + } + if kind in {"insert_rows", "delete_rows", "insert_columns", "delete_columns"}: + return _op_rows_or_columns(workbook, op, kind) + if kind == "merge_cells": + return _op_merge(workbook, op, True) + if kind == "unmerge_cells": + return _op_merge(workbook, op, False) + handler = handlers.get(kind) + if handler is None: + supported = sorted( + set(handlers) + | { + "insert_rows", + "delete_rows", + "insert_columns", + "delete_columns", + "merge_cells", + "unmerge_cells", + } + ) + raise ValueError(f"不支持的操作 {kind!r};可选:{'、'.join(supported)}") + return handler(workbook, op) + + +def _scan_workbook(workbook: Any) -> dict[str, Any]: + formula_count = 0 + literal_errors: list[dict[str, str]] = [] + risky_formulas: list[dict[str, str]] = [] + for worksheet in workbook.worksheets: + for cell in worksheet._cells.values(): + value = cell.value + if cell.data_type == "f" or ( + isinstance(value, str) and value.startswith("=") + ): + formula_count += 1 + if ( + isinstance(value, str) + and RISKY_FORMULA_RE.search(value) + and len(risky_formulas) < 100 + ): + risky_formulas.append( + { + "sheet": worksheet.title, + "cell": cell.coordinate, + "formula": value, + } + ) + error = normalize_formula_error(value) + if error and len(literal_errors) < 100: + literal_errors.append( + { + "sheet": worksheet.title, + "cell": cell.coordinate, + "error": error, + } + ) + return { + "formula_count": formula_count, + "literal_errors": literal_errors, + "risky_formulas": risky_formulas, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = SkillArgumentParser( + description="依据受控 JSON 操作创建或编辑 Excel 工作簿。" + ) + parser.add_argument("--input", help="可选的现有 .xlsx/.xlsm/.xltx 文件") + parser.add_argument("--output", required=True, help="输出 .xlsx 或 .xlsm") + parser.add_argument("--spec", help="内联 JSON 操作说明") + parser.add_argument("--spec-file", help="JSON 操作说明文件") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument( + "--allow-external-links", + action="store_true", + help="明确接受外部链接缓存值可能丢失的风险", + ) + parser.add_argument( + "--drop-macros", + action="store_true", + help="明确允许把 .xlsm 输出为 .xlsx 并丢弃宏", + ) + return parser + + +def main() -> dict[str, Any]: + from openpyxl import Workbook, load_workbook + + args = build_parser().parse_args() + destination = output_file( + args.output, + EXCEL_OUTPUT_SUFFIXES, + overwrite=args.overwrite, + ) + spec = load_json_argument(args.spec, args.spec_file, label="操作说明") + unknown_top = set(spec) - { + "properties", + "operations", + "active_sheet", + "calculation_mode", + } + if unknown_top: + raise ValueError(f"操作说明包含未知顶层字段:{sorted(unknown_top)}") + + source: Optional[Path] = None + warnings: list[str] = [] + if args.input: + source = input_file(args.input, EXCEL_INPUT_SUFFIXES) + if source == destination: + raise ValueError("输出路径不能与输入文件相同;请保留原始文件") + has_external_links = workbook_has_external_links(source) + if has_external_links and not args.allow_external_links: + raise ValueError( + "输入工作簿含外部链接。openpyxl 保存后可能丢失缓存值;" + "请先固化外部数据,或明确传 --allow-external-links 接受风险" + ) + if has_external_links: + warnings.append("输入工作簿含外部链接,保存后外部缓存值可能变化") + source_macro = source.suffix.lower() in {".xlsm", ".xltm"} + destination_macro = destination.suffix.lower() == ".xlsm" + if source_macro and not destination_macro and not args.drop_macros: + raise ValueError( + "宏工作簿输出为 .xlsx 会丢失宏;如确认接受请传 --drop-macros" + ) + if not source_macro and destination_macro: + raise ValueError("不能从无宏工作簿新建真正的 .xlsm 文件") + if source_macro and not destination_macro: + warnings.append("已按要求把宏工作簿输出为 .xlsx,宏将被丢弃") + workbook = load_workbook(source, **openpyxl_load_options(source)) + else: + if destination.suffix.lower() == ".xlsm": + raise ValueError("新建工作簿只支持 .xlsx;无 VBA 工程时不能伪造 .xlsm") + workbook = Workbook() + + if "properties" in spec: + _apply_properties(workbook, spec["properties"]) + operations = _expect_list(spec.get("operations", []), "operations") + if len(operations) > MAX_OPERATIONS: + raise ValueError(f"operations 不能超过 {MAX_OPERATIONS} 项") + + written_cells = 0 + for index, operation in enumerate(operations): + try: + written_cells += _apply_operation(workbook, operation) + except Exception as exc: + raise ValueError(f"第 {index + 1} 个操作失败:{exc}") from exc + if written_cells > MAX_WRITTEN_CELLS: + raise ValueError( + f"本次任务写入或处理的单元格不能超过 {MAX_WRITTEN_CELLS}" + ) + + if "active_sheet" in spec: + active_name = str(spec["active_sheet"]) + if active_name not in workbook.sheetnames: + raise ValueError(f"active_sheet 不存在:{active_name}") + workbook.active = workbook.sheetnames.index(active_name) + + calculation_mode = str(spec.get("calculation_mode", "auto")) + if calculation_mode not in {"auto", "manual", "autoNoTable"}: + raise ValueError("calculation_mode 只能是 auto、manual、autoNoTable") + if getattr(workbook, "calculation", None): + workbook.calculation.calcMode = calculation_mode + workbook.calculation.fullCalcOnLoad = True + workbook.calculation.forceFullCalc = True + + scan = _scan_workbook(workbook) + if scan["literal_errors"]: + warnings.append("工作簿中存在字面错误值,请在交付前检查") + if scan["risky_formulas"]: + warnings.append( + "检测到 LibreOffice 可能无法可靠重算的动态数组/新式查找公式" + ) + + descriptor, temp_name = tempfile.mkstemp( + prefix=f".{destination.stem}.", + suffix=destination.suffix, + dir=str(destination.parent), + ) + os.close(descriptor) + temp_path = Path(temp_name) + try: + workbook.save(temp_path) + load_workbook( + temp_path, + read_only=True, + data_only=False, + keep_vba=destination.suffix.lower() == ".xlsm", + keep_links=True, + ).close() + publish_file(temp_path, destination, overwrite=args.overwrite) + finally: + if temp_path.exists(): + temp_path.unlink() + + return { + "path": str(destination), + "source": str(source) if source else None, + "sheet_names": workbook.sheetnames, + "active_sheet": workbook.active.title, + "operation_count": len(operations), + "processed_cell_count": written_cells, + "formula_count": scan["formula_count"], + "requires_recalculation": scan["formula_count"] > 0, + "literal_errors": scan["literal_errors"], + "risky_formulas": scan["risky_formulas"], + "warnings": warnings, + } + + +if __name__ == "__main__": + raise SystemExit(run_cli(main)) diff --git a/skills/xlsx/scripts/convert_workbook.py b/skills/xlsx/scripts/convert_workbook.py new file mode 100644 index 0000000..57c3e74 --- /dev/null +++ b/skills/xlsx/scripts/convert_workbook.py @@ -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)) diff --git a/skills/xlsx/scripts/download_workbook.py b/skills/xlsx/scripts/download_workbook.py new file mode 100644 index 0000000..291b0f2 --- /dev/null +++ b/skills/xlsx/scripts/download_workbook.py @@ -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((" 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()) diff --git a/skills/xlsx/scripts/inspect_workbook.py b/skills/xlsx/scripts/inspect_workbook.py new file mode 100644 index 0000000..902a04c --- /dev/null +++ b/skills/xlsx/scripts/inspect_workbook.py @@ -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)) diff --git a/skills/xlsx/scripts/recalculate_workbook.py b/skills/xlsx/scripts/recalculate_workbook.py new file mode 100644 index 0000000..108cd63 --- /dev/null +++ b/skills/xlsx/scripts/recalculate_workbook.py @@ -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)) diff --git a/skills/xlsx/scripts/render_workbook.py b/skills/xlsx/scripts/render_workbook.py new file mode 100644 index 0000000..8f4bd69 --- /dev/null +++ b/skills/xlsx/scripts/render_workbook.py @@ -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))