diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 2fc3d668..de818e29 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -400,18 +400,6 @@ }, "category": "Creativity" }, - { - "name": "postgres", - "source": { - "source": "local", - "path": "./plugins/codex/plugins/postgres" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Data & Analytics" - }, { "name": "literature-review", "source": { diff --git a/README.md b/README.md index 631c909f..4e915c2f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ 有些插件是 skill 集合,安装一个 `plugin-id` 后会同时提供多个 `$skill-name`。可安装项和安装后可用的 skills 均以 Codex 的实际输出为准。 +`docx`、`pdf`、`pptx` 和 `xlsx` 提供简体中文入口与 Windows PowerShell 指引。首次使用先检查 Python、Node、中文字体和实际需要的依赖;Office 转换需要 LibreOffice,PDF 逐页预览使用 PyMuPDF,无需 Poppler。中文文件路径必须加引号,交付前检查缺字、版式与公式结果。 + +`find-skills` 使用 Vercel Skills CLI 搜索技能,需要 Node.js 与 `npx`。搜索结果里的安装命令适用于其上游生态;安装本市场插件仍使用上述 Codex 命令。 + +`generate-agents-md`、`design-md`、`eapil-llmapi-structured`、`eapil-gpt2-image` 和 `eapil-voice` 的内容与版本跟随 [ManySkills 正式发版](https://git.playones.com/chenmingxuan/ManySkills/releases),安装 ID 保持不变。图像、LLM 和音频服务需要用户自行配置相应凭据。 + `dingtalk-workspace` 提供钉钉官方办公协作 Skills。使用前请单独安装与插件版本匹配的 [dws CLI](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases),执行 `dws auth login` 并取得企业授权;辅助脚本需要 Python 3。建议使用官方预编译二进制,避免安装器额外安装重复 Skills;升级 CLI 时可使用 `dws upgrade --skip-skills`,Skills 由本市场更新。插件不会附带账号凭据,消息发送、文档修改和审批等操作需要明确的用户指令。 例如 `grill-me` 插件会同时安装用户入口 `$grill-me` 和必需的基础能力 `$grilling`;请安装插件整体,不要只复制其中一个 skill 目录。 diff --git a/config/assets/docx.svg b/config/assets/docx.svg new file mode 100644 index 00000000..22b8b323 --- /dev/null +++ b/config/assets/docx.svg @@ -0,0 +1 @@ +docx diff --git a/config/assets/find-skills.svg b/config/assets/find-skills.svg new file mode 100644 index 00000000..7fd6fe91 --- /dev/null +++ b/config/assets/find-skills.svg @@ -0,0 +1 @@ +find-skills diff --git a/config/assets/pdf.svg b/config/assets/pdf.svg new file mode 100644 index 00000000..ac86a65c --- /dev/null +++ b/config/assets/pdf.svg @@ -0,0 +1 @@ +pdf diff --git a/config/assets/pptx.svg b/config/assets/pptx.svg new file mode 100644 index 00000000..a04eaf9b --- /dev/null +++ b/config/assets/pptx.svg @@ -0,0 +1 @@ +pptx diff --git a/config/assets/xlsx.svg b/config/assets/xlsx.svg new file mode 100644 index 00000000..744c0229 --- /dev/null +++ b/config/assets/xlsx.svg @@ -0,0 +1 @@ +xlsx diff --git a/config/external-sources.json b/config/external-sources.json index 2c10e166..4ea1a144 100644 --- a/config/external-sources.json +++ b/config/external-sources.json @@ -1,5 +1,444 @@ { "sources": [ + { + "id": "docx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "pluginName": "docx", + "category": "Productivity", + "skillPath": "skills/docx", + "sparseCheckout": [ + "skills/docx" + ], + "materializePaths": [ + { + "source": "skills/docx/SKILL.md", + "destination": "upstream-guide.md" + } + ], + "marketAssets": [ + { + "source": "config/skill-overrides/docx/SKILL.md", + "destination": "skills/docx/SKILL.md" + }, + { + "source": "config/skill-overrides/common/platform.md", + "destination": "skills/docx/references/platform.md" + }, + { + "source": "config/assets/docx.svg", + "destination": "assets/market-icon.svg" + }, + { + "source": "config/skill-overrides/common/soffice.py", + "destination": "skills/docx/scripts/office/soffice.py" + }, + { + "source": "config/skill-overrides/common/render_pdf.py", + "destination": "skills/docx/scripts/render_pdf.py" + }, + { + "source": "config/skill-overrides/common/office_package.py", + "destination": "skills/docx/scripts/office_package.py" + }, + { + "source": "config/skill-overrides/docx/accept_changes.py", + "destination": "skills/docx/scripts/accept_changes.py" + } + ], + "manifestOverrides": { + "version": "2.0.0", + "description": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。", + "author": { + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" + }, + "license": "Proprietary", + "keywords": [ + "docx", + "documents", + "zh-CN", + "windows" + ], + "interface": { + "displayName": "docx", + "shortDescription": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。", + "longDescription": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", + "category": "Productivity", + "defaultPrompt": [ + "使用 docx 处理这份中文文档,并检查最终输出。" + ], + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" + } + } + }, + { + "id": "pdf", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "pluginName": "pdf", + "category": "Productivity", + "skillPath": "skills/pdf", + "sparseCheckout": [ + "skills/pdf" + ], + "materializePaths": [ + { + "source": "skills/pdf/SKILL.md", + "destination": "upstream-guide.md" + } + ], + "marketAssets": [ + { + "source": "config/skill-overrides/pdf/SKILL.md", + "destination": "skills/pdf/SKILL.md" + }, + { + "source": "config/skill-overrides/common/platform.md", + "destination": "skills/pdf/references/platform.md" + }, + { + "source": "config/assets/pdf.svg", + "destination": "assets/market-icon.svg" + }, + { + "source": "config/skill-overrides/common/render_pdf.py", + "destination": "skills/pdf/scripts/convert_pdf_to_images.py" + } + ], + "manifestOverrides": { + "version": "2.0.0", + "description": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。", + "author": { + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" + }, + "license": "Proprietary", + "keywords": [ + "pdf", + "documents", + "zh-CN", + "windows" + ], + "interface": { + "displayName": "pdf", + "shortDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。", + "longDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", + "category": "Productivity", + "defaultPrompt": [ + "使用 pdf 处理这份中文文档,并检查最终输出。" + ], + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" + } + } + }, + { + "id": "pptx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "pluginName": "pptx", + "category": "Productivity", + "skillPath": "skills/pptx", + "sparseCheckout": [ + "skills/pptx" + ], + "materializePaths": [ + { + "source": "skills/pptx/SKILL.md", + "destination": "upstream-guide.md" + } + ], + "marketAssets": [ + { + "source": "config/skill-overrides/pptx/SKILL.md", + "destination": "skills/pptx/SKILL.md" + }, + { + "source": "config/skill-overrides/common/platform.md", + "destination": "skills/pptx/references/platform.md" + }, + { + "source": "config/assets/pptx.svg", + "destination": "assets/market-icon.svg" + }, + { + "source": "config/skill-overrides/common/soffice.py", + "destination": "skills/pptx/scripts/office/soffice.py" + }, + { + "source": "config/skill-overrides/common/render_pdf.py", + "destination": "skills/pptx/scripts/render_pdf.py" + }, + { + "source": "config/skill-overrides/common/office_package.py", + "destination": "skills/pptx/scripts/office_package.py" + } + ], + "manifestOverrides": { + "version": "2.0.0", + "description": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。", + "author": { + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" + }, + "license": "Proprietary", + "keywords": [ + "pptx", + "documents", + "zh-CN", + "windows" + ], + "interface": { + "displayName": "pptx", + "shortDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。", + "longDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", + "category": "Productivity", + "defaultPrompt": [ + "使用 pptx 处理这份中文文档,并检查最终输出。" + ], + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" + } + } + }, + { + "id": "xlsx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "pluginName": "xlsx", + "category": "Productivity", + "skillPath": "skills/xlsx", + "sparseCheckout": [ + "skills/xlsx" + ], + "materializePaths": [ + { + "source": "skills/xlsx/SKILL.md", + "destination": "upstream-guide.md" + } + ], + "marketAssets": [ + { + "source": "config/skill-overrides/xlsx/SKILL.md", + "destination": "skills/xlsx/SKILL.md" + }, + { + "source": "config/skill-overrides/common/platform.md", + "destination": "skills/xlsx/references/platform.md" + }, + { + "source": "config/assets/xlsx.svg", + "destination": "assets/market-icon.svg" + }, + { + "source": "config/skill-overrides/common/soffice.py", + "destination": "skills/xlsx/scripts/office/soffice.py" + }, + { + "source": "config/skill-overrides/common/render_pdf.py", + "destination": "skills/xlsx/scripts/render_pdf.py" + }, + { + "source": "config/skill-overrides/common/office_package.py", + "destination": "skills/xlsx/scripts/office_package.py" + }, + { + "source": "config/skill-overrides/xlsx/recalc.py", + "destination": "skills/xlsx/scripts/recalc.py" + } + ], + "manifestOverrides": { + "version": "2.0.0", + "description": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。", + "author": { + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" + }, + "license": "Proprietary", + "keywords": [ + "xlsx", + "documents", + "zh-CN", + "windows" + ], + "interface": { + "displayName": "xlsx", + "shortDescription": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。", + "longDescription": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", + "category": "Productivity", + "defaultPrompt": [ + "使用 xlsx 处理这份中文文档,并检查最终输出。" + ], + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" + } + } + }, + { + "id": "find-skills", + "repo": "https://github.com/vercel-labs/skills.git", + "ref": "main", + "adapter": "claude-skill", + "pluginName": "find-skills", + "category": "Productivity", + "skillPath": "skills/find-skills", + "sparseCheckout": [ + "skills/find-skills", + "LICENSE" + ], + "includeRootFiles": [ + "LICENSE" + ], + "marketAssets": [ + { + "source": "config/assets/find-skills.svg", + "destination": "assets/market-icon.svg" + } + ], + "skillDescriptions": { + "find-skills": "当用户想查找、选择或安装现成 Agent Skill 时,搜索技能生态并核对来源与适用性。" + }, + "manifestOverrides": { + "description": "从 Vercel Skills 生态发现和安装适合任务的 Agent Skills。", + "author": { + "name": "Vercel", + "url": "https://github.com/vercel-labs" + }, + "license": "MIT", + "interface": { + "displayName": "find-skills", + "shortDescription": "查找并选择适合任务的 Agent Skills。", + "longDescription": "跟随 vercel-labs/skills 的 find-skills 原始技能,使用 Skills CLI 搜索并核对来源。需要 Node.js 和 npx。", + "developerName": "Vercel", + "category": "Productivity", + "defaultPrompt": [ + "使用 find-skills 查找适合当前任务的技能。" + ], + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" + } + } + }, + { + "id": "generate-agents-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "releaseChannel": "latest-stable", + "releaseProvider": "gitea", + "adapter": "codex-plugin", + "pluginName": "generate-agents-md", + "category": "Developer Tools", + "sourcePath": "plugins/generate-agents-md", + "sparseCheckout": [ + "plugins/generate-agents-md" + ], + "include": [ + ".codex-plugin", + "skills", + "assets" + ], + "manifestOverrides": { + "repository": "https://git.playones.com/chenmingxuan/ManySkills", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/generate-agents-md" + } + }, + { + "id": "design-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "releaseChannel": "latest-stable", + "releaseProvider": "gitea", + "adapter": "codex-plugin", + "pluginName": "design-md", + "category": "Creativity", + "sourcePath": "plugins/design-md", + "sparseCheckout": [ + "plugins/design-md" + ], + "include": [ + ".codex-plugin", + "skills", + "assets" + ], + "manifestOverrides": { + "repository": "https://git.playones.com/chenmingxuan/ManySkills", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/design-md" + } + }, + { + "id": "eapil-llmapi-structured", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "releaseChannel": "latest-stable", + "releaseProvider": "gitea", + "adapter": "codex-plugin", + "pluginName": "eapil-llmapi-structured", + "category": "Developer Tools", + "sourcePath": "plugins/eapil-llmapi-structured", + "sparseCheckout": [ + "plugins/eapil-llmapi-structured" + ], + "include": [ + ".codex-plugin", + "skills", + "assets" + ], + "manifestOverrides": { + "repository": "https://git.playones.com/chenmingxuan/ManySkills", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-llmapi-structured" + } + }, + { + "id": "eapil-gpt2-image", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "releaseChannel": "latest-stable", + "releaseProvider": "gitea", + "adapter": "codex-plugin", + "pluginName": "eapil-gpt2-image", + "category": "Creativity", + "sourcePath": "plugins/eapil-gpt2-image", + "sparseCheckout": [ + "plugins/eapil-gpt2-image" + ], + "include": [ + ".codex-plugin", + "skills", + "assets" + ], + "manifestOverrides": { + "repository": "https://git.playones.com/chenmingxuan/ManySkills", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-gpt2-image" + } + }, + { + "id": "eapil-voice", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "releaseChannel": "latest-stable", + "releaseProvider": "gitea", + "adapter": "codex-plugin", + "pluginName": "eapil-voice", + "category": "Creativity", + "sourcePath": "plugins/eapil-voice", + "sparseCheckout": [ + "plugins/eapil-voice" + ], + "include": [ + ".codex-plugin", + "skills", + "assets" + ], + "manifestOverrides": { + "repository": "https://git.playones.com/chenmingxuan/ManySkills", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-voice" + } + }, { "id": "dingtalk-workspace", "repo": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git", diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 78724a84..03d950ed 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -1,5 +1,95 @@ { "sources": [ + { + "id": "docx", + "pluginName": "docx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "pdf", + "pluginName": "pdf", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "pptx", + "pluginName": "pptx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "xlsx", + "pluginName": "xlsx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "adapter": "claude-skill", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "find-skills", + "pluginName": "find-skills", + "repo": "https://github.com/vercel-labs/skills.git", + "ref": "main", + "adapter": "claude-skill", + "commit": "14cf84aa922cccc7e5b11fee5af9a01b3750fddf", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "generate-agents-md", + "pluginName": "generate-agents-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "adapter": "codex-plugin", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "design-md", + "pluginName": "design-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "adapter": "codex-plugin", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "eapil-llmapi-structured", + "pluginName": "eapil-llmapi-structured", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "adapter": "codex-plugin", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "eapil-gpt2-image", + "pluginName": "eapil-gpt2-image", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "adapter": "codex-plugin", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "syncedAt": "2026-10-06T07:26:41Z" + }, + { + "id": "eapil-voice", + "pluginName": "eapil-voice", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "adapter": "codex-plugin", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "syncedAt": "2026-10-06T07:26:41Z" + }, { "id": "dingtalk-workspace", "pluginName": "dingtalk-workspace", @@ -24,8 +114,8 @@ "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", "adapter": "codex-plugin", - "commit": "6571943370f7c9d4de1946481177ee7b306cd8e8", - "syncedAt": "2026-10-04T16:00:00Z" + "commit": "99aafe151a1be72be783e662858e8a0955add59f", + "syncedAt": "2026-10-06T07:26:41Z" }, { "id": "taste-skill", @@ -42,8 +132,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "dba27166ad7b146b2bd840dcfb0a7d0fd95f5c8c", - "syncedAt": "2026-10-05T16:00:00Z" + "commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c", + "syncedAt": "2026-10-06T07:26:41Z" }, { "id": "frontend-slides", @@ -168,8 +258,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "41215d77762375b4b5154b12cfbb4372a835f8a0", - "syncedAt": "2026-10-05T16:00:00Z" + "commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92", + "syncedAt": "2026-10-06T07:26:41Z" }, { "id": "shuorenhua", diff --git a/config/skill-overrides/common/office_package.py b/config/skill-overrides/common/office_package.py new file mode 100644 index 00000000..39b1f27b --- /dev/null +++ b/config/skill-overrides/common/office_package.py @@ -0,0 +1,52 @@ +"""Unpack or repack an OOXML document without shell-specific ZIP commands.""" + +import argparse +import os +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +def unpack(source, destination): + root = Path(destination).resolve() + if root.exists() and any(root.iterdir()): + raise ValueError("Unpack destination must be empty") + with zipfile.ZipFile(source) as archive: + for info in archive.infolist(): + name = PurePosixPath(info.filename.replace("\\", "/")) + if name.is_absolute() or ".." in name.parts or any(":" in part for part in name.parts): + raise ValueError(f"Unsafe ZIP entry: {info.filename}") + if (info.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"ZIP symlink is not supported: {info.filename}") + archive.extractall(root) + + +def pack(source, destination): + root = Path(source).resolve() + output = Path(destination).resolve() + if output.is_relative_to(root): + raise ValueError("Output must be outside the unpacked directory") + if not (root / "[Content_Types].xml").is_file(): + raise ValueError("Source must be the package root containing [Content_Types].xml") + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(suffix=output.suffix, dir=output.parent) + os.close(fd) + try: + with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: + for file in sorted(root.rglob("*")): + if file.is_symlink(): + raise ValueError(f"Package symlink is not supported: {file}") + if file.is_file(): + archive.write(file, file.relative_to(root).as_posix()) + os.replace(temporary, output) + finally: + Path(temporary).unlink(missing_ok=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("unpack", "pack")) + parser.add_argument("input") + parser.add_argument("output") + args = parser.parse_args() + {"unpack": unpack, "pack": pack}[args.action](args.input, args.output) diff --git a/config/skill-overrides/common/platform.md b/config/skill-overrides/common/platform.md new file mode 100644 index 00000000..c3bb862f --- /dev/null +++ b/config/skill-overrides/common/platform.md @@ -0,0 +1,41 @@ +# 简体中文与 Windows 环境 + +以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。 + +```powershell +$env:PYTHONUTF8 = "1" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +python --version +node --version +``` + +若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。 + +需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。 + +## 字体与文本 + +- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。 +- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。 +- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。 +- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。 +- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。 + +## 转换与预览 + +Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。 + +```powershell +# 仅在自动检测不到时设置;不要求添加系统 PATH +$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx" +python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages" +``` + +按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。 + +使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。 + +## 验证与交付 + +生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。 diff --git a/config/skill-overrides/common/render_pdf.py b/config/skill-overrides/common/render_pdf.py new file mode 100644 index 00000000..4820bbdc --- /dev/null +++ b/config/skill-overrides/common/render_pdf.py @@ -0,0 +1,29 @@ +"""Render PDF pages without Poppler; requires PyMuPDF.""" + +import argparse +from pathlib import Path + +import pymupdf + + +def convert(pdf_path, output_dir, max_dim=1600): + if max_dim <= 0: + raise ValueError("max_dim must be positive") + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + with pymupdf.open(pdf_path) as document: + for index, page in enumerate(document): + scale = min(max_dim / page.rect.width, max_dim / page.rect.height) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False) + target = output / f"page_{index + 1}.png" + pixmap.save(str(target)) + print(target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--max-dim", type=int, default=1600) + args = parser.parse_args() + convert(args.input, args.output, args.max_dim) diff --git a/config/skill-overrides/common/soffice.py b/config/skill-overrides/common/soffice.py new file mode 100644 index 00000000..49bfe9f9 --- /dev/null +++ b/config/skill-overrides/common/soffice.py @@ -0,0 +1,61 @@ +"""Portable LibreOffice runner for Windows, macOS and Linux.""" + +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def resolve_soffice_binary(): + explicit = os.environ.get("SOFFICE_PATH") + if explicit: + candidate = Path(explicit).expanduser() + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError(f"SOFFICE_PATH does not exist: {candidate}") + names = ("soffice.com", "soffice.exe", "soffice") if platform.system() == "Windows" else ("soffice",) + for name in names: + binary = shutil.which(name) + if binary: + return binary + candidates = [Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")] + if platform.system() == "Windows": + candidates = [ + Path(root) / "LibreOffice/program" / name + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")) if root + for name in ("soffice.com", "soffice.exe") + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError("LibreOffice not found. Install it or set SOFFICE_PATH to its executable.") + + +def get_soffice_env(): + env = os.environ.copy() + env["PYTHONUTF8"] = "1" + if platform.system() == "Linux": + env.setdefault("SAL_USE_VCLPLUGIN", "svp") + return env + + +def run_soffice(args, **kwargs): + binary = resolve_soffice_binary() + kwargs.setdefault("env", get_soffice_env()) + kwargs.setdefault("timeout", 120) + if any(str(arg).startswith("-env:UserInstallation=") for arg in args): + return subprocess.run([binary, *args], **kwargs) + with tempfile.TemporaryDirectory(prefix="skill-lo-", ignore_cleanup_errors=True) as profile: + profile_arg = f"-env:UserInstallation={Path(profile).resolve().as_uri()}" + return subprocess.run([binary, profile_arg, *args], **kwargs) + + +if __name__ == "__main__": + try: + raise SystemExit(run_soffice(sys.argv[1:]).returncode) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/config/skill-overrides/docx/SKILL.md b/config/skill-overrides/docx/SKILL.md new file mode 100644 index 00000000..31e4d64e --- /dev/null +++ b/config/skill-overrides/docx/SKILL.md @@ -0,0 +1,38 @@ +--- +name: docx +description: 创建、读取、编辑和检查 Word 文档或模板(.docx/.dotx),包括中文排版、表格、批注和修订;适用于简体中文与 Windows PowerShell 环境。 +license: Proprietary. LICENSE.txt has complete terms +--- + +# Word 文档 + +开始操作前读取 [references/platform.md](references/platform.md),确认 Python、Node、中文字体及需要的转换工具。命令中的脚本路径均相对本 Skill。 + +| 任务 | 选择 | +| --- | --- | +| 新建文档 | `docx`(Node)或 `python-docx`,按所需版式选择 | +| 提取正文或表格 | `python-docx`;需要查看修订时用 Pandoc 或检查 OOXML | +| 编辑复杂模板、批注或修订 | 保留 OOXML 结构,使用随附脚本 | +| .doc 旧文件 | LibreOffice 先转换为 .docx | + +## 中文版式 + +未指定页面时,新文档采用 A4;已有文档沿用其页面和样式。正文中文字体设置 East Asian 字体,语言为 `zh-CN`,标题使用真正的标题样式。保持全角标点、编号、金额和原文引文;中文表格不要依赖空格对齐。 + +`docx` 创建时注意:表格同时指定列宽和单元格宽度;`ShadingType.CLEAR` 避免黑底;列表用 numbering 配置;`ImageRun` 指定类型;分页符放进 Paragraph。每个逻辑段落独立创建,不用换行模拟段落。需要 API 细节时按需读取 [upstream-guide.md](upstream-guide.md)。 + +## 编辑与审阅 + +先确认要清稿、批注还是保留修订;不要因文档类型自动强制红线。原有修订与批注保持不变,除非用户要求接受、拒绝或删除。 + +```powershell +python "$skill\scripts\office_package.py" unpack ".\输入.docx" ".\unpacked" +python "$skill\scripts\merge_runs.py" ".\unpacked" +# 按需编辑 word/document.xml,保持命名空间与节点顺序 +python "$skill\scripts\office_package.py" pack ".\unpacked" ".\输出.docx" +python "$skill\scripts\office\validate.py" ".\输出.docx" --original ".\输入.docx" +``` + +`scripts/comment.py` 支持为文档添加批注;输出的定位节点需放到对应正文位置才能显示。`scripts/accept_changes.py` 仅在用户要求接受全部修订时使用,输出新文件;超时、文件未保存或仍有修订均算失败。 + +最终转换为 PDF,用 `scripts/render_pdf.py` 生成预览并查看中文字体、表格、页眉页脚、分页和页码。保留可编辑 Word 文件与用户需要的最终副本。 diff --git a/config/skill-overrides/docx/accept_changes.py b/config/skill-overrides/docx/accept_changes.py new file mode 100644 index 00000000..0e45a390 --- /dev/null +++ b/config/skill-overrides/docx/accept_changes.py @@ -0,0 +1,75 @@ +"""Accept tracked changes into a new DOCX using an isolated LibreOffice profile.""" + +import argparse +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from defusedxml import ElementTree +from office.soffice import run_soffice + +MACRO = ''' + + +Sub AcceptAllTrackedChanges() + Dim dispatcher As Object + dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") + dispatcher.executeDispatch(ThisComponent.CurrentController.Frame, ".uno:AcceptAllTrackedChanges", "", 0, Array()) + ThisComponent.store() + ThisComponent.close(True) +End Sub +''' +WORD_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + + +def accept_changes(input_file, output_file, timeout=120): + source = Path(input_file).resolve() + output = Path(output_file).resolve() + if source == output or not source.is_file() or source.suffix.lower() != ".docx": + return None, "Input must be an existing DOCX and output must be a different file" + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="docx-accept-", ignore_cleanup_errors=True) as temporary: + root = Path(temporary).resolve() + profile = root / "profile" + profile_arg = f"-env:UserInstallation={profile.as_uri()}" + run_soffice([profile_arg, "--headless", "--terminate_after_init"], check=True, capture_output=True, timeout=timeout) + macro_dir = profile / "user/basic/Standard" + macro_dir.mkdir(parents=True, exist_ok=True) + (macro_dir / "Module1.xba").write_text(MACRO, encoding="utf-8") + candidate = root / "accepted.docx" + shutil.copy2(source, candidate) + before = candidate.stat().st_mtime_ns + run_soffice([ + profile_arg, "--headless", "--norestore", + "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", + str(candidate), + ], check=True, capture_output=True, timeout=timeout) + if candidate.stat().st_mtime_ns == before: + return None, "LibreOffice did not save the document" + with zipfile.ZipFile(candidate) as archive: + for name in archive.namelist(): + if not name.startswith("word/") or not name.endswith(".xml"): + continue + document = ElementTree.fromstring(archive.read(name)) + if any(element.tag in {WORD_NS + "ins", WORD_NS + "del", WORD_NS + "moveFrom", WORD_NS + "moveTo"} + or (element.tag.startswith(WORD_NS) and element.tag.endswith("Change")) + for element in document.iter()): + return None, f"Tracked changes remain in {name}" + shutil.copy2(candidate, output) + return None, f"Accepted tracked changes: {output}" + except (OSError, subprocess.SubprocessError, zipfile.BadZipFile) as exc: + return None, f"Failed to accept tracked changes: {exc}" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--timeout", type=int, default=120) + args = parser.parse_args() + _, message = accept_changes(args.input, args.output, args.timeout) + print(message) + raise SystemExit(0 if message.startswith("Accepted tracked changes:") else 1) diff --git a/config/skill-overrides/pdf/SKILL.md b/config/skill-overrides/pdf/SKILL.md new file mode 100644 index 00000000..4fa05ab3 --- /dev/null +++ b/config/skill-overrides/pdf/SKILL.md @@ -0,0 +1,35 @@ +--- +name: pdf +description: 读取、创建、合并、拆分、旋转、OCR 或填写 PDF 表单,处理简体中文字体与页面预览;适用于 Windows PowerShell 和跨平台终端。 +license: Proprietary. LICENSE.txt has complete terms +--- + +# PDF 文档 + +先读取 [references/platform.md](references/platform.md),确认中文字体、Python 与实际需要的依赖。PDF 不等于扫描图片,先判断页面是否有可提取文字。 + +| 任务 | 工具 | +| --- | --- | +| 文本、页面预览、坐标检查 | PyMuPDF | +| 合并、拆分、旋转、元数据、加密 | pypdf | +| 表格提取 | pdfplumber,提取后核对表头、合并单元格与换行 | +| 新建中文 PDF | ReportLab 或可用的 Office/HTML 转 PDF 工具 | +| 扫描页 OCR | 本机可用的 OCR;Tesseract 中文需 `chi_sim` 数据 | + +## 中文与排版 + +新文档默认简体中文、A4;已有版式或用户指定版式优先。中文必须使用实际可用的中文字体。优先嵌入授权允许的字体;ReportLab 简单文档可注册 `UnicodeCIDFont('STSong-Light')`。复制文本不乱码、渲染无缺字都需检查。 + +OCR 只针对扫描页或提取失败的页面;中文可用 `chi_sim+eng`,未装语言包时报告缺失。OCR 结果需核对数字、单位、姓名与表格关系,不凭猜测补全。 + +## 表单 + +涉及填写时按需读取 [forms.md](forms.md),先判断是否有 AcroForm 字段。可填表单使用随附 `extract_form_field_info.py`、`fill_fillable_fields.py`;非可填表单按页面坐标叠加,并检查原内容与填写区域。不要把签名图片视作数字签名。 + +## 预览与检查 + +```powershell +python "$skill\scripts\convert_pdf_to_images.py" ".\输入 中文.pdf" ".\preview" +``` + +此脚本使用 PyMuPDF,无需 Poppler,逐页渲染以避免一次加载整个大文档。合并或拆分后核对页面数量和顺序;填写后检查可见内容、字段值与页面边界。更多提取与转换方案见 [reference.md](reference.md),旧平台专用命令以本地环境指导为准。 diff --git a/config/skill-overrides/pptx/SKILL.md b/config/skill-overrides/pptx/SKILL.md new file mode 100644 index 00000000..50cce731 --- /dev/null +++ b/config/skill-overrides/pptx/SKILL.md @@ -0,0 +1,34 @@ +--- +name: pptx +description: 创建、读取、编辑和检查 PowerPoint 演示文稿或模板(.pptx/.potx),保留模板与可编辑图表,适配简体中文字体和 Windows PowerShell。 +license: Proprietary. LICENSE.txt has complete terms +--- + +# PowerPoint 演示文稿 + +先读取 [references/platform.md](references/platform.md),确认字体、Node/Python 与需要的转换工具。先看用户模板或参考稿,再决定布局、配色与内容密度。 + +| 任务 | 选择 | +| --- | --- | +| 新建幻灯片 | `pptxgenjs`;简单任务也可使用 `python-pptx` | +| 提取文本 | `python-pptx` 或 markitdown;检查备注与隐藏页 | +| 复杂模板、母版或原生图表编辑 | OOXML 加随附脚本 | +| .ppt 旧格式 | LibreOffice 先转 .pptx | + +## 中文与画布 + +新建稿未指定比例时用 16:9,设置画布后再添加元素。用户模板比例和主题优先。使用本机可用的微软雅黑、等线或思源黑体,同时设置主题字体与正文文本字体;简体中文语言标记使用 `zh-CN`。长中文标题主动换行,表格与数据标签保留足够空间,不能只缩小字号隐藏溢出。 + +`pptxgenjs` 颜色使用六位 HEX,不带 `#`;每次添加元素使用独立选项对象。文本框 margin 与字体测量要一致;原生图表、表格和文本保持可编辑。坐标单位为英寸,必须落在实际画布内。库限制、组合图表和模板 OOXML 细节按需读取 [upstream-guide.md](upstream-guide.md)。 + +## 模板编辑 + +先完成复制、删除或重排幻灯片,再改正文。使用 `scripts/add_slide.py` 注册复制页,最终页序确定后运行 `scripts/clean.py` 清理失去引用的部分。不要只复制 slide XML;关系、媒体、母版和内容类型必须一起处理。用 `scripts/office_package.py` 解包和重打包,避免依赖 Bash ZIP 命令。 + +```powershell +python "$skill\scripts\office\validate.py" ".\输出.pptx" --original ".\模板.pptx" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\输出.pptx" +python "$skill\scripts\render_pdf.py" ".\preview\输出.pdf" ".\preview\pages" +``` + +没有模板时省略 `--original`。每页检查文字溢出、缺字、对齐、遮挡、图表标签、图片分辨率和页序。需要模板缩略图且 Poppler 可用时运行 `scripts/thumbnail.py` 并指定独立输出前缀;否则用上面的免 Poppler 逐页预览。 diff --git a/config/skill-overrides/xlsx/SKILL.md b/config/skill-overrides/xlsx/SKILL.md new file mode 100644 index 00000000..eeec9196 --- /dev/null +++ b/config/skill-overrides/xlsx/SKILL.md @@ -0,0 +1,35 @@ +--- +name: xlsx +description: 创建、读取、清理、编辑和检查 Excel 工作簿或 CSV/TSV,处理公式、图表、中文表头及 Windows Excel 编码,保留现有格式与宏。 +license: Proprietary. LICENSE.txt has complete terms +--- + +# Excel 工作簿 + +先读取 [references/platform.md](references/platform.md),确认 Python、中文字体及是否需要公式计算引擎。复杂工作簿先检查公式、外部链接、宏、图表和现有样式,再选择处理方法。 + +- `openpyxl` 用于普通 .xlsx 单元格、公式和样式;`.xlsm` 读取时用 `keep_vba=True`,保留扩展名。不保证复杂 Excel 功能无损,必须检查实际需要保留的部分。 +- `pandas` 适合表格数据清理;不要用整表重写处理需要保留样式、公式或图表的原文件。 +- `.xls` 旧格式先使用兼容工具读取或转换;不要直接交给 openpyxl。 + +## 简体中文与数据类型 + +用户未指定语言时用简体中文表头、图例与说明。新文件使用可用的中文字体,表头、行高与列宽按中文实际长度设置;已有工作簿沿用其约定。身份证号、订单号、手机号和前导零存为文本,日期存真实日期值并设置明确格式,金额单位按用户要求标注。 + +Windows Excel 打开的新 CSV 可写 `utf-8-sig`;读取旧 CSV 先判断编码和分隔符。CSV 导出不保留多表、公式和样式,转换前明确所需交付格式。 + +## 公式与缓存 + +按用户语义写公式,验证引用范围与预期结果;不要用硬编码数值代替可重算公式。跨表引用含空格或中文标点时正确引用工作表名。合并区域只写左上角。 + +openpyxl 不计算公式。检查时分别用 `data_only=False` 读取公式、`data_only=True` 读取缓存;不能保存 `data_only=True` 的工作簿,否则公式会被数值覆盖。 + +```powershell +python "$skill\scripts\recalc.py" ".\输出 中文.xlsx" 120 +``` + +重算会改写指定工作簿,先保留输入。脚本使用独立 LibreOffice profile,自动识别 Windows 的安装路径。检查 JSON 的 `status`、`total_errors` 和具体单元格;`errors_found` 即使进程退出码为零也未通过。含 `error` 的结果表示没有完成重算。 + +有外部链接、宏、动态数组或 Excel 专用函数时,优先使用目标 Excel 环境验证,不能盲目用 LibreOffice 往返保存。兼容公式、缓存丢失和链接风险按需读取 [upstream-guide.md](upstream-guide.md)。无法运行计算引擎时说明“尚未重算”,不要声称公式已验证。 + +交付前核对数值、公式、中文显示、筛选、冻结窗格、打印区域和图表。用新输出文件保留输入;抽查代表行、合计和异常情况,避免只检查文件能够打开。 diff --git a/config/skill-overrides/xlsx/recalc.py b/config/skill-overrides/xlsx/recalc.py new file mode 100644 index 00000000..74dc430b --- /dev/null +++ b/config/skill-overrides/xlsx/recalc.py @@ -0,0 +1,89 @@ +"""Recalculate an XLSX through a temporary LibreOffice import/export, without macros.""" + +import argparse +import json +import os +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from openpyxl import load_workbook +from office.soffice import run_soffice + + +def inspect_workbook(path): + formulas = load_workbook(path, data_only=False) + values = load_workbook(path, data_only=True) + try: + formula_cells = set() + errors = {} + for sheet in formulas: + if not hasattr(sheet, "iter_rows"): + continue + for row in sheet.iter_rows(): + for cell in row: + location = f"{sheet.title}!{cell.coordinate}" + if cell.data_type == "f": + formula_cells.add(location) + cached = values[sheet.title][cell.coordinate] + if cached.data_type == "e": + errors.setdefault(str(cached.value), []).append(location) + return formula_cells, errors + finally: + formulas.close() + values.close() + + +def recalc(filename, timeout=120): + source = Path(filename).expanduser().resolve() + if not source.is_file() or source.suffix.lower() != ".xlsx": + return {"error": "Input must be an existing .xlsx file; use Excel for macro-enabled workbooks"} + try: + with zipfile.ZipFile(source) as archive: + if any(name.startswith("xl/externalLinks/") for name in archive.namelist()): + return {"error": "External workbook links require validation in Excel; original file was not modified"} + original_formulas, _ = inspect_workbook(source) + with tempfile.TemporaryDirectory(prefix="xlsx-recalc-", dir=source.parent, ignore_cleanup_errors=True) as temporary: + root = Path(temporary) + input_dir, output_dir = root / "input", root / "output" + input_dir.mkdir() + output_dir.mkdir() + candidate = input_dir / source.name + shutil.copy2(source, candidate) + result = run_soffice([ + "--headless", "--norestore", "--convert-to", "xlsx:Calc MS Excel 2007 XML", + "--outdir", str(output_dir), str(candidate), + ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) + recalculated = output_dir / source.name + if result.returncode or not recalculated.is_file(): + return {"error": "LibreOffice did not export a recalculated workbook", "details": result.stderr or result.stdout} + formula_cells, errors = inspect_workbook(recalculated) + if original_formulas != formula_cells: + return {"error": "Formula locations changed during conversion; original file was not modified"} + error_count = sum(len(locations) for locations in errors.values()) + report = { + "status": "errors_found" if error_count else "success", + "total_formulas": len(formula_cells), + "total_errors": error_count, + "error_summary": { + error: {"count": len(locations), "locations": locations[:100]} + for error, locations in errors.items() + }, + } + if not errors: + os.replace(recalculated, source) + return report + except (OSError, subprocess.SubprocessError, zipfile.BadZipFile, ValueError) as exc: + return {"error": str(exc)} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("timeout", nargs="?", type=int, default=120) + args = parser.parse_args() + report = recalc(args.input, args.timeout) + print(json.dumps(report, ensure_ascii=False, indent=2)) + raise SystemExit(1 if "error" in report else 0) diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index 6b873919..f452998e 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -400,18 +400,6 @@ }, "category": "Creativity" }, - { - "name": "postgres", - "source": { - "source": "local", - "path": "./plugins/postgres" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Data & Analytics" - }, { "name": "literature-review", "source": { diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index 98fbdc61..0257c6c7 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "caveman", "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", - "commit": "6571943370f7c9d4de1946481177ee7b306cd8e8", + "commit": "99aafe151a1be72be783e662858e8a0955add59f", "adapter": "codex-plugin", "sourcePath": "plugins/caveman", - "syncedAt": "2026-10-04T16:00:00Z" + "syncedAt": "2026-10-06T07:26:41Z" } diff --git a/plugins/codex/plugins/design-md/.codex-plugin/plugin.json b/plugins/codex/plugins/design-md/.codex-plugin/plugin.json index 0d8245e7..f58ccf6f 100644 --- a/plugins/codex/plugins/design-md/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/design-md/.codex-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "design-md", - "version": "0.1.0", - "description": "创建、编辑、验证、比较、导出及应用 Google Labs DESIGN.md 文件,从而构建可供人工智能读取的规范化设计系统。", + "version": "1.0.0", + "description": "创建或完善 DESIGN.md,维护项目设计读取入口,并应用、核对界面设计规则。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/design-md", + "repository": "https://git.playones.com/chenmingxuan/ManySkills", "license": "Proprietary", "keywords": [ "eapil", @@ -17,8 +17,8 @@ "skills": "./skills/", "interface": { "displayName": "DESIGN.md生成器", - "shortDescription": "创建、编辑、验证、比较、导出及应用 Google Labs DESIGN.md 文件,从而构建可供人工智能读取的规范化设计系统。", - "longDescription": "创建、编辑、验证、比较、导出及应用 Google Labs DESIGN.md 文件,从而构建可供人工智能读取的规范化设计系统。", + "shortDescription": "生成设计指南,连接 Agent 读取入口,落实并核对界面设计。", + "longDescription": "基于项目证据创建或完善 Google Labs DESIGN.md,按 UI 作用域维护 AGENTS.md 入口,将设计意图和 token 落实到代码,并区分文档校验与渲染结果验证。", "developerName": "EAPIL", "category": "Creativity", "capabilities": [ diff --git a/plugins/codex/plugins/design-md/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/design-md/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..01839fb8 --- /dev/null +++ b/plugins/codex/plugins/design-md/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "design-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "adapter": "codex-plugin", + "sourcePath": "plugins/design-md", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/design-md/skills/design-md/SKILL.md b/plugins/codex/plugins/design-md/skills/design-md/SKILL.md index a232151d..02e324f7 100644 --- a/plugins/codex/plugins/design-md/skills/design-md/SKILL.md +++ b/plugins/codex/plugins/design-md/skills/design-md/SKILL.md @@ -1,100 +1,28 @@ --- name: design-md -description: Create, edit, validate, compare, export, and apply Google Labs DESIGN.md files for AI-readable design systems. Use when the user asks to create or use DESIGN.md, convert brand/UI guidance into DESIGN.md, derive design rules from an existing app or screenshots, keep frontend implementation aligned with design tokens and prose, validate DESIGN.md with the @google/design.md CLI, or export DESIGN.md tokens to Tailwind, CSS, JSON, or DTCG formats. +description: Create or revise DESIGN.md, connect it to project instructions, or apply and audit its visual rules in UI work. --- # DESIGN.md -Use this skill to make `DESIGN.md` a durable source of truth for a product's visual identity. A `DESIGN.md` combines exact machine-readable tokens in YAML front matter with human-readable design rationale in Markdown. +Make the design usable across later UI tasks: a clear visual intent, exact values where known, an instruction entrypoint, and evidence that the implementation follows it. A saved DESIGN.md is context, not an automatic loader or enforcement mechanism. -## Core Workflow +## Choose the work -1. Locate and read any existing `DESIGN.md` before UI work. Treat it as design context for implementation, review, and refactoring. -2. If creating one, gather concrete inputs first: existing CSS/Tailwind/theme files, component styles, screenshots, brand notes, typography choices, accessibility constraints, and target audience. -3. Write exact tokens in YAML front matter and explain intent in prose. Tokens provide values; prose explains how to apply them. -4. Validate with the official CLI whenever Node/npm is available. Fix errors before considering the file ready. -5. Apply tokens and prose to implementation, then update `DESIGN.md` if the code intentionally changes the design system. +- **Create or revise:** Use the user's references and actual brand/theme/component evidence. Describe distinctive intent and important states; include tokens when useful. Read [format and tooling](references/design-md-reference.md) for schema, lint, diff, or export. The [template](assets/DESIGN.md.template) is a scaffold, not a palette or visual default. Remove or resolve its prompts before delivery. +- **Connect future tasks:** When creating or maintaining a design guide, merge a short, scoped reading rule into the project's active AGENTS.md. Preserve existing instructions; use paths relative to that guide. For placement, multiple apps, and a starting snippet, read [integration and application](references/applying-design.md). Do not claim a running session will automatically reload new instructions. +- **Apply or repair drift:** Resolve the design applicable to the target UI, understand its prose and token roles, and map this task's constraints to actual theme/component code. Follow [integration and application](references/applying-design.md) for scope and implementation checks. Small, clear UI changes need only their relevant constraints, not a full design-system rewrite. -Read `references/design-md-reference.md` when creating, editing, validating, exporting, or applying a `DESIGN.md`. +Use the user's explicit choice of design. Otherwise, use the nearest applicable DESIGN.md toward the project root; consume shared or ancestor guides only when inheritance is established. This is this skill's selection policy, not a file-loading feature of the format. Separate apps can have separate design sources. -Use `assets/DESIGN.md.template` as a starting point for new files when the repo has no existing `DESIGN.md`. +## Preserve intent -## Creating DESIGN.md +Treat the accepted guide as the design target. Repair code that deviates. Change the guide only when the task intentionally changes the design or authorizes filling a genuine gap; document that change rather than retroactively legitimizing an accidental implementation. Surface conflicting or missing decisions when they materially affect the result. Preserve valid custom sections and token keys. -Prefer a specific design reference over vague adjectives. For example, "enterprise observability console with dense tables and quiet operational controls" is more useful than "modern, clean, premium." Include intentional negative constraints only when they prevent likely drift. +Bind values into the project's existing styling system; check overrides, component variants, and computed styles. Exact tokens alone do not express density, hierarchy, brand character, or responsive behavior. Prose does not replace known exact values. -Structure the file as: +## Verification and delivery -```markdown ---- -version: alpha -name: Product Design System -colors: - primary: "#1A1C1E" -typography: - body-md: - fontFamily: Inter - fontSize: 16px - fontWeight: 400 - lineHeight: 1.5 ---- +When the guide changes, use applicable format checks; CLI lint and export do not verify the UI. When UI changes, check representative rendered screens and affected states/sizes against the task's design constraints. Use existing browser/tests where available. If rendering is unavailable, report which checks remain; do not call a code-only or document-only check visual verification. -## Overview -... -``` - -Use canonical `##` sections in this order when relevant: `Overview`, `Colors`, `Typography`, `Layout`, `Elevation & Depth`, `Shapes`, `Components`, `Do's and Don'ts`. Preserve custom sections when editing existing files. - -Include token groups that the implementation can actually use: - -- `colors`: CSS color strings such as hex, rgb(), hsl(), oklch(), named colors, or transparent. -- `typography`: objects with font family, size, weight, line height, letter spacing, and optional feature/variation settings. -- `rounded`: radius scale values such as `4px`, `8px`, or `9999px`. -- `spacing`: spacing scale values such as `4px`, `8px`, `1rem`, or domain-specific numeric values. -- `components`: common component style tokens and state variants using literal values or references like `{colors.primary}`. - -## Applying DESIGN.md - -When building UI from an existing `DESIGN.md`: - -1. Parse the YAML tokens first for exact values. -2. Read the prose for personality, density, hierarchy, interaction emphasis, and constraints. -3. Map tokens into the local styling system: CSS variables, Tailwind theme, component props, design-token JSON, or framework-specific theme configuration. -4. Use the design's named roles, not arbitrary nearby values. Do not invent new colors, radii, shadows, or type styles unless the task requires extending the system. -5. Keep visual decisions consistent across new screens and components. When a required pattern is absent, extend the `DESIGN.md` deliberately and validate it. - -## CLI - -Use the official package for checks and conversion: - -```powershell -npx -p @google/design.md designmd lint DESIGN.md -npx -p @google/design.md designmd diff DESIGN.md DESIGN-v2.md -npx -p @google/design.md designmd export --format css-tailwind DESIGN.md > theme.css -npx -p @google/design.md designmd export --format json-tailwind DESIGN.md > tailwind.theme.json -npx -p @google/design.md designmd export --format dtcg DESIGN.md > tokens.json -``` - -On macOS/Linux, `npx @google/design.md ...` is also acceptable. On Windows/PowerShell, prefer the `designmd` alias via `npx -p @google/design.md designmd ...` because the `.md` executable name can collide with Markdown file associations. - -If CLI access is unavailable, manually check: - -- Front matter starts and ends with exact `---` fences. -- Token references resolve to existing YAML paths. -- Component text/background color pairs meet WCAG AA contrast for normal text when possible. -- Present sections follow canonical order. -- No duplicate section headings exist. - -## Editing Rules - -Preserve unknown sections and custom token keys unless the user asks to remove them. Unknown content is allowed by the format and often contains project-specific design context. - -Reject or repair duplicate canonical sections, broken token references, obvious misspelled top-level keys, and invalid YAML. - -Use `diff` when comparing versions. Treat added errors or warnings as a regression unless the user explicitly accepts the change. - -## Sources - -Primary reference: https://github.com/google-labs-code/design.md - -Official spec: https://github.com/google-labs-code/design.md/blob/main/docs/spec.md +Report the active design and scope, changed files, relevant rule-to-code mappings, checks performed, and any unresolved drift. Keep the mapping in the handoff or existing project docs rather than creating extra reports for every small task. diff --git a/plugins/codex/plugins/design-md/skills/design-md/agents/openai.yaml b/plugins/codex/plugins/design-md/skills/design-md/agents/openai.yaml index 02d5af93..9fe3dd4b 100644 --- a/plugins/codex/plugins/design-md/skills/design-md/agents/openai.yaml +++ b/plugins/codex/plugins/design-md/skills/design-md/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "DESIGN.md" - short_description: "Create and apply AI-readable design systems" - default_prompt: "Use $design-md to create or apply a DESIGN.md for this UI project." + short_description: "Create, connect, and apply project design rules" + default_prompt: "Use $design-md to create or apply this project's DESIGN.md, maintain its scoped instruction entrypoint, and verify the relevant UI result." diff --git a/plugins/codex/plugins/design-md/skills/design-md/assets/AGENTS.design-snippet.md b/plugins/codex/plugins/design-md/skills/design-md/assets/AGENTS.design-snippet.md new file mode 100644 index 00000000..9f45ae27 --- /dev/null +++ b/plugins/codex/plugins/design-md/skills/design-md/assets/AGENTS.design-snippet.md @@ -0,0 +1,7 @@ + + +## UI design + +For UI changes in this guide's scope, read [DESIGN.md](DESIGN.md) and apply its design intent and exact values to the relevant theme and components. Backend-only tasks do not need this visual context. + +Check representative rendered results and affected states/sizes against the rules used in the task. Repair implementation drift; do not rewrite the accepted design to excuse it. If the task deliberately changes the design, update its guide and implementation consistently. diff --git a/plugins/codex/plugins/design-md/skills/design-md/assets/DESIGN.md.template b/plugins/codex/plugins/design-md/skills/design-md/assets/DESIGN.md.template index 88c628b9..73ef70f7 100644 --- a/plugins/codex/plugins/design-md/skills/design-md/assets/DESIGN.md.template +++ b/plugins/codex/plugins/design-md/skills/design-md/assets/DESIGN.md.template @@ -1,103 +1,33 @@ ---- -version: alpha -name: Product Design System -description: Replace with a short description of the product or brand identity. -colors: - primary: "#111827" - on-primary: "#FFFFFF" - secondary: "#4B5563" - tertiary: "#2563EB" - neutral: "#F9FAFB" - surface: "#FFFFFF" - on-surface: "#111827" - border: "#E5E7EB" -typography: - headline-lg: - fontFamily: Inter - fontSize: 32px - fontWeight: 700 - lineHeight: 1.15 - headline-md: - fontFamily: Inter - fontSize: 24px - fontWeight: 650 - lineHeight: 1.2 - body-md: - fontFamily: Inter - fontSize: 16px - fontWeight: 400 - lineHeight: 1.5 - label-md: - fontFamily: Inter - fontSize: 14px - fontWeight: 600 - lineHeight: 1.2 -rounded: - sm: 4px - md: 8px - lg: 12px - full: 9999px -spacing: - xs: 4px - sm: 8px - md: 16px - lg: 24px - xl: 32px -components: - page: - backgroundColor: "{colors.neutral}" - textColor: "{colors.on-surface}" - button-primary: - backgroundColor: "{colors.tertiary}" - textColor: "{colors.on-primary}" - typography: "{typography.label-md}" - rounded: "{rounded.md}" - padding: 12px - card: - backgroundColor: "{colors.surface}" - textColor: "{colors.on-surface}" - rounded: "{rounded.lg}" - padding: 24px - divider: - backgroundColor: "{colors.border}" - height: 1px ---- + ## Overview -Describe the specific product, audience, visual reference, interaction density, and emotional target. Prefer concrete references over generic terms. +[Product, audience, specific visual reference, distinctive identity and interaction density.] ## Colors -- **Primary** is used for core text and the strongest structural elements. -- **Secondary** is used for captions, metadata, borders, and low-emphasis controls. -- **Tertiary** is used for the primary action and focused interactive emphasis. -- **Neutral** is the page foundation. -- **Surface** is used for panels, inputs, cards, and modal content. +[Actual color roles and known values. Explain accent use and the relevant text/background pairs.] ## Typography -Describe the type hierarchy, role of each family, weight strategy, line-height rhythm, casing, and how dense or relaxed text should feel. +[Verified font availability, hierarchy, density, weights and fallback behavior.] ## Layout -Describe grid behavior, max content width, gutters, spacing rhythm, responsive behavior, and whether the UI should feel dense, spacious, editorial, operational, playful, or utilitarian. +[Concrete container, spacing and responsive rules relevant to this product.] ## Elevation & Depth -Describe how hierarchy is created: shadows, borders, tonal layers, color contrast, flat surfaces, or another strategy. +[How hierarchy is expressed: borders, surfaces, elevation or another evidenced approach.] ## Shapes -Describe radius usage and exceptions. State whether the system is sharp, soft, geometric, organic, or mixed. +[Shape language and justified exceptions. Use exact values when known.] ## Components -Describe primary buttons, secondary buttons, inputs, cards, lists, tables, navigation, dialogs, tabs, chips, tooltips, and state variants that matter to the product. +[Relevant components and their important states; connect each to the design roles or tokens.] ## Do's and Don'ts -- Do keep token usage consistent across screens. -- Do preserve the intended hierarchy and interaction emphasis. -- Don't introduce new colors, radii, shadows, or type styles without updating this file. -- Don't use generic decorative effects that conflict with the overview. +[Only a few intentional constraints that prevent likely drift; accepted design changes require task evidence.] diff --git a/plugins/codex/plugins/design-md/skills/design-md/references/applying-design.md b/plugins/codex/plugins/design-md/skills/design-md/references/applying-design.md new file mode 100644 index 00000000..1be7bc6f --- /dev/null +++ b/plugins/codex/plugins/design-md/skills/design-md/references/applying-design.md @@ -0,0 +1,52 @@ +# Connect and apply a design + +This workflow belongs to this skill. Google Labs defines the DESIGN.md representation; it does not provide automatic Codex loading or a UI-enforcement hook. + +## Choose the active source + +Use a design explicitly selected by the user. Otherwise, locate DESIGN.md from the target UI directory toward the project root and use the closest relevant guide. Inspect stated scope and inheritance; do not merge unrelated palettes or silently apply a sibling application's guide. If a task spans multiple apps, identify a source for each. + +Where shared root and local design rules intentionally compose, use the local rules for their declared overrides. A missing local detail is not permission to borrow a conflicting style. If the choice changes the outcome and cannot be inferred, ask a focused question. + +## Keep it discoverable + +When creating or maintaining DESIGN.md, merge a small UI-conditioned reading rule into the active project AGENTS.md, preserving existing instructions. Start from [the snippet](../assets/AGENTS.design-snippet.md) only if helpful. Adapt it to actual UI paths and document locations; paths in links are relative to the instruction file. + +For multiple apps, the root guide can route console UI work to apps/console/DESIGN.md and storefront work to apps/storefront/DESIGN.md. A nested AGENTS.md is useful for distinct local instructions, but is not a substitute for ensuring the session actually receives the route. Do not copy the full design into always-loaded instructions. Backend-only tasks do not need to load visual guidance. + +Codex documents startup discovery from the project root toward its working directory. DESIGN.md is not itself an automatically discovered instruction file. Adding or changing AGENTS.md does not prove the active session has reloaded it. For lasting integration, verify in a fresh session or explicitly supply the relevant guide for the current task. Check the chosen host's own loading rules if supporting another agent. + +If the project has existing agent instructions, merge into the effective guide; do not introduce AGENTS.override.md or duplicate competing loader files merely to force precedence. If the task expressly limits edits to DESIGN.md, honor that limit and provide the reading rule as a handoff instead. + +## Convert intent to implementation + +Understand the prose, then the exact values and semantic roles. For this task, identify the constraints that affect the result: for example a control radius, accent role, text density, border hierarchy, font family, or mobile layout. Keep a compact rule-to-code mapping in working notes or the handoff; a small change may need only a sentence. + +Map tokens into the current CSS variables, theme, utility system, or component API. Reuse semantic roles instead of selecting nearby literals. Inspect the existing cascade: global selectors, inline values, utilities, theme modes, library defaults and component variants can override a correct token declaration. Exported CSS that is not imported or is overridden has not applied the design. + +For known measurements, bind or use the exact value. For prose-only intent, choose an implementation consistent with its specific reference and hierarchy. A required new state can use existing roles; introduce a new token only if the task justifies it. Keep unrequested theme redesigns outside scope. + +If an implementation deviates from the accepted guide, fix the implementation. Change DESIGN.md only for a requested design change or an authorized, evidenced gap. Do not rewrite the document to match accidental drift. Preserve functional behavior and accessibility while repairing styles. + +## Verify the result + +Use checks appropriate to the changed constraints: + +- Inspect computed styles for critical exact values after all overrides are applied. +- Inspect a representative rendered page for hierarchy, density, spacing, component shape and brand character that numeric checks cannot establish. +- Exercise affected hover/focus/disabled/error states and responsive sizes when those rules matter to this change; do not expand a tiny edit into a full UI audit. +- Run existing relevant UI/accessibility/build checks when they provide useful evidence. + +Source inspection can catch an obvious unbound token, but it is not rendered verification. Document lint checks the document; export checks conversion; neither establishes implementation adherence. Record actual checks, unresolved drift, and missing browser or app access. Do not claim every future model will obey the guide. + +## Delivery + +Identify the active design file and UI scope, integration entrypoint, relevant constraints and implementation locations, and verification evidence. Report intentionally changed design rules separately from code repairs. Do not add persistent mapping reports, full visual regression suites, or extra tooling unless the task warrants them. + +## Sources + +Reviewed 2026-09-30: + +- [Google Labs design philosophy](https://github.com/google-labs-code/design.md/blob/main/PHILOSOPHY.md): design context and intent. +- [Google Labs format](https://github.com/google-labs-code/design.md/blob/main/docs/spec.md): tokens and prose. +- [Codex instruction discovery](https://learn.chatgpt.com/docs/agent-configuration/agents-md): instruction loading; DESIGN.md integration above is this skill's policy. diff --git a/plugins/codex/plugins/design-md/skills/design-md/references/design-md-reference.md b/plugins/codex/plugins/design-md/skills/design-md/references/design-md-reference.md index 06ba3308..b1dba5ce 100644 --- a/plugins/codex/plugins/design-md/skills/design-md/references/design-md-reference.md +++ b/plugins/codex/plugins/design-md/skills/design-md/references/design-md-reference.md @@ -1,198 +1,75 @@ -# DESIGN.md Reference +# DESIGN.md format and tooling -Use this reference after the `design-md` skill triggers. It is a compact working guide based on the Google Labs DESIGN.md alpha specification. +Use for creating a guide or handling schema, lint, diff, and export. For discovery, instruction integration, and implementation adherence, use [applying-design.md](applying-design.md). -## Purpose +## Current format -`DESIGN.md` is a plain-text design-system file for coding agents. It has two layers: +Reviewed 2026-09-30 against the Google Labs alpha specification and CLI 0.4.0. The CLI package version is separate from `version: alpha` in a document and from this plugin's release version. -- YAML front matter for machine-readable design tokens. -- Markdown prose for design rationale, usage guidance, and constraints. +A guide has a Markdown body and optional YAML front matter. With tokens, the YAML block must be at the beginning between exact `---` lines. Tokens carry normative values; prose explains their roles and fills in design decisions they cannot express. Prose-only guides are valid; do not invent token groups to satisfy a checklist. -Tokens are the exact values. Prose explains the design intent and tells the agent how to apply the values. +When relevant, canonical `##` sections occur in this order: Overview (or Brand & Style), Colors, Typography, Layout (or Layout & Spacing), Elevation & Depth (or Elevation), Shapes, Components, Do's and Don'ts. Omit irrelevant sections; preserve custom sections. Duplicate headings are errors. Section order comes from this format, unlike AGENTS.md's free-form headings. -## File Shape +## Tokens -```markdown ---- -version: alpha -name: Product Name -description: Optional one-line summary -colors: - primary: "#1A1C1E" -typography: - body-md: - fontFamily: Inter - fontSize: 16px - fontWeight: 400 - lineHeight: 1.5 -rounded: - sm: 4px -spacing: - md: 16px -components: - button-primary: - backgroundColor: "{colors.primary}" - textColor: "{colors.on-primary}" - rounded: "{rounded.sm}" - padding: 12px ---- +- `name`: design-system name; optional `version` currently uses `alpha`, and `description` is optional. +- `omitted`: optional strings or objects with `section` and optional `reason`, for deliberately absent groups. Use for genuine omissions, not to hide problems. +- `colors`: named CSS colors; semantic roles explain where to use them. +- `typography`: named objects using `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation` as applicable. +- `rounded`: dimensions; `spacing`: dimensions or numbers. Supported dimension units are px, em, rem; explain unitless spacing values. Unitless lineHeight is a font-size multiplier. +- `components`: component/state properties; known properties include backgroundColor, textColor, typography, rounded, padding, size, height, width. Custom properties can produce review warnings. -## Overview -... -``` +References use `{path.to.token}`. Most targets are primitive values; component typography may reference a full typography object. Resolve every reference; unknown valid token names are allowed. -The front matter block must be at the top of the file and delimited by lines containing exactly `---`. - -## Token Schema - -Top-level token groups: - -- `version`: optional, current alpha value is `alpha`. -- `name`: design-system name. -- `description`: optional short summary. -- `colors`: map of color token names to CSS color strings. -- `typography`: map of type role names to typography objects. -- `rounded`: map of radius scale names to dimensions. -- `spacing`: map of spacing names to dimensions or numbers. -- `components`: map of component/state names to component property tokens. - -Common color token names: `primary`, `secondary`, `tertiary`, `neutral`, `surface`, `on-surface`, `error`, `on-primary`. - -Common typography token names: `headline-display`, `headline-lg`, `headline-md`, `body-lg`, `body-md`, `body-sm`, `label-lg`, `label-md`, `label-sm`. - -Common rounded token names: `none`, `sm`, `md`, `lg`, `xl`, `full`. - -## Token Types - -Color values may be any valid CSS color string. Hex `#RRGGBB` is the safest default for broad tooling support. - -Dimensions are numbers with `px`, `em`, or `rem` units. Use unitless `lineHeight` only when intended as a multiplier. - -Typography objects may contain: - -- `fontFamily` -- `fontSize` -- `fontWeight` -- `lineHeight` -- `letterSpacing` -- `fontFeature` -- `fontVariation` - -Token references use `{path.to.token}`. Most references should point to primitive values, such as `{colors.primary}`. Component tokens may reference composite typography objects, such as `{typography.label-md}`. - -## Section Order - -Use `##` headings. Sections are optional, but present canonical sections should appear in this order: - -1. `Overview` or `Brand & Style` -2. `Colors` -3. `Typography` -4. `Layout` or `Layout & Spacing` -5. `Elevation & Depth` or `Elevation` -6. `Shapes` -7. `Components` -8. `Do's and Don'ts` - -An optional `#` title may appear before the sections. It is not parsed as a design section. - -## Section Guidance - -`Overview`: describe the product, audience, density, personality, and emotional target. Use specific visual references. Avoid generic adjective lists. - -`Colors`: define roles, not just hex values. Explain where each color appears and where it must not appear. - -`Typography`: define hierarchy, font families, weights, scale, line heights, casing, data/label treatments, and reading-density decisions. - -`Layout`: define grid, max widths, spacing rhythm, density, page gutters, container behavior, and responsive expectations. - -`Elevation & Depth`: define whether hierarchy comes from shadows, borders, tonal layers, contrast, surface color, or no elevation. - -`Shapes`: define radius, border geometry, sharp/soft language, and where exceptions are allowed. - -`Components`: define buttons, inputs, cards, tables, lists, chips, tabs, dialogs, tooltips, and state variants that matter to the product. - -`Do's and Don'ts`: include practical guardrails. Keep them intentional; if the list gets long, strengthen the overview and component guidance. - -## Components - -Known component property tokens: - -- `backgroundColor` -- `textColor` -- `typography` -- `rounded` -- `padding` -- `size` -- `height` -- `width` - -Represent states and variants as related component keys: +This small schema example illustrates references, not a recommended palette: ```yaml +version: alpha +name: Example only +colors: + primary: "#152820" + on-action: "#FFFFFF" +rounded: + control: 0px components: button-primary: backgroundColor: "{colors.primary}" - textColor: "{colors.on-primary}" - rounded: "{rounded.md}" - padding: 12px - button-primary-hover: - backgroundColor: "{colors.primary-hover}" + textColor: "{colors.on-action}" + rounded: "{rounded.control}" ``` -Unknown component properties are allowed but should produce a warning during review. +Choose actual values from the project's design inputs. Define meaningful hover, focus, disabled, or error states where this task needs them; do not fabricate a full component library. -## Consumer Behavior +## Authoring -Preserve unknown section headings. Preserve unknown valid token names. Accept unknown component properties with review warnings. Reject duplicate section headings. Fix broken references. +Start with the product, audience, concrete visual reference, and distinctive layout/hierarchy. Explain scarce accents, typography roles, spacing, shape, elevation, and responsive behavior in terms an implementer can check. Keep prohibitions few and specific. Include exact measurements or token roles when the input supports them; identify unresolved design decisions rather than substituting a generic theme. -## Official CLI +Preserve established custom content when editing. If brand prose and tokens disagree, resolve the intended target using the user's request and source evidence; do not silently choose whichever matches existing code. -Install or run directly: +## CLI -```bash -npm install @google/design.md -npx @google/design.md lint DESIGN.md -``` - -Windows/PowerShell reliable form: +Node 18+ is required. Prefer the project's already pinned compatible package. The commands below were verified against 0.4.0; they run from the target project directory. The explicit `designmd` alias also avoids Windows .md file-association problems. ```powershell -npx -p @google/design.md designmd lint DESIGN.md +npx --yes --package @google/design.md@0.4.0 designmd lint DESIGN.md +npx --yes --package @google/design.md@0.4.0 designmd diff DESIGN.md DESIGN-v2.md +npx --yes --package @google/design.md@0.4.0 designmd export --format css-vars --prefix brand DESIGN.md +npx --yes --package @google/design.md@0.4.0 designmd export --format css-tailwind DESIGN.md +npx --yes --package @google/design.md@0.4.0 designmd export --format json-tailwind DESIGN.md +npx --yes --package @google/design.md@0.4.0 designmd export --format dtcg DESIGN.md ``` -Useful commands: +Inspect output before integrating it. css-vars emits custom properties; css-tailwind targets Tailwind v4, json-tailwind targets Tailwind v3 theme.extend, and dtcg emits design tokens. Exporting does not wire the result into the app. Write files with the project's UTF-8 convention; avoid overwriting a hand-maintained theme through blind redirection. -```powershell -npx -p @google/design.md designmd lint DESIGN.md -npx -p @google/design.md designmd diff DESIGN.md DESIGN-v2.md -npx -p @google/design.md designmd export --format json-tailwind DESIGN.md > tailwind.theme.json -npx -p @google/design.md designmd export --format css-tailwind DESIGN.md > theme.css -npx -p @google/design.md designmd export --format dtcg DESIGN.md > tokens.json -npx -p @google/design.md designmd spec --rules -``` +In 0.4.0, lint exits 1 for errors, 0 otherwise; file-read failures exit 2. Review warnings by their meaning: missing optional groups and custom fields need not be failures. For `missing-primary`, clarify the intended primary role using an existing approved color or token reference when appropriate; do not invent a new hue to silence the warning. Preserve existing semantic names. If the design deliberately has no primary role, explain that decision rather than implying lint guarantees model behavior. -`lint` exits nonzero when errors are present. `diff` exits nonzero when the later file has regressions, such as more errors or warnings. +Export can succeed despite lint errors, so its exit status is not a validation gate. Diff detects token changes and increased error/warning counts; it does not assess changed prose intent or rendered UI. Compare prose and implementation separately. -## Authoring Checklist +Without CLI access, check YAML validity where present, reference resolution, relevant section order, duplicate headings, and documented color-pair contrast. Label this manual validation and state its limits. Use project accessibility checks for the actual rendered states. -- Use a concrete visual reference in `Overview`. -- Include exact values for reusable colors, typography, spacing, radii, and core components. -- Explain scarcity rules for accents, shadows, display type, decorative elements, and motion. -- Use token references for component values instead of duplicating literals. -- Keep prose actionable for future UI generation. -- Validate with the CLI and fix structural errors. +## Primary sources -## Applying Checklist - -- Read `DESIGN.md` before editing UI. -- Bind exact tokens to CSS variables, Tailwind theme entries, framework theme objects, or component constants. -- Use prose for unstated choices such as density, hierarchy, affordance strength, and restraint. -- Avoid introducing style drift. Add new tokens only when the task expands the design system. -- Re-run lint after editing `DESIGN.md`. - -## Source Links - -- Repository: https://github.com/google-labs-code/design.md -- Specification: https://github.com/google-labs-code/design.md/blob/main/docs/spec.md -- Philosophy: https://github.com/google-labs-code/design.md/blob/main/PHILOSOPHY.md +- [Format specification](https://github.com/google-labs-code/design.md/blob/main/docs/spec.md) +- [Design philosophy](https://github.com/google-labs-code/design.md/blob/main/PHILOSOPHY.md) +- [CLI 0.4.0 release](https://github.com/google-labs-code/design.md/releases/tag/0.4.0) +- [Export implementation](https://github.com/google-labs-code/design.md/blob/main/packages/cli/src/commands/export.ts) and [diff implementation](https://github.com/google-labs-code/design.md/blob/main/packages/cli/src/commands/diff.ts) diff --git a/plugins/codex/plugins/docx/.codex-plugin/plugin.json b/plugins/codex/plugins/docx/.codex-plugin/plugin.json index 1489072b..32920469 100644 --- a/plugins/codex/plugins/docx/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/docx/.codex-plugin/plugin.json @@ -1,39 +1,40 @@ { "name": "docx", - "version": "1.0.2", - "description": "可以创建、编辑和分析Word文档,同时支持跟踪修改、添加注释、保持格式不变以及提取文本内容。", + "version": "2.0.0", + "description": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。", "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://github.com/anthropics/skills", + "repository": "https://github.com/anthropics/skills", "license": "Proprietary", "keywords": [ - "eapil", - "codex-skill", - "docx" + "docx", + "documents", + "zh-CN", + "windows" ], "skills": "./skills/", "interface": { "displayName": "docx", - "shortDescription": "可以创建、编辑和分析Word文档,同时支持跟踪修改、添加注释、保持格式不变以及提取文本内容。", - "longDescription": "可以创建、编辑和分析Word文档,同时支持跟踪修改、添加注释、保持格式不变以及提取文本内容。", - "developerName": "EAPIL", + "shortDescription": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。", + "longDescription": "创建、读取和编辑 Word 文档,适配简体中文排版与 Windows。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", "category": "Productivity", "capabilities": [ "Read", "Write" ], "defaultPrompt": [ - "使用 docx 帮我完成这个任务。" + "使用 docx 处理这份中文文档,并检查最终输出。" ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "websiteURL": "https://github.com/anthropics/skills", + "privacyPolicyURL": "https://github.com/anthropics/skills", + "termsOfServiceURL": "https://github.com/anthropics/skills", "brandColor": "#2563EB", "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" } } diff --git a/plugins/codex/plugins/docx/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/docx/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..5acb04ce --- /dev/null +++ b/plugins/codex/plugins/docx/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "docx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "adapter": "claude-skill", + "sourcePath": "skills/docx", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/docx/skills/docx/SKILL.md b/plugins/codex/plugins/docx/skills/docx/SKILL.md index ee7a9092..31e4d64e 100644 --- a/plugins/codex/plugins/docx/skills/docx/SKILL.md +++ b/plugins/codex/plugins/docx/skills/docx/SKILL.md @@ -1,227 +1,38 @@ --- name: docx -description: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files), especially on Windows and in Chinese/CJK language contexts." +description: 创建、读取、编辑和检查 Word 文档或模板(.docx/.dotx),包括中文排版、表格、批注和修订;适用于简体中文与 Windows PowerShell 环境。 license: Proprietary. LICENSE.txt has complete terms --- -# DOCX creation, editing, and analysis +# Word 文档 -## Overview +开始操作前读取 [references/platform.md](references/platform.md),确认 Python、Node、中文字体及需要的转换工具。命令中的脚本路径均相对本 Skill。 -A user may ask you to create, edit, or analyze the contents of a .docx file. A .docx file is essentially a ZIP archive containing XML files and other resources that you can read or edit. You have different tools and workflows available for different tasks. +| 任务 | 选择 | +| --- | --- | +| 新建文档 | `docx`(Node)或 `python-docx`,按所需版式选择 | +| 提取正文或表格 | `python-docx`;需要查看修订时用 Pandoc 或检查 OOXML | +| 编辑复杂模板、批注或修订 | 保留 OOXML 结构,使用随附脚本 | +| .doc 旧文件 | LibreOffice 先转换为 .docx | -## Windows and Chinese-language defaults +## 中文版式 -- Prefer PowerShell-compatible commands and always quote paths: `"C:\path with spaces\file.docx"` -- Before Python text processing on Windows, set UTF-8 mode: `$env:PYTHONUTF8='1'` -- If terminal output shows mojibake for Chinese text, treat it as a console issue first; verify by writing UTF-8 files and reopening them -- For Chinese documents, prefer fonts available on Windows (`Microsoft YaHei`, `SimSun`) and preserve existing East Asian font settings when editing -- Keep language tags consistent (`zh-CN` for Simplified Chinese) to reduce Word fallback-font behavior +未指定页面时,新文档采用 A4;已有文档沿用其页面和样式。正文中文字体设置 East Asian 字体,语言为 `zh-CN`,标题使用真正的标题样式。保持全角标点、编号、金额和原文引文;中文表格不要依赖空格对齐。 -## Workflow Decision Tree +`docx` 创建时注意:表格同时指定列宽和单元格宽度;`ShadingType.CLEAR` 避免黑底;列表用 numbering 配置;`ImageRun` 指定类型;分页符放进 Paragraph。每个逻辑段落独立创建,不用换行模拟段落。需要 API 细节时按需读取 [upstream-guide.md](upstream-guide.md)。 -### Reading/Analyzing Content -Use "Text extraction" or "Raw XML access" sections below +## 编辑与审阅 -### Creating New Document -Use "Creating a new Word document" workflow - -### Editing Existing Document -- **Your own document + simple changes** - Use "Basic OOXML editing" workflow - -- **Someone else's document** - Use **"Redlining workflow"** (recommended default) - -- **Legal, academic, business, or government docs** - Use **"Redlining workflow"** (required) - -## Reading and analyzing content - -### Text extraction -If you just need to read the text contents of a document, you should convert the document to markdown using pandoc. Pandoc provides excellent support for preserving document structure and can show tracked changes: - -```bash -# Convert document to markdown with tracked changes -pandoc --track-changes=all path-to-file.docx -o output.md -# Options: --track-changes=accept/reject/all -``` - -Windows PowerShell example: +先确认要清稿、批注还是保留修订;不要因文档类型自动强制红线。原有修订与批注保持不变,除非用户要求接受、拒绝或删除。 ```powershell -pandoc --track-changes=all "C:\docs\contract-zh.docx" -o "C:\docs\output.md" +python "$skill\scripts\office_package.py" unpack ".\输入.docx" ".\unpacked" +python "$skill\scripts\merge_runs.py" ".\unpacked" +# 按需编辑 word/document.xml,保持命名空间与节点顺序 +python "$skill\scripts\office_package.py" pack ".\unpacked" ".\输出.docx" +python "$skill\scripts\office\validate.py" ".\输出.docx" --original ".\输入.docx" ``` -### Raw XML access -You need raw XML access for: comments, complex formatting, document structure, embedded media, and metadata. For any of these features, you'll need to unpack a document and read its raw XML contents. +`scripts/comment.py` 支持为文档添加批注;输出的定位节点需放到对应正文位置才能显示。`scripts/accept_changes.py` 仅在用户要求接受全部修订时使用,输出新文件;超时、文件未保存或仍有修订均算失败。 -#### Unpacking a file -`python ooxml/scripts/unpack.py ` - -#### Key file structures -* `word/document.xml` - Main document contents -* `word/comments.xml` - Comments referenced in document.xml -* `word/media/` - Embedded images and media files -* Tracked changes use `` (insertions) and `` (deletions) tags - -## Creating a new Word document - -When creating a new Word document from scratch, use **docx-js**, which allows you to create Word documents using JavaScript/TypeScript. - -For Chinese/CJK content in new documents: -- Use Unicode strings directly (UTF-8 source files) -- Prefer explicit CJK-capable fonts in generated runs and styles -- Keep punctuation and full-width characters unchanged unless user requests normalization - -### Workflow -1. **MANDATORY - READ ENTIRE FILE**: Read [`docx-js.md`](docx-js.md) (~500 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for detailed syntax, critical formatting rules, and best practices before proceeding with document creation. -2. Create a JavaScript/TypeScript file using Document, Paragraph, TextRun components (You can assume all dependencies are installed, but if not, refer to the dependencies section below) -3. Export as .docx using Packer.toBuffer() - -## Editing an existing Word document - -When editing an existing Word document, use the **Document library** (a Python library for OOXML manipulation). The library automatically handles infrastructure setup and provides methods for document manipulation. For complex scenarios, you can access the underlying DOM directly through the library. - -### Workflow -1. **MANDATORY - READ ENTIRE FILE**: Read [`ooxml.md`](ooxml.md) (~600 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for the Document library API and XML patterns for directly editing document files. -2. Unpack the document: `python ooxml/scripts/unpack.py ` -3. Create and run a Python script using the Document library (see "Document Library" section in ooxml.md) -4. Pack the final document: `python ooxml/scripts/pack.py ` - -The Document library provides both high-level methods for common operations and direct DOM access for complex scenarios. - -## Redlining workflow for document review - -This workflow allows you to plan comprehensive tracked changes using markdown before implementing them in OOXML. **CRITICAL**: For complete tracked changes, you must implement ALL changes systematically. - -**Batching Strategy**: Group related changes into batches of 3-10 changes. This makes debugging manageable while maintaining efficiency. Test each batch before moving to the next. - -**Principle: Minimal, Precise Edits** -When implementing tracked changes, only mark text that actually changes. Repeating unchanged text makes edits harder to review and appears unprofessional. Break replacements into: [unchanged text] + [deletion] + [insertion] + [unchanged text]. Preserve the original run's RSID for unchanged text by extracting the `` element from the original and reusing it. - -**Chinese text editing note**: -- Do not split a Chinese word/phrase across insertion/deletion boundaries unless required by the requested edit -- Preserve original full-width punctuation and spacing style in unchanged runs -- Keep original East Asian font tags in unchanged runs to avoid visual jumps after redlining - -Example - Changing "30 days" to "60 days" in a sentence: -```python -# BAD - Replaces entire sentence -'The term is 30 days.The term is 60 days.' - -# GOOD - Only marks what changed, preserves original for unchanged text -'The term is 3060 days.' -``` - -### Tracked changes workflow - -1. **Get markdown representation**: Convert document to markdown with tracked changes preserved: - ```bash - pandoc --track-changes=all path-to-file.docx -o current.md - ``` - -2. **Identify and group changes**: Review the document and identify ALL changes needed, organizing them into logical batches: - - **Location methods** (for finding changes in XML): - - Section/heading numbers (e.g., "Section 3.2", "Article IV") - - Paragraph identifiers if numbered - - Grep patterns with unique surrounding text - - Document structure (e.g., "first paragraph", "signature block") - - **DO NOT use markdown line numbers** - they don't map to XML structure - - **Batch organization** (group 3-10 related changes per batch): - - By section: "Batch 1: Section 2 amendments", "Batch 2: Section 5 updates" - - By type: "Batch 1: Date corrections", "Batch 2: Party name changes" - - By complexity: Start with simple text replacements, then tackle complex structural changes - - Sequential: "Batch 1: Pages 1-3", "Batch 2: Pages 4-6" - -3. **Read documentation and unpack**: - - **MANDATORY - READ ENTIRE FILE**: Read [`ooxml.md`](ooxml.md) (~600 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Pay special attention to the "Document Library" and "Tracked Change Patterns" sections. - - **Unpack the document**: `python ooxml/scripts/unpack.py ` - - **Note the suggested RSID**: The unpack script will suggest an RSID to use for your tracked changes. Copy this RSID for use in step 4b. - -4. **Implement changes in batches**: Group changes logically (by section, by type, or by proximity) and implement them together in a single script. This approach: - - Makes debugging easier (smaller batch = easier to isolate errors) - - Allows incremental progress - - Maintains efficiency (batch size of 3-10 changes works well) - - **Suggested batch groupings:** - - By document section (e.g., "Section 3 changes", "Definitions", "Termination clause") - - By change type (e.g., "Date changes", "Party name updates", "Legal term replacements") - - By proximity (e.g., "Changes on pages 1-3", "Changes in first half of document") - - For each batch of related changes: - - **a. Map text to XML**: Grep for text in `word/document.xml` to verify how text is split across `` elements. - - **b. Create and run script**: Use `get_node` to find nodes, implement changes, then `doc.save()`. See **"Document Library"** section in ooxml.md for patterns. - - **Note**: Always grep `word/document.xml` immediately before writing a script to get current line numbers and verify text content. Line numbers change after each script run. - -5. **Pack the document**: After all batches are complete, convert the unpacked directory back to .docx: - ```bash - python ooxml/scripts/pack.py unpacked reviewed-document.docx - ``` - -6. **Final verification**: Do a comprehensive check of the complete document: - - Convert final document to markdown: - ```bash - pandoc --track-changes=all reviewed-document.docx -o verification.md - ``` - - Verify ALL changes were applied correctly: - ```bash - grep "original phrase" verification.md # Should NOT find it - grep "replacement phrase" verification.md # Should find it - ``` - - Check that no unintended changes were introduced - - -## Converting Documents to Images - -To visually analyze Word documents, convert them to images using a two-step process: - -1. **Convert DOCX to PDF**: - ```bash - soffice --headless --convert-to pdf document.docx - ``` - -2. **Convert PDF pages to JPEG images**: - ```bash - pdftoppm -jpeg -r 150 document.pdf page - ``` - This creates files like `page-1.jpg`, `page-2.jpg`, etc. - -Options: -- `-r 150`: Sets resolution to 150 DPI (adjust for quality/size balance) -- `-jpeg`: Output JPEG format (use `-png` for PNG if preferred) -- `-f N`: First page to convert (e.g., `-f 2` starts from page 2) -- `-l N`: Last page to convert (e.g., `-l 5` stops at page 5) -- `page`: Prefix for output files - -Example for specific range: -```bash -pdftoppm -jpeg -r 150 -f 2 -l 5 document.pdf page # Converts only pages 2-5 -``` - -## Code Style Guidelines -**IMPORTANT**: When generating code for DOCX operations: -- Write concise code -- Avoid verbose variable names and redundant operations -- Avoid unnecessary print statements - -## Dependencies - -Required dependencies (install if not available): - -- **pandoc**: - - Windows: `winget install --id JohnMacFarlane.Pandoc -e` - - Linux: `sudo apt-get install pandoc` -- **docx (npm package)**: `npm install -g docx` (for creating new documents) -- **LibreOffice**: - - Windows: `winget install --id TheDocumentFoundation.LibreOffice -e` - - Linux: `sudo apt-get install libreoffice` -- **Poppler (pdftoppm)**: - - Windows: `winget install --id oschwartz10612.Poppler -e` - - Linux: `sudo apt-get install poppler-utils` -- **defusedxml**: `pip install defusedxml` (for secure XML parsing) \ No newline at end of file +最终转换为 PDF,用 `scripts/render_pdf.py` 生成预览并查看中文字体、表格、页眉页脚、分页和页码。保留可编辑 Word 文件与用户需要的最终副本。 diff --git a/plugins/codex/plugins/docx/skills/docx/docx-js.md b/plugins/codex/plugins/docx/skills/docx/docx-js.md deleted file mode 100644 index 27215419..00000000 --- a/plugins/codex/plugins/docx/skills/docx/docx-js.md +++ /dev/null @@ -1,357 +0,0 @@ -# DOCX Library Tutorial - -Generate .docx files with JavaScript/TypeScript. - -**Important: Read this entire document before starting.** Critical formatting rules and common pitfalls are covered throughout - skipping sections may result in corrupted files or rendering issues. - -## Setup -Assumes docx is already installed globally -If not installed: `npm install -g docx` - -## Windows and Chinese-language defaults -- Prefer UTF-8 source files (`.js`/`.ts`) for Chinese text -- On Windows PowerShell, set UTF-8 mode before generating files: `$env:PYTHONUTF8='1'` -- Prefer CJK-capable fonts for Chinese output (`Microsoft YaHei`, `SimSun`, `Noto Sans CJK SC`) -- Keep path arguments quoted on Windows (for example: `"C:\docs\output.docx"`) - -```javascript -const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, Media, - Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink, - InternalHyperlink, TableOfContents, HeadingLevel, BorderStyle, WidthType, TabStopType, - TabStopPosition, UnderlineType, ShadingType, VerticalAlign, SymbolRun, PageNumber, - FootnoteReferenceRun, Footnote, PageBreak } = require('docx'); - -// Create & Save -const doc = new Document({ sections: [{ children: [/* content */] }] }); -Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer)); // Node.js -Packer.toBlob(doc).then(blob => { /* download logic */ }); // Browser -``` - -## Text & Formatting -```javascript -// IMPORTANT: Never use \n for line breaks - always use separate Paragraph elements -// ❌ WRONG: new TextRun("Line 1\nLine 2") -// ✅ CORRECT: new Paragraph({ children: [new TextRun("Line 1")] }), new Paragraph({ children: [new TextRun("Line 2")] }) - -// Basic text with all formatting options -new Paragraph({ - alignment: AlignmentType.CENTER, - spacing: { before: 200, after: 200 }, - indent: { left: 720, right: 720 }, - children: [ - new TextRun({ text: "Bold", bold: true }), - new TextRun({ text: "Italic", italics: true }), - new TextRun({ text: "Underlined", underline: { type: UnderlineType.DOUBLE, color: "FF0000" } }), - new TextRun({ text: "Colored", color: "FF0000", size: 28, font: "Arial" }), // Arial default - new TextRun({ text: "Highlighted", highlight: "yellow" }), - new TextRun({ text: "Strikethrough", strike: true }), - new TextRun({ text: "x2", superScript: true }), - new TextRun({ text: "H2O", subScript: true }), - new TextRun({ text: "SMALL CAPS", smallCaps: true }), - new SymbolRun({ char: "2022", font: "Symbol" }), // Bullet • - new SymbolRun({ char: "00A9", font: "Arial" }) // Copyright © - Arial for symbols - ] -}) -``` - -## Styles & Professional Formatting - -```javascript -const doc = new Document({ - styles: { - default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default - paragraphStyles: [ - // Document title style - override built-in Title style - { id: "Title", name: "Title", basedOn: "Normal", - run: { size: 56, bold: true, color: "000000", font: "Arial" }, - paragraph: { spacing: { before: 240, after: 120 }, alignment: AlignmentType.CENTER } }, - // IMPORTANT: Override built-in heading styles by using their exact IDs - { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, - run: { size: 32, bold: true, color: "000000", font: "Arial" }, // 16pt - paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // Required for TOC - { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, - run: { size: 28, bold: true, color: "000000", font: "Arial" }, // 14pt - paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } }, - // Custom styles use your own IDs - { id: "myStyle", name: "My Style", basedOn: "Normal", - run: { size: 28, bold: true, color: "000000" }, - paragraph: { spacing: { after: 120 }, alignment: AlignmentType.CENTER } } - ], - characterStyles: [{ id: "myCharStyle", name: "My Char Style", - run: { color: "FF0000", bold: true, underline: { type: UnderlineType.SINGLE } } }] - }, - sections: [{ - properties: { page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } }, - children: [ - new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun("Document Title")] }), // Uses overridden Title style - new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Heading 1")] }), // Uses overridden Heading1 style - new Paragraph({ style: "myStyle", children: [new TextRun("Custom paragraph style")] }), - new Paragraph({ children: [ - new TextRun("Normal with "), - new TextRun({ text: "custom char style", style: "myCharStyle" }) - ]}) - ] - }] -}); -``` - -**Professional Font Combinations:** -- **Arial (Headers) + Arial (Body)** - Good default for English-heavy documents -- **Times New Roman (Headers) + Arial (Body)** - Classic serif headers with modern sans-serif body -- **Microsoft YaHei (Headers) + Microsoft YaHei (Body)** - Recommended for Chinese-heavy documents on Windows -- **Noto Sans CJK SC (Headers) + Noto Sans CJK SC (Body)** - Better cross-platform CJK consistency - -**Key Styling Principles:** -- **Override built-in styles**: Use exact IDs like "Heading1", "Heading2", "Heading3" to override Word's built-in heading styles -- **HeadingLevel constants**: `HeadingLevel.HEADING_1` uses "Heading1" style, `HeadingLevel.HEADING_2` uses "Heading2" style, etc. -- **Include outlineLevel**: Set `outlineLevel: 0` for H1, `outlineLevel: 1` for H2, etc. to ensure TOC works correctly -- **Use custom styles** instead of inline formatting for consistency -- **Set a default font** using `styles.default.document.run.font` - for Chinese/CJK output, use a CJK-capable font -- **Establish visual hierarchy** with different font sizes (titles > headers > body) -- **Add proper spacing** with `before` and `after` paragraph spacing -- **Use colors sparingly**: Default to black (000000) and shades of gray for titles and headings (heading 1, heading 2, etc.) -- **Set consistent margins** (1440 = 1 inch is standard) - - -## Lists (ALWAYS USE PROPER LISTS - NEVER USE UNICODE BULLETS) -```javascript -// Bullets - ALWAYS use the numbering config, NOT unicode symbols -// CRITICAL: Use LevelFormat.BULLET constant, NOT the string "bullet" -const doc = new Document({ - numbering: { - config: [ - { reference: "bullet-list", - levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT, - style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, - { reference: "first-numbered-list", - levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, - style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, - { reference: "second-numbered-list", // Different reference = restarts at 1 - levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, - style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] } - ] - }, - sections: [{ - children: [ - // Bullet list items - new Paragraph({ numbering: { reference: "bullet-list", level: 0 }, - children: [new TextRun("First bullet point")] }), - new Paragraph({ numbering: { reference: "bullet-list", level: 0 }, - children: [new TextRun("Second bullet point")] }), - // Numbered list items - new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 }, - children: [new TextRun("First numbered item")] }), - new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 }, - children: [new TextRun("Second numbered item")] }), - // ⚠️ CRITICAL: Different reference = INDEPENDENT list that restarts at 1 - // Same reference = CONTINUES previous numbering - new Paragraph({ numbering: { reference: "second-numbered-list", level: 0 }, - children: [new TextRun("Starts at 1 again (because different reference)")] }) - ] - }] -}); - -// ⚠️ CRITICAL NUMBERING RULE: Each reference creates an INDEPENDENT numbered list -// - Same reference = continues numbering (1, 2, 3... then 4, 5, 6...) -// - Different reference = restarts at 1 (1, 2, 3... then 1, 2, 3...) -// Use unique reference names for each separate numbered section! - -// ⚠️ CRITICAL: NEVER use unicode bullets - they create fake lists that don't work properly -// new TextRun("• Item") // WRONG -// new SymbolRun({ char: "2022" }) // WRONG -// ✅ ALWAYS use numbering config with LevelFormat.BULLET for real Word lists -``` - -## Tables -```javascript -// Complete table with margins, borders, headers, and bullet points -const tableBorder = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }; -const cellBorders = { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder }; - -new Table({ - columnWidths: [4680, 4680], // ⚠️ CRITICAL: Set column widths at table level - values in DXA (twentieths of a point) - margins: { top: 100, bottom: 100, left: 180, right: 180 }, // Set once for all cells - rows: [ - new TableRow({ - tableHeader: true, - children: [ - new TableCell({ - borders: cellBorders, - width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell - // ⚠️ CRITICAL: Always use ShadingType.CLEAR to prevent black backgrounds in Word. - shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, - verticalAlign: VerticalAlign.CENTER, - children: [new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: "Header", bold: true, size: 22 })] - })] - }), - new TableCell({ - borders: cellBorders, - width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell - shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, - children: [new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: "Bullet Points", bold: true, size: 22 })] - })] - }) - ] - }), - new TableRow({ - children: [ - new TableCell({ - borders: cellBorders, - width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell - children: [new Paragraph({ children: [new TextRun("Regular data")] })] - }), - new TableCell({ - borders: cellBorders, - width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell - children: [ - new Paragraph({ - numbering: { reference: "bullet-list", level: 0 }, - children: [new TextRun("First bullet point")] - }), - new Paragraph({ - numbering: { reference: "bullet-list", level: 0 }, - children: [new TextRun("Second bullet point")] - }) - ] - }) - ] - }) - ] -}) -``` - -**IMPORTANT: Table Width & Borders** -- Use BOTH `columnWidths: [width1, width2, ...]` array AND `width: { size: X, type: WidthType.DXA }` on each cell -- Values in DXA (twentieths of a point): 1440 = 1 inch, Letter usable width = 9360 DXA (with 1" margins) -- Apply borders to individual `TableCell` elements, NOT the `Table` itself - -**Precomputed Column Widths (Letter size with 1" margins = 9360 DXA total):** -- **2 columns:** `columnWidths: [4680, 4680]` (equal width) -- **3 columns:** `columnWidths: [3120, 3120, 3120]` (equal width) - -## Links & Navigation -```javascript -// TOC (requires headings) - CRITICAL: Use HeadingLevel only, NOT custom styles -// ❌ WRONG: new Paragraph({ heading: HeadingLevel.HEADING_1, style: "customHeader", children: [new TextRun("Title")] }) -// ✅ CORRECT: new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }) -new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" }), - -// External link -new Paragraph({ - children: [new ExternalHyperlink({ - children: [new TextRun({ text: "Google", style: "Hyperlink" })], - link: "https://www.google.com" - })] -}), - -// Internal link & bookmark -new Paragraph({ - children: [new InternalHyperlink({ - children: [new TextRun({ text: "Go to Section", style: "Hyperlink" })], - anchor: "section1" - })] -}), -new Paragraph({ - children: [new TextRun("Section Content")], - bookmark: { id: "section1", name: "section1" } -}), -``` - -## Images & Media -```javascript -// Basic image with sizing & positioning -// CRITICAL: Always specify 'type' parameter - it's REQUIRED for ImageRun -new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new ImageRun({ - type: "png", // NEW REQUIREMENT: Must specify image type (png, jpg, jpeg, gif, bmp, svg) - data: fs.readFileSync("image.png"), - transformation: { width: 200, height: 150, rotation: 0 }, // rotation in degrees - altText: { title: "Logo", description: "Company logo", name: "Name" } // IMPORTANT: All three fields are required - })] -}) -``` - -## Page Breaks -```javascript -// Manual page break -new Paragraph({ children: [new PageBreak()] }), - -// Page break before paragraph -new Paragraph({ - pageBreakBefore: true, - children: [new TextRun("This starts on a new page")] -}) - -// ⚠️ CRITICAL: NEVER use PageBreak standalone - it will create invalid XML that Word cannot open -// ❌ WRONG: new PageBreak() -// ✅ CORRECT: new Paragraph({ children: [new PageBreak()] }) -``` - -## Headers/Footers & Page Setup -```javascript -const doc = new Document({ - sections: [{ - properties: { - page: { - margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1440 = 1 inch - size: { orientation: PageOrientation.LANDSCAPE }, - pageNumbers: { start: 1, formatType: "decimal" } // "upperRoman", "lowerRoman", "upperLetter", "lowerLetter" - } - }, - headers: { - default: new Header({ children: [new Paragraph({ - alignment: AlignmentType.RIGHT, - children: [new TextRun("Header Text")] - })] }) - }, - footers: { - default: new Footer({ children: [new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(" of "), new TextRun({ children: [PageNumber.TOTAL_PAGES] })] - })] }) - }, - children: [/* content */] - }] -}); -``` - -## Tabs -```javascript -new Paragraph({ - tabStops: [ - { type: TabStopType.LEFT, position: TabStopPosition.MAX / 4 }, - { type: TabStopType.CENTER, position: TabStopPosition.MAX / 2 }, - { type: TabStopType.RIGHT, position: TabStopPosition.MAX * 3 / 4 } - ], - children: [new TextRun("Left\tCenter\tRight")] -}) -``` - -## Constants & Quick Reference -- **Underlines:** `SINGLE`, `DOUBLE`, `WAVY`, `DASH` -- **Borders:** `SINGLE`, `DOUBLE`, `DASHED`, `DOTTED` -- **Numbering:** `DECIMAL` (1,2,3), `UPPER_ROMAN` (I,II,III), `LOWER_LETTER` (a,b,c) -- **Tabs:** `LEFT`, `CENTER`, `RIGHT`, `DECIMAL` -- **Symbols:** `"2022"` (•), `"00A9"` (©), `"00AE"` (®), `"2122"` (™), `"00B0"` (°), `"F070"` (✓), `"F0FC"` (✗) - -## Critical Issues & Common Mistakes -- **CRITICAL: PageBreak must ALWAYS be inside a Paragraph** - standalone PageBreak creates invalid XML that Word cannot open -- **ALWAYS use ShadingType.CLEAR for table cell shading** - Never use ShadingType.SOLID (causes black background). -- Measurements in DXA (1440 = 1 inch) | Each table cell needs ≥1 Paragraph | TOC requires HeadingLevel styles only -- **ALWAYS use custom styles** for professional appearance and proper visual hierarchy -- **ALWAYS set a default font** using `styles.default.document.run.font` (English: Arial/Calibri; Chinese: Microsoft YaHei/SimSun/Noto Sans CJK SC) -- **ALWAYS use columnWidths array for tables** + individual cell widths for compatibility -- **NEVER use unicode symbols for bullets** - always use proper numbering configuration with `LevelFormat.BULLET` constant (NOT the string "bullet") -- **NEVER use \n for line breaks anywhere** - always use separate Paragraph elements for each line -- **ALWAYS use TextRun objects within Paragraph children** - never use text property directly on Paragraph -- **CRITICAL for images**: ImageRun REQUIRES `type` parameter - always specify "png", "jpg", "jpeg", "gif", "bmp", or "svg" -- **CRITICAL for bullets**: Must use `LevelFormat.BULLET` constant, not string "bullet", and include `text: "•"` for the bullet character -- **CRITICAL for numbering**: Each numbering reference creates an INDEPENDENT list. Same reference = continues numbering (1,2,3 then 4,5,6). Different reference = restarts at 1 (1,2,3 then 1,2,3). Use unique reference names for each separate numbered section! -- **CRITICAL for TOC**: When using TableOfContents, headings must use HeadingLevel ONLY - do NOT add custom styles to heading paragraphs or TOC will break -- **Tables**: Set `columnWidths` array + individual cell widths, apply borders to cells not table -- **Set table margins at TABLE level** for consistent cell padding (avoids repetition per cell) \ No newline at end of file diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml.md b/plugins/codex/plugins/docx/skills/docx/ooxml.md deleted file mode 100644 index 9f9e65ae..00000000 --- a/plugins/codex/plugins/docx/skills/docx/ooxml.md +++ /dev/null @@ -1,635 +0,0 @@ -# Office Open XML Technical Reference - -**Important: Read this entire document before starting.** This document covers: -- [Technical Guidelines](#technical-guidelines) - Schema compliance rules and validation requirements -- [Document Content Patterns](#document-content-patterns) - XML patterns for headings, lists, tables, formatting, etc. -- [Document Library (Python)](#document-library-python) - Recommended approach for OOXML manipulation with automatic infrastructure setup -- [Tracked Changes (Redlining)](#tracked-changes-redlining) - XML patterns for implementing tracked changes - -## Technical Guidelines - -### Schema Compliance -- **Element ordering in ``**: ``, ``, ``, ``, `` -- **Whitespace**: Add `xml:space='preserve'` to `` elements with leading/trailing spaces -- **Unicode**: Escape characters in ASCII content: `"` becomes `“` - - **Character encoding reference**: Curly quotes `""` become `“”`, apostrophe `'` becomes `’`, em-dash `—` becomes `—` -- **Tracked changes**: Use `` and `` tags with `w:author="Claude"` outside `` elements - - **Critical**: `` closes with ``, `` closes with `` - never mix - - **RSIDs must be 8-digit hex**: Use values like `00AB1234` (only 0-9, A-F characters) - - **trackRevisions placement**: Add `` after `` in settings.xml -- **Images**: Add to `word/media/`, reference in `document.xml`, set dimensions to prevent overflow - -### Windows and Chinese-language defaults -- Prefer quoted Windows paths (for example: `"C:\docs\unpacked"`) -- Before Python XML editing on Windows, set UTF-8 mode: `$env:PYTHONUTF8='1'` -- Preserve East Asian font tags (`w:eastAsia`) in unchanged runs when editing Chinese text -- For mixed English/Chinese runs, keep both `w:ascii` and `w:eastAsia` to avoid fallback-font shifts - -## Document Content Patterns - -### Basic Structure -```xml - - Text content - -``` - -### Headings and Styles -```xml - - - - - - Document Title - - - - - Section Heading - -``` - -### Text Formatting -```xml - -Bold - -Italic - -Underlined - -Highlighted -``` - -### Lists -```xml - - - - - - - - First item - - - - - - - - - - New list item 1 - - - - - - - - - - - Bullet item - -``` - -### Tables -```xml - - - - - - - - - - - - Cell 1 - - - - Cell 2 - - - -``` - -### Layout -```xml - - - - - - - - - - - - New Section Title - - - - - - - - - - Centered text - - - - - - - - Monospace text - - - - - - - This text is Courier New - - and this text uses default font - - - - - - - - - Mixed English and Chinese text - - -``` - -## File Updates - -When adding content, update these files: - -**`word/_rels/document.xml.rels`:** -```xml - - -``` - -**`[Content_Types].xml`:** -```xml - - -``` - -### Images -**CRITICAL**: Calculate dimensions to prevent page overflow and maintain aspect ratio. - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -### Links (Hyperlinks) - -**IMPORTANT**: All hyperlinks (both internal and external) require the Hyperlink style to be defined in styles.xml. Without this style, links will look like regular text instead of blue underlined clickable links. - -**External Links:** -```xml - - - - - Link Text - - - - - -``` - -**Internal Links:** - -```xml - - - - - Link Text - - - - - -Target content - -``` - -**Hyperlink Style (required in styles.xml):** -```xml - - - - - - - - - - -``` - -## Document Library (Python) - -Use the Document class from `scripts/document.py` for all tracked changes and comments. It automatically handles infrastructure setup (people.xml, RSIDs, settings.xml, comment files, relationships, content types). Only use direct XML manipulation for complex scenarios not supported by the library. - -**Working with Unicode and Entities:** -- **Searching**: Both entity notation and Unicode characters work - `contains="“Company"` and `contains="\u201cCompany"` find the same text -- **Replacing**: Use either entities (`“`) or Unicode (`\u201c`) - both work and will be converted appropriately based on the file's encoding (ascii → entities, utf-8 → Unicode) - -### Initialization - -**Find the docx skill root** (directory containing `scripts/` and `ooxml/`): -```bash -# Linux/macOS example -find /mnt/skills -name "document.py" -path "*/docx/scripts/*" 2>/dev/null | head -1 -``` - -```powershell -# Windows PowerShell example -Get-ChildItem -Path "$env:USERPROFILE" -Recurse -Filter document.py -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -like "*\docx\scripts\document.py" } | - Select-Object -First 1 -ExpandProperty FullName -``` - -**Run your script with PYTHONPATH** set to the docx skill root: -```bash -PYTHONPATH=/mnt/skills/docx python your_script.py -``` - -```powershell -$env:PYTHONPATH = "C:\path\to\docx" -py -3 .\your_script.py -``` - -**In your script**, import from the skill root: -```python -from scripts.document import Document, DocxXMLEditor - -# Basic initialization (automatically creates temp copy and sets up infrastructure) -doc = Document('unpacked') - -# Customize author and initials -doc = Document('unpacked', author="John Doe", initials="JD") - -# Enable track revisions mode -doc = Document('unpacked', track_revisions=True) - -# Specify custom RSID (auto-generated if not provided) -doc = Document('unpacked', rsid="07DC5ECB") -``` - -### Creating Tracked Changes - -**CRITICAL**: Only mark text that actually changes. Keep ALL unchanged text outside ``/`` tags. Marking unchanged text makes edits unprofessional and harder to review. - -**Attribute Handling**: The Document class auto-injects attributes (w:id, w:date, w:rsidR, w:rsidDel, w16du:dateUtc, xml:space) into new elements. When preserving unchanged text from the original document, copy the original `` element with its existing attributes to maintain document integrity. - -**Method Selection Guide**: -- **Adding your own changes to regular text**: Use `replace_node()` with ``/`` tags, or `suggest_deletion()` for removing entire `` or `` elements -- **Partially modifying another author's tracked change**: Use `replace_node()` to nest your changes inside their ``/`` -- **Completely rejecting another author's insertion**: Use `revert_insertion()` on the `` element (NOT `suggest_deletion()`) -- **Completely rejecting another author's deletion**: Use `revert_deletion()` on the `` element to restore deleted content using tracked changes - -```python -# Minimal edit - change one word: "The report is monthly" → "The report is quarterly" -# Original: The report is monthly -node = doc["word/document.xml"].get_node(tag="w:r", contains="The report is monthly") -rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" -replacement = f'{rpr}The report is {rpr}monthly{rpr}quarterly' -doc["word/document.xml"].replace_node(node, replacement) - -# Minimal edit - change number: "within 30 days" → "within 45 days" -# Original: within 30 days -node = doc["word/document.xml"].get_node(tag="w:r", contains="within 30 days") -rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" -replacement = f'{rpr}within {rpr}30{rpr}45{rpr} days' -doc["word/document.xml"].replace_node(node, replacement) - -# Complete replacement - preserve formatting even when replacing all text -node = doc["word/document.xml"].get_node(tag="w:r", contains="apple") -rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" -replacement = f'{rpr}apple{rpr}banana orange' -doc["word/document.xml"].replace_node(node, replacement) - -# Insert new content (no attributes needed - auto-injected) -node = doc["word/document.xml"].get_node(tag="w:r", contains="existing text") -doc["word/document.xml"].insert_after(node, 'new text') - -# Partially delete another author's insertion -# Original: quarterly financial report -# Goal: Delete only "financial" to make it "quarterly report" -node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) -# IMPORTANT: Preserve w:author="Jane Smith" on the outer to maintain authorship -replacement = ''' - quarterly - financial - report -''' -doc["word/document.xml"].replace_node(node, replacement) - -# Change part of another author's insertion -# Original: in silence, safe and sound -# Goal: Change "safe and sound" to "soft and unbound" -node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "8"}) -replacement = f''' - in silence, - - - soft and unbound - - - safe and sound -''' -doc["word/document.xml"].replace_node(node, replacement) - -# Delete entire run (use only when deleting all content; use replace_node for partial deletions) -node = doc["word/document.xml"].get_node(tag="w:r", contains="text to delete") -doc["word/document.xml"].suggest_deletion(node) - -# Delete entire paragraph (in-place, handles both regular and numbered list paragraphs) -para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph to delete") -doc["word/document.xml"].suggest_deletion(para) - -# Add new numbered list item -target_para = doc["word/document.xml"].get_node(tag="w:p", contains="existing list item") -pPr = tags[0].toxml() if (tags := target_para.getElementsByTagName("w:pPr")) else "" -new_item = f'{pPr}New item' -tracked_para = DocxXMLEditor.suggest_paragraph(new_item) -doc["word/document.xml"].insert_after(target_para, tracked_para) -# Optional: add spacing paragraph before content for better visual separation -# spacing = DocxXMLEditor.suggest_paragraph('') -# doc["word/document.xml"].insert_after(target_para, spacing + tracked_para) -``` - -### Adding Comments - -```python -# Add comment spanning two existing tracked changes -# Note: w:id is auto-generated. Only search by w:id if you know it from XML inspection -start_node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) -end_node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "2"}) -doc.add_comment(start=start_node, end=end_node, text="Explanation of this change") - -# Add comment on a paragraph -para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") -doc.add_comment(start=para, end=para, text="Comment on this paragraph") - -# Add comment on newly created tracked change -# First create the tracked change -node = doc["word/document.xml"].get_node(tag="w:r", contains="old") -new_nodes = doc["word/document.xml"].replace_node( - node, - 'oldnew' -) -# Then add comment on the newly created elements -# new_nodes[0] is the , new_nodes[1] is the -doc.add_comment(start=new_nodes[0], end=new_nodes[1], text="Changed old to new per requirements") - -# Reply to existing comment -doc.reply_to_comment(parent_comment_id=0, text="I agree with this change") -``` - -### Rejecting Tracked Changes - -**IMPORTANT**: Use `revert_insertion()` to reject insertions and `revert_deletion()` to restore deletions using tracked changes. Use `suggest_deletion()` only for regular unmarked content. - -```python -# Reject insertion (wraps it in deletion) -# Use this when another author inserted text that you want to delete -ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) -nodes = doc["word/document.xml"].revert_insertion(ins) # Returns [ins] - -# Reject deletion (creates insertion to restore deleted content) -# Use this when another author deleted text that you want to restore -del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"}) -nodes = doc["word/document.xml"].revert_deletion(del_elem) # Returns [del_elem, new_ins] - -# Reject all insertions in a paragraph -para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") -nodes = doc["word/document.xml"].revert_insertion(para) # Returns [para] - -# Reject all deletions in a paragraph -para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") -nodes = doc["word/document.xml"].revert_deletion(para) # Returns [para] -``` - -### Inserting Images - -**CRITICAL**: The Document class works with a temporary copy at `doc.unpacked_path`. Always copy images to this temp directory, not the original unpacked folder. - -```python -from PIL import Image -import shutil, os - -# Initialize document first -doc = Document('unpacked') - -# Copy image and calculate full-width dimensions with aspect ratio -media_dir = os.path.join(doc.unpacked_path, 'word/media') -os.makedirs(media_dir, exist_ok=True) -shutil.copy('image.png', os.path.join(media_dir, 'image1.png')) -img = Image.open(os.path.join(media_dir, 'image1.png')) -width_emus = int(6.5 * 914400) # 6.5" usable width, 914400 EMUs/inch -height_emus = int(width_emus * img.size[1] / img.size[0]) - -# Add relationship and content type -rels_editor = doc['word/_rels/document.xml.rels'] -next_rid = rels_editor.get_next_rid() -rels_editor.append_to(rels_editor.dom.documentElement, - f'') -doc['[Content_Types].xml'].append_to(doc['[Content_Types].xml'].dom.documentElement, - '') - -# Insert image -node = doc["word/document.xml"].get_node(tag="w:p", line_number=100) -doc["word/document.xml"].insert_after(node, f''' - - - - - - - - - - - - - - - - - -''') -``` - -### Getting Nodes - -```python -# By text content -node = doc["word/document.xml"].get_node(tag="w:p", contains="specific text") - -# By line range -para = doc["word/document.xml"].get_node(tag="w:p", line_number=range(100, 150)) - -# By attributes -node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) - -# By exact line number (must be line number where tag opens) -para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) - -# Combine filters -node = doc["word/document.xml"].get_node(tag="w:r", line_number=range(40, 60), contains="text") - -# Disambiguate when text appears multiple times - add line_number range -node = doc["word/document.xml"].get_node(tag="w:r", contains="Section", line_number=range(2400, 2500)) -``` - -### Saving - -```python -# Save with automatic validation (copies back to original directory) -doc.save() # Validates by default, raises error if validation fails - -# Save to different location -doc.save('modified-unpacked') - -# Skip validation (debugging only - needing this in production indicates XML issues) -doc.save(validate=False) -``` - -### Direct DOM Manipulation - -For complex scenarios not covered by the library: - -```python -# Access any XML file -editor = doc["word/document.xml"] -editor = doc["word/comments.xml"] - -# Direct DOM access (defusedxml.minidom.Document) -node = doc["word/document.xml"].get_node(tag="w:p", line_number=5) -parent = node.parentNode -parent.removeChild(node) -parent.appendChild(node) # Move to end - -# General document manipulation (without tracked changes) -old_node = doc["word/document.xml"].get_node(tag="w:p", contains="original text") -doc["word/document.xml"].replace_node(old_node, "replacement text") - -# Multiple insertions - use return value to maintain order -node = doc["word/document.xml"].get_node(tag="w:r", line_number=100) -nodes = doc["word/document.xml"].insert_after(node, "A") -nodes = doc["word/document.xml"].insert_after(nodes[-1], "B") -nodes = doc["word/document.xml"].insert_after(nodes[-1], "C") -# Results in: original_node, A, B, C -``` - -## Tracked Changes (Redlining) - -**Use the Document class above for all tracked changes.** The patterns below are for reference when constructing replacement XML strings. - -### Validation Rules -The validator checks that the document text matches the original after reverting Claude's changes. This means: -- **NEVER modify text inside another author's `` or `` tags** -- **ALWAYS use nested deletions** to remove another author's insertions -- **Every edit must be properly tracked** with `` or `` tags - -### Tracked Change Patterns - -**CRITICAL RULES**: -1. Never modify the content inside another author's tracked changes. Always use nested deletions. -2. **XML Structure**: Always place `` and `` at paragraph level containing complete `` elements. Never nest inside `` elements - this creates invalid XML that breaks document processing. - -**Text Insertion:** -```xml - - - inserted text - - -``` - -**Text Deletion:** -```xml - - - deleted text - - -``` - -**Deleting Another Author's Insertion (MUST use nested structure):** -```xml - - - - monthly - - - - weekly - -``` - -**Restoring Another Author's Deletion:** -```xml - - - within 30 days - - - within 30 days - -``` \ No newline at end of file diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/pack.py b/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/pack.py deleted file mode 100644 index cd1bb3a6..00000000 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/pack.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -""" -Tool to pack a directory into a .docx, .pptx, or .xlsx file with XML formatting undone. - -Example usage: - python pack.py [--force] -""" - -import argparse -import os -import shutil -import subprocess -import sys -import tempfile -import zipfile -from pathlib import Path - -try: - import defusedxml.minidom as secure_minidom -except ImportError: # pragma: no cover - fallback for minimal environments - import xml.dom.minidom as secure_minidom - - -def resolve_soffice_binary(): - candidates = ["soffice", "soffice.com", "soffice.exe"] - if sys.platform == "win32": - candidates = ["soffice.com", "soffice.exe", "soffice"] - - for name in candidates: - resolved = shutil.which(name) - if resolved: - return resolved - - if sys.platform == "win32": - for env_key in ("ProgramFiles", "ProgramFiles(x86)"): - value = os.environ.get(env_key) - if not value: - continue - root = Path(value) - for rel in ("LibreOffice/program/soffice.com", "LibreOffice/program/soffice.exe"): - candidate = root / rel - if candidate.exists(): - return str(candidate) - return None - - -def main(): - parser = argparse.ArgumentParser(description="Pack a directory into an Office file") - parser.add_argument("input_directory", help="Unpacked Office document directory") - parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") - parser.add_argument("--force", action="store_true", help="Skip validation") - args = parser.parse_args() - - try: - success = pack_document( - args.input_directory, args.output_file, validate=not args.force - ) - - if args.force: - print("Warning: Skipped validation, file may be corrupt", file=sys.stderr) - elif not success: - print("Contents would produce a corrupt file.", file=sys.stderr) - print("Please validate XML before repacking.", file=sys.stderr) - print("Use --force to skip validation and pack anyway.", file=sys.stderr) - sys.exit(1) - - except ValueError as exc: - sys.exit(f"Error: {exc}") - - -def pack_document(input_dir, output_file, validate=False): - input_dir = Path(input_dir) - output_file = Path(output_file) - - if not input_dir.is_dir(): - raise ValueError(f"{input_dir} is not a directory") - if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}: - raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file") - - with tempfile.TemporaryDirectory() as temp_dir: - temp_content_dir = Path(temp_dir) / "content" - shutil.copytree(input_dir, temp_content_dir) - - for pattern in ["*.xml", "*.rels"]: - for xml_file in temp_content_dir.rglob(pattern): - condense_xml(xml_file) - - output_file.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as archive: - for file_path in temp_content_dir.rglob("*"): - if file_path.is_file(): - archive.write(file_path, file_path.relative_to(temp_content_dir)) - - if validate and not validate_document(output_file): - if output_file.exists(): - output_file.unlink() - return False - - return True - - -def validate_document(doc_path): - match doc_path.suffix.lower(): - case ".docx": - filter_name = "html:HTML" - case ".pptx": - filter_name = "html:impress_html_Export" - case ".xlsx": - filter_name = "html:HTML (StarCalc)" - case _: - return False - - soffice_binary = resolve_soffice_binary() - if not soffice_binary: - print("Warning: soffice not found. Skipping validation.", file=sys.stderr) - return True - - with tempfile.TemporaryDirectory() as temp_dir: - try: - result = subprocess.run( - [ - soffice_binary, - "--headless", - "--convert-to", - filter_name, - "--outdir", - temp_dir, - str(doc_path), - ], - capture_output=True, - timeout=30, - text=True, - check=False, - ) - if not (Path(temp_dir) / f"{doc_path.stem}.html").exists(): - error_msg = result.stderr.strip() or result.stdout.strip() or "Document validation failed" - print(f"Validation error: {error_msg}", file=sys.stderr) - return False - return True - except subprocess.TimeoutExpired: - print("Validation error: Timeout during conversion", file=sys.stderr) - return False - except Exception as exc: - print(f"Validation error: {exc}", file=sys.stderr) - return False - - -def condense_xml(xml_file): - with open(xml_file, "r", encoding="utf-8") as file_obj: - dom = secure_minidom.parse(file_obj) - - for element in dom.getElementsByTagName("*"): - if element.tagName.endswith(":t"): - continue - - for child in list(element.childNodes): - is_blank_text = ( - child.nodeType == child.TEXT_NODE - and child.nodeValue - and child.nodeValue.strip() == "" - ) - if is_blank_text or child.nodeType == child.COMMENT_NODE: - element.removeChild(child) - - with open(xml_file, "wb") as file_obj: - file_obj.write(dom.toxml(encoding="UTF-8")) - - -if __name__ == "__main__": - main() diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/unpack.py b/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/unpack.py deleted file mode 100644 index 393f17d8..00000000 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/unpack.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Unpack and format XML contents of Office files (.docx, .pptx, .xlsx).""" - -import argparse -import random -import re -import sys -import zipfile -from pathlib import Path - -try: - import defusedxml.minidom as secure_minidom -except ImportError: # pragma: no cover - fallback for minimal environments - import xml.dom.minidom as secure_minidom - - -ENCODING_RE = re.compile(br"encoding=[\"']([^\"']+)[\"']", re.IGNORECASE) - - -def detect_xml_encoding(raw_bytes: bytes) -> str: - header = raw_bytes[:256] - match = ENCODING_RE.search(header) - if not match: - return "utf-8" - value = match.group(1).decode("ascii", errors="ignore").lower() - if value == "ascii": - return "ascii" - return "utf-8" - - -def _is_within_directory(base_dir: Path, target: Path) -> bool: - try: - target.resolve().relative_to(base_dir.resolve()) - return True - except ValueError: - return False - - -def safe_extract_office_archive(input_file: Path, output_dir: Path) -> None: - with zipfile.ZipFile(input_file, "r") as archive: - for member in archive.infolist(): - target = output_dir / member.filename - if not _is_within_directory(output_dir, target): - raise ValueError(f"Unsafe archive path detected: {member.filename}") - archive.extractall(output_dir) - - -def pretty_print_xml_files(output_dir: Path) -> None: - xml_files = list(output_dir.rglob("*.xml")) + list(output_dir.rglob("*.rels")) - for xml_file in xml_files: - raw = xml_file.read_bytes() - encoding = detect_xml_encoding(raw) - dom = secure_minidom.parseString(raw) - xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding=encoding)) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Unpack Office file and pretty-print XML") - parser.add_argument("office_file", help="Input Office file (.docx/.pptx/.xlsx)") - parser.add_argument("output_dir", help="Directory where unpacked files are written") - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - input_path = Path(args.office_file) - output_path = Path(args.output_dir) - - if not input_path.exists(): - print(f"Error: file not found: {input_path}", file=sys.stderr) - return 1 - if input_path.suffix.lower() not in {".docx", ".pptx", ".xlsx"}: - print("Error: office_file must be a .docx, .pptx, or .xlsx file", file=sys.stderr) - return 1 - - output_path.mkdir(parents=True, exist_ok=True) - - try: - safe_extract_office_archive(input_path, output_path) - pretty_print_xml_files(output_path) - except zipfile.BadZipFile: - print(f"Error: invalid Office archive: {input_path}", file=sys.stderr) - return 1 - except Exception as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if input_path.suffix.lower() == ".docx": - suggested_rsid = "".join(random.choices("0123456789ABCDEF", k=8)) - print(f"Suggested RSID for edit session: {suggested_rsid}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validate.py b/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validate.py deleted file mode 100644 index 508c5891..00000000 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validate.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -""" -Command line tool to validate Office document XML files against XSD schemas and tracked changes. - -Usage: - python validate.py --original -""" - -import argparse -import sys -from pathlib import Path - -from validation import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator - - -def main(): - parser = argparse.ArgumentParser(description="Validate Office document XML files") - parser.add_argument( - "unpacked_dir", - help="Path to unpacked Office document directory", - ) - parser.add_argument( - "--original", - required=True, - help="Path to original file (.docx/.pptx/.xlsx)", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose output", - ) - args = parser.parse_args() - - # Validate paths - unpacked_dir = Path(args.unpacked_dir) - original_file = Path(args.original) - file_extension = original_file.suffix.lower() - assert unpacked_dir.is_dir(), f"Error: {unpacked_dir} is not a directory" - assert original_file.is_file(), f"Error: {original_file} is not a file" - assert file_extension in [".docx", ".pptx", ".xlsx"], ( - f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" - ) - - # Run validations - match file_extension: - case ".docx": - validators = [DOCXSchemaValidator, RedliningValidator] - case ".pptx": - validators = [PPTXSchemaValidator] - case _: - print(f"Error: Validation not supported for file type {file_extension}") - sys.exit(1) - - # Run validators - success = True - for V in validators: - validator = V(unpacked_dir, original_file, verbose=args.verbose) - if not validator.validate(): - success = False - - if success: - print("All validations PASSED!") - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/docx.py b/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/docx.py deleted file mode 100644 index 602c4708..00000000 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/docx.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Validator for Word document XML files against XSD schemas. -""" - -import re -import tempfile -import zipfile - -import lxml.etree - -from .base import BaseSchemaValidator - - -class DOCXSchemaValidator(BaseSchemaValidator): - """Validator for Word document XML files against XSD schemas.""" - - # Word-specific namespace - WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - # Word-specific element to relationship type mappings - # Start with empty mapping - add specific cases as we discover them - ELEMENT_RELATIONSHIP_TYPES = {} - - def validate(self): - """Run all validation checks and return True if all pass.""" - # Test 0: XML well-formedness - if not self.validate_xml(): - return False - - # Test 1: Namespace declarations - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - # Test 2: Unique IDs - if not self.validate_unique_ids(): - all_valid = False - - # Test 3: Relationship and file reference validation - if not self.validate_file_references(): - all_valid = False - - # Test 4: Content type declarations - if not self.validate_content_types(): - all_valid = False - - # Test 5: XSD schema validation - if not self.validate_against_xsd(): - all_valid = False - - # Test 6: Whitespace preservation - if not self.validate_whitespace_preservation(): - all_valid = False - - # Test 7: Deletion validation - if not self.validate_deletions(): - all_valid = False - - # Test 8: Insertion validation - if not self.validate_insertions(): - all_valid = False - - # Test 9: Relationship ID reference validation - if not self.validate_all_relationship_ids(): - all_valid = False - - # Count and compare paragraphs - self.compare_paragraph_counts() - - return all_valid - - def validate_whitespace_preservation(self): - """ - Validate that w:t elements with whitespace have xml:space='preserve'. - """ - errors = [] - - for xml_file in self.xml_files: - # Only check document.xml files - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - # Find all w:t elements - for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): - if elem.text: - text = elem.text - # Check if text starts or ends with whitespace - if re.match(r"^\s.*", text) or re.match(r".*\s$", text): - # Check if xml:space="preserve" attribute exists - xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" - if ( - xml_space_attr not in elem.attrib - or elem.attrib[xml_space_attr] != "preserve" - ): - # Show a preview of the text - text_preview = ( - repr(text)[:50] + "..." - if len(repr(text)) > 50 - else repr(text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} whitespace preservation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All whitespace is properly preserved") - return True - - def validate_deletions(self): - """ - Validate that w:t elements are not within w:del elements. - For some reason, XSD validation does not catch this, so we do it manually. - """ - errors = [] - - for xml_file in self.xml_files: - # Only check document.xml files - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - # Find all w:t elements that are descendants of w:del elements - namespaces = {"w": self.WORD_2006_NAMESPACE} - xpath_expression = ".//w:del//w:t" - problematic_t_elements = root.xpath( - xpath_expression, namespaces=namespaces - ) - for t_elem in problematic_t_elements: - if t_elem.text: - # Show a preview of the text - text_preview = ( - repr(t_elem.text)[:50] + "..." - if len(repr(t_elem.text)) > 50 - else repr(t_elem.text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {t_elem.sourceline}: found within : {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} deletion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:t elements found within w:del elements") - return True - - def count_paragraphs_in_unpacked(self): - """Count the number of paragraphs in the unpacked document.""" - count = 0 - - for xml_file in self.xml_files: - # Only check document.xml files - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - # Count all w:p elements - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - except Exception as e: - print(f"Error counting paragraphs in unpacked document: {e}") - - return count - - def count_paragraphs_in_original(self): - """Count the number of paragraphs in the original docx file.""" - count = 0 - - try: - # Create temporary directory to unpack original - with tempfile.TemporaryDirectory() as temp_dir: - # Unpack original docx - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - zip_ref.extractall(temp_dir) - - # Parse document.xml - doc_xml_path = temp_dir + "/word/document.xml" - root = lxml.etree.parse(doc_xml_path).getroot() - - # Count all w:p elements - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - - except Exception as e: - print(f"Error counting paragraphs in original document: {e}") - - return count - - def validate_insertions(self): - """ - Validate that w:delText elements are not within w:ins elements. - w:delText is only allowed in w:ins if nested within a w:del. - """ - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - # Find w:delText in w:ins that are NOT within w:del - invalid_elements = root.xpath( - ".//w:ins//w:delText[not(ancestor::w:del)]", - namespaces=namespaces - ) - - for elem in invalid_elements: - text_preview = ( - repr(elem.text or "")[:50] + "..." - if len(repr(elem.text or "")) > 50 - else repr(elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: within : {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} insertion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:delText elements within w:ins elements") - return True - - def compare_paragraph_counts(self): - """Compare paragraph counts between original and new document.""" - original_count = self.count_paragraphs_in_original() - new_count = self.count_paragraphs_in_unpacked() - - diff = new_count - original_count - diff_str = f"+{diff}" if diff > 0 else str(diff) - print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/docx/skills/docx/references/platform.md b/plugins/codex/plugins/docx/skills/docx/references/platform.md new file mode 100644 index 00000000..c3bb862f --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/references/platform.md @@ -0,0 +1,41 @@ +# 简体中文与 Windows 环境 + +以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。 + +```powershell +$env:PYTHONUTF8 = "1" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +python --version +node --version +``` + +若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。 + +需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。 + +## 字体与文本 + +- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。 +- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。 +- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。 +- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。 +- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。 + +## 转换与预览 + +Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。 + +```powershell +# 仅在自动检测不到时设置;不要求添加系统 PATH +$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx" +python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages" +``` + +按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。 + +使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。 + +## 验证与交付 + +生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。 diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/__init__.py b/plugins/codex/plugins/docx/skills/docx/scripts/__init__.py old mode 100644 new mode 100755 index bf9c5627..8b137891 --- a/plugins/codex/plugins/docx/skills/docx/scripts/__init__.py +++ b/plugins/codex/plugins/docx/skills/docx/scripts/__init__.py @@ -1 +1 @@ -# Make scripts directory a package for relative imports in tests + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/accept_changes.py b/plugins/codex/plugins/docx/skills/docx/scripts/accept_changes.py new file mode 100644 index 00000000..0e45a390 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/accept_changes.py @@ -0,0 +1,75 @@ +"""Accept tracked changes into a new DOCX using an isolated LibreOffice profile.""" + +import argparse +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from defusedxml import ElementTree +from office.soffice import run_soffice + +MACRO = ''' + + +Sub AcceptAllTrackedChanges() + Dim dispatcher As Object + dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") + dispatcher.executeDispatch(ThisComponent.CurrentController.Frame, ".uno:AcceptAllTrackedChanges", "", 0, Array()) + ThisComponent.store() + ThisComponent.close(True) +End Sub +''' +WORD_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + + +def accept_changes(input_file, output_file, timeout=120): + source = Path(input_file).resolve() + output = Path(output_file).resolve() + if source == output or not source.is_file() or source.suffix.lower() != ".docx": + return None, "Input must be an existing DOCX and output must be a different file" + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="docx-accept-", ignore_cleanup_errors=True) as temporary: + root = Path(temporary).resolve() + profile = root / "profile" + profile_arg = f"-env:UserInstallation={profile.as_uri()}" + run_soffice([profile_arg, "--headless", "--terminate_after_init"], check=True, capture_output=True, timeout=timeout) + macro_dir = profile / "user/basic/Standard" + macro_dir.mkdir(parents=True, exist_ok=True) + (macro_dir / "Module1.xba").write_text(MACRO, encoding="utf-8") + candidate = root / "accepted.docx" + shutil.copy2(source, candidate) + before = candidate.stat().st_mtime_ns + run_soffice([ + profile_arg, "--headless", "--norestore", + "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", + str(candidate), + ], check=True, capture_output=True, timeout=timeout) + if candidate.stat().st_mtime_ns == before: + return None, "LibreOffice did not save the document" + with zipfile.ZipFile(candidate) as archive: + for name in archive.namelist(): + if not name.startswith("word/") or not name.endswith(".xml"): + continue + document = ElementTree.fromstring(archive.read(name)) + if any(element.tag in {WORD_NS + "ins", WORD_NS + "del", WORD_NS + "moveFrom", WORD_NS + "moveTo"} + or (element.tag.startswith(WORD_NS) and element.tag.endswith("Change")) + for element in document.iter()): + return None, f"Tracked changes remain in {name}" + shutil.copy2(candidate, output) + return None, f"Accepted tracked changes: {output}" + except (OSError, subprocess.SubprocessError, zipfile.BadZipFile) as exc: + return None, f"Failed to accept tracked changes: {exc}" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--timeout", type=int, default=120) + args = parser.parse_args() + _, message = accept_changes(args.input, args.output, args.timeout) + print(message) + raise SystemExit(0 if message.startswith("Accepted tracked changes:") else 1) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/comment.py b/plugins/codex/plugins/docx/skills/docx/scripts/comment.py new file mode 100755 index 00000000..46ed5f52 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/comment.py @@ -0,0 +1,368 @@ +"""Add comments to a DOCX document. + +Accepts either an unpacked directory OR a .docx/.dotx file directly. + +Usage: + # Against an unpacked directory (writes satellite files in place) + python comment.py unpacked/ "Comment text" + python comment.py unpacked/ "Reply text" --parent 0 + + # Against a .docx directly (extracts, writes satellite files, rezips) + python comment.py contract.docx "This cap is too low" -o annotated.docx + python comment.py contract.docx "Comment" --id 5 # explicit ID + +The comment ID is auto-assigned (max existing + 1) unless --id is given. +Plain text is XML-escaped automatically; if you pass already-escaped text +(e.g. &, ’) use --raw to skip escaping. + +After running, add markers to word/document.xml so the comment is visible: + + ... commented content ... + + +""" + +import argparse +import random +import shutil +import sys +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +import defusedxml.minidom +from xml.parsers.expat import ExpatError +from xml.sax.saxutils import escape as xml_escape + +from office.helpers import opc_target, rezip as _rezip, safe_extract as _safe_extract + +TEMPLATE_DIR = Path(__file__).parent / "templates" +NS = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", + "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", +} + +COMMENT_XML = """\ + + + + + + + + + + + + + {text} + + +""" + +COMMENT_MARKER_TEMPLATE = """ +Add to word/document.xml (markers must be direct children of w:p, never inside w:r): + + ... + + """ + +REPLY_MARKER_TEMPLATE = """ +Nest markers inside parent {pid}'s markers (direct children of w:p, never inside w:r): + + ... + + + """ + +SMART_QUOTE_ENTITIES = { + "“": "“", + "”": "”", + "‘": "‘", + "’": "’", +} + + +def _generate_hex_id() -> str: + return f"{random.randint(0, 0x7FFFFFFE):08X}" + + +def _encode_smart_quotes(text: str) -> str: + for char, entity in SMART_QUOTE_ENTITIES.items(): + text = text.replace(char, entity) + return text + + +def _append_xml(xml_path: Path, root_tag: str, content: str) -> None: + dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8")) + root = dom.getElementsByTagName(root_tag)[0] + ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items()) + wrapper_dom = defusedxml.minidom.parseString(f"{content}") + for child in wrapper_dom.documentElement.childNodes: + if child.nodeType == child.ELEMENT_NODE: + root.appendChild(dom.importNode(child, True)) + output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8")) + xml_path.write_text(output, encoding="utf-8") + + +def _find_para_id(comments_path: Path, comment_id: int) -> str | None: + dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) + for c in dom.getElementsByTagName("w:comment"): + if c.getAttribute("w:id") == str(comment_id): + for p in c.getElementsByTagName("w:p"): + if pid := p.getAttribute("w14:paraId"): + return pid + return None + + +def _next_comment_id(comments_path: Path) -> int: + if not comments_path.exists(): + return 0 + dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) + ids = [] + for c in dom.getElementsByTagName("w:comment"): + try: + ids.append(int(c.getAttribute("w:id"))) + except ValueError: + pass + return (max(ids) + 1) if ids else 0 + + +def _get_next_rid(rels_path: Path) -> int: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + max_rid = 0 + for rel in dom.getElementsByTagName("Relationship"): + rid = rel.getAttribute("Id") + if rid and rid.startswith("rId"): + try: + max_rid = max(max_rid, int(rid[3:])) + except ValueError: + pass + return max_rid + 1 + + +def _has_relationship(rels_path: Path, target: str) -> bool: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + return any( + rel.getAttribute("Target") == target + for rel in dom.getElementsByTagName("Relationship") + ) + + +def _has_content_type(ct_path: Path, part_name: str) -> bool: + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + return any( + o.getAttribute("PartName") == part_name + for o in dom.getElementsByTagName("Override") + ) + + +_COMMENT_RELS = [ + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", "comments.xml"), + ("http://schemas.microsoft.com/office/2011/relationships/commentsExtended", "commentsExtended.xml"), + ("http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", "commentsIds.xml"), + ("http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", "commentsExtensible.xml"), +] +_COMMENT_OVERRIDES = [ + ("/word/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"), + ("/word/commentsExtended.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"), + ("/word/commentsIds.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"), + ("/word/commentsExtensible.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"), +] + + +def _ensure_comment_relationships(unpacked_dir: Path) -> None: + rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels" + if not rels_path.exists(): + return + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + root = dom.documentElement + comment_types = {rel_type for rel_type, _ in _COMMENT_RELS} + existing = set() + for rel in dom.getElementsByTagName("Relationship"): + if rel.getAttribute("Type") not in comment_types: + continue + part = opc_target( + rel.getAttribute("Target"), + "word/document.xml", + rel.getAttribute("TargetMode"), + ) + if part is not None: + existing.add(part) + next_rid = _get_next_rid(rels_path) + changed = False + for rel_type, target in _COMMENT_RELS: + if opc_target(target, "word/document.xml") in existing: + continue + rel = dom.createElement("Relationship") + rel.setAttribute("Id", f"rId{next_rid}") + rel.setAttribute("Type", rel_type) + rel.setAttribute("Target", target) + root.appendChild(rel) + next_rid += 1 + changed = True + if changed: + rels_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def _ensure_comment_content_types(unpacked_dir: Path) -> None: + ct_path = unpacked_dir / "[Content_Types].xml" + if not ct_path.exists(): + return + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + root = dom.documentElement + existing = { + o.getAttribute("PartName") + for o in dom.getElementsByTagName("Override") + } + changed = False + for part_name, content_type in _COMMENT_OVERRIDES: + if part_name in existing: + continue + override = dom.createElement("Override") + override.setAttribute("PartName", part_name) + override.setAttribute("ContentType", content_type) + root.appendChild(override) + changed = True + if changed: + ct_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def add_comment( + unpacked_dir: Path | str, + text: str, + comment_id: int | None = None, + author: str = "Claude", + initials: str = "C", + parent_id: int | None = None, + raw: bool = False, +) -> tuple[int, str, str]: + unpacked_dir = Path(unpacked_dir) + if not raw: + text = xml_escape(text) + author = xml_escape(author, {'"': """}) + initials = xml_escape(initials, {'"': """}) + word = unpacked_dir / "word" + if not word.exists(): + raise FileNotFoundError(f"{word} not found (not an unpacked .docx?)") + + comments = word / "comments.xml" + if comment_id is None: + comment_id = _next_comment_id(comments) + + parent_para = None + if parent_id is not None: + parent_para = _find_para_id(comments, parent_id) if comments.exists() else None + if not parent_para: + raise ValueError(f"parent comment {parent_id} not found") + + para_id, durable_id = _generate_hex_id(), _generate_hex_id() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + if not comments.exists(): + shutil.copy(TEMPLATE_DIR / "comments.xml", comments) + _ensure_comment_relationships(unpacked_dir) + _ensure_comment_content_types(unpacked_dir) + _append_xml( + comments, + "w:comments", + COMMENT_XML.format( + id=comment_id, author=author, date=ts, initials=initials, + para_id=para_id, text=text, + ), + ) + + ext = word / "commentsExtended.xml" + if not ext.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext) + if parent_para is not None: + _append_xml( + ext, "w15:commentsEx", + f'', + ) + else: + _append_xml( + ext, "w15:commentsEx", + f'', + ) + + ids = word / "commentsIds.xml" + if not ids.exists(): + shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids) + _append_xml( + ids, "w16cid:commentsIds", + f'', + ) + + extensible = word / "commentsExtensible.xml" + if not extensible.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible) + _append_xml( + extensible, "w16cex:commentsExtensible", + f'', + ) + + action = "reply" if parent_id is not None else "comment" + return comment_id, para_id, f"Added {action} id={comment_id} (paraId={para_id})" + + +def main() -> None: + p = argparse.ArgumentParser(description="Add a comment to a DOCX (directory or .docx file).") + p.add_argument("input", help="Unpacked DOCX directory OR a .docx/.dotx file") + p.add_argument("text", help="Comment text (plain text; XML-escaped automatically)") + p.add_argument("--raw", action="store_true", + help="Treat text as pre-escaped XML (skip automatic escaping)") + p.add_argument("--id", type=int, dest="comment_id", + help="Comment ID (default: auto-assign as max existing + 1)") + p.add_argument("--author", default="Claude", help="Author name") + p.add_argument("--initials", default="C", help="Author initials") + p.add_argument("--parent", type=int, help="Parent comment ID (makes this a reply)") + p.add_argument("-o", "--output", + help="Output .docx path (only used when input is a .docx; default: overwrite input)") + args = p.parse_args() + + src = Path(args.input) + + try: + if src.is_dir(): + if args.output: + print("Warning: --output ignored for directory input", file=sys.stderr) + cid, _, msg = add_comment( + src, args.text, comment_id=args.comment_id, + author=args.author, initials=args.initials, + parent_id=args.parent, raw=args.raw, + ) + print(msg) + elif src.is_file() and src.suffix.lower() in (".docx", ".dotx"): + out = Path(args.output) if args.output else src + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(src) as zf: + _safe_extract(zf, tmp_path) + cid, _, msg = add_comment( + tmp_path, args.text, comment_id=args.comment_id, + author=args.author, initials=args.initials, + parent_id=args.parent, raw=args.raw, + ) + _rezip(tmp_path, out) + print(msg) + print(f"Wrote {out} (comment defined; add markers to word/document.xml to make it visible)") + else: + print(f"Error: {src} is neither a directory nor a .docx/.dotx file", file=sys.stderr) + sys.exit(1) + except (FileNotFoundError, ValueError, zipfile.BadZipFile, ExpatError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if args.parent is not None: + print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid)) + else: + print(COMMENT_MARKER_TEMPLATE.format(cid=cid)) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/document.py b/plugins/codex/plugins/docx/skills/docx/scripts/document.py deleted file mode 100644 index ae9328dd..00000000 --- a/plugins/codex/plugins/docx/skills/docx/scripts/document.py +++ /dev/null @@ -1,1276 +0,0 @@ -#!/usr/bin/env python3 -""" -Library for working with Word documents: comments, tracked changes, and editing. - -Usage: - from skills.docx.scripts.document import Document - - # Initialize - doc = Document('workspace/unpacked') - doc = Document('workspace/unpacked', author="John Doe", initials="JD") - - # Find nodes - node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) - node = doc["word/document.xml"].get_node(tag="w:p", line_number=10) - - # Add comments - doc.add_comment(start=node, end=node, text="Comment text") - doc.reply_to_comment(parent_comment_id=0, text="Reply text") - - # Suggest tracked changes - doc["word/document.xml"].suggest_deletion(node) # Delete content - doc["word/document.xml"].revert_insertion(ins_node) # Reject insertion - doc["word/document.xml"].revert_deletion(del_node) # Reject deletion - - # Save - doc.save() -""" - -import html -import random -import shutil -import tempfile -from datetime import datetime, timezone -from pathlib import Path - -from defusedxml import minidom -from ooxml.scripts.pack import pack_document -from ooxml.scripts.validation.docx import DOCXSchemaValidator -from ooxml.scripts.validation.redlining import RedliningValidator - -from .utilities import XMLEditor - -# Path to template files -TEMPLATE_DIR = Path(__file__).parent / "templates" - - -class DocxXMLEditor(XMLEditor): - """XMLEditor that automatically applies RSID, author, and date to new elements. - - Automatically adds attributes to elements that support them when inserting new content: - - w:rsidR, w:rsidRDefault, w:rsidP (for w:p and w:r elements) - - w:author and w:date (for w:ins, w:del, w:comment elements) - - w:id (for w:ins and w:del elements) - - Attributes: - dom (defusedxml.minidom.Document): The DOM document for direct manipulation - """ - - def __init__( - self, xml_path, rsid: str, author: str = "Claude", initials: str = "C" - ): - """Initialize with required RSID and optional author. - - Args: - xml_path: Path to XML file to edit - rsid: RSID to automatically apply to new elements - author: Author name for tracked changes and comments (default: "Claude") - initials: Author initials (default: "C") - """ - super().__init__(xml_path) - self.rsid = rsid - self.author = author - self.initials = initials - - def _get_next_change_id(self): - """Get the next available change ID by checking all tracked change elements.""" - max_id = -1 - for tag in ("w:ins", "w:del"): - elements = self.dom.getElementsByTagName(tag) - for elem in elements: - change_id = elem.getAttribute("w:id") - if change_id: - try: - max_id = max(max_id, int(change_id)) - except ValueError: - pass - return max_id + 1 - - def _ensure_w16du_namespace(self): - """Ensure w16du namespace is declared on the root element.""" - root = self.dom.documentElement - if not root.hasAttribute("xmlns:w16du"): # type: ignore - root.setAttribute( # type: ignore - "xmlns:w16du", - "http://schemas.microsoft.com/office/word/2023/wordml/word16du", - ) - - def _ensure_w16cex_namespace(self): - """Ensure w16cex namespace is declared on the root element.""" - root = self.dom.documentElement - if not root.hasAttribute("xmlns:w16cex"): # type: ignore - root.setAttribute( # type: ignore - "xmlns:w16cex", - "http://schemas.microsoft.com/office/word/2018/wordml/cex", - ) - - def _ensure_w14_namespace(self): - """Ensure w14 namespace is declared on the root element.""" - root = self.dom.documentElement - if not root.hasAttribute("xmlns:w14"): # type: ignore - root.setAttribute( # type: ignore - "xmlns:w14", - "http://schemas.microsoft.com/office/word/2010/wordml", - ) - - def _inject_attributes_to_nodes(self, nodes): - """Inject RSID, author, and date attributes into DOM nodes where applicable. - - Adds attributes to elements that support them: - - w:r: gets w:rsidR (or w:rsidDel if inside w:del) - - w:p: gets w:rsidR, w:rsidRDefault, w:rsidP, w14:paraId, w14:textId - - w:t: gets xml:space="preserve" if text has leading/trailing whitespace - - w:ins, w:del: get w:id, w:author, w:date, w16du:dateUtc - - w:comment: gets w:author, w:date, w:initials - - w16cex:commentExtensible: gets w16cex:dateUtc - - Args: - nodes: List of DOM nodes to process - """ - from datetime import datetime, timezone - - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - def is_inside_deletion(elem): - """Check if element is inside a w:del element.""" - parent = elem.parentNode - while parent: - if parent.nodeType == parent.ELEMENT_NODE and parent.tagName == "w:del": - return True - parent = parent.parentNode - return False - - def add_rsid_to_p(elem): - if not elem.hasAttribute("w:rsidR"): - elem.setAttribute("w:rsidR", self.rsid) - if not elem.hasAttribute("w:rsidRDefault"): - elem.setAttribute("w:rsidRDefault", self.rsid) - if not elem.hasAttribute("w:rsidP"): - elem.setAttribute("w:rsidP", self.rsid) - # Add w14:paraId and w14:textId if not present - if not elem.hasAttribute("w14:paraId"): - self._ensure_w14_namespace() - elem.setAttribute("w14:paraId", _generate_hex_id()) - if not elem.hasAttribute("w14:textId"): - self._ensure_w14_namespace() - elem.setAttribute("w14:textId", _generate_hex_id()) - - def add_rsid_to_r(elem): - # Use w:rsidDel for inside , otherwise w:rsidR - if is_inside_deletion(elem): - if not elem.hasAttribute("w:rsidDel"): - elem.setAttribute("w:rsidDel", self.rsid) - else: - if not elem.hasAttribute("w:rsidR"): - elem.setAttribute("w:rsidR", self.rsid) - - def add_tracked_change_attrs(elem): - # Auto-assign w:id if not present - if not elem.hasAttribute("w:id"): - elem.setAttribute("w:id", str(self._get_next_change_id())) - if not elem.hasAttribute("w:author"): - elem.setAttribute("w:author", self.author) - if not elem.hasAttribute("w:date"): - elem.setAttribute("w:date", timestamp) - # Add w16du:dateUtc for tracked changes (same as w:date since we generate UTC timestamps) - if elem.tagName in ("w:ins", "w:del") and not elem.hasAttribute( - "w16du:dateUtc" - ): - self._ensure_w16du_namespace() - elem.setAttribute("w16du:dateUtc", timestamp) - - def add_comment_attrs(elem): - if not elem.hasAttribute("w:author"): - elem.setAttribute("w:author", self.author) - if not elem.hasAttribute("w:date"): - elem.setAttribute("w:date", timestamp) - if not elem.hasAttribute("w:initials"): - elem.setAttribute("w:initials", self.initials) - - def add_comment_extensible_date(elem): - # Add w16cex:dateUtc for comment extensible elements - if not elem.hasAttribute("w16cex:dateUtc"): - self._ensure_w16cex_namespace() - elem.setAttribute("w16cex:dateUtc", timestamp) - - def add_xml_space_to_t(elem): - # Add xml:space="preserve" to w:t if text has leading/trailing whitespace - if ( - elem.firstChild - and elem.firstChild.nodeType == elem.firstChild.TEXT_NODE - ): - text = elem.firstChild.data - if text and (text[0].isspace() or text[-1].isspace()): - if not elem.hasAttribute("xml:space"): - elem.setAttribute("xml:space", "preserve") - - for node in nodes: - if node.nodeType != node.ELEMENT_NODE: - continue - - # Handle the node itself - if node.tagName == "w:p": - add_rsid_to_p(node) - elif node.tagName == "w:r": - add_rsid_to_r(node) - elif node.tagName == "w:t": - add_xml_space_to_t(node) - elif node.tagName in ("w:ins", "w:del"): - add_tracked_change_attrs(node) - elif node.tagName == "w:comment": - add_comment_attrs(node) - elif node.tagName == "w16cex:commentExtensible": - add_comment_extensible_date(node) - - # Process descendants (getElementsByTagName doesn't return the element itself) - for elem in node.getElementsByTagName("w:p"): - add_rsid_to_p(elem) - for elem in node.getElementsByTagName("w:r"): - add_rsid_to_r(elem) - for elem in node.getElementsByTagName("w:t"): - add_xml_space_to_t(elem) - for tag in ("w:ins", "w:del"): - for elem in node.getElementsByTagName(tag): - add_tracked_change_attrs(elem) - for elem in node.getElementsByTagName("w:comment"): - add_comment_attrs(elem) - for elem in node.getElementsByTagName("w16cex:commentExtensible"): - add_comment_extensible_date(elem) - - def replace_node(self, elem, new_content): - """Replace node with automatic attribute injection.""" - nodes = super().replace_node(elem, new_content) - self._inject_attributes_to_nodes(nodes) - return nodes - - def insert_after(self, elem, xml_content): - """Insert after with automatic attribute injection.""" - nodes = super().insert_after(elem, xml_content) - self._inject_attributes_to_nodes(nodes) - return nodes - - def insert_before(self, elem, xml_content): - """Insert before with automatic attribute injection.""" - nodes = super().insert_before(elem, xml_content) - self._inject_attributes_to_nodes(nodes) - return nodes - - def append_to(self, elem, xml_content): - """Append to with automatic attribute injection.""" - nodes = super().append_to(elem, xml_content) - self._inject_attributes_to_nodes(nodes) - return nodes - - def revert_insertion(self, elem): - """Reject an insertion by wrapping its content in a deletion. - - Wraps all runs inside w:ins in w:del, converting w:t to w:delText. - Can process a single w:ins element or a container element with multiple w:ins. - - Args: - elem: Element to process (w:ins, w:p, w:body, etc.) - - Returns: - list: List containing the processed element(s) - - Raises: - ValueError: If the element contains no w:ins elements - - Example: - # Reject a single insertion - ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) - doc["word/document.xml"].revert_insertion(ins) - - # Reject all insertions in a paragraph - para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) - doc["word/document.xml"].revert_insertion(para) - """ - # Collect insertions - ins_elements = [] - if elem.tagName == "w:ins": - ins_elements.append(elem) - else: - ins_elements.extend(elem.getElementsByTagName("w:ins")) - - # Validate that there are insertions to reject - if not ins_elements: - raise ValueError( - f"revert_insertion requires w:ins elements. " - f"The provided element <{elem.tagName}> contains no insertions. " - ) - - # Process all insertions - wrap all children in w:del - for ins_elem in ins_elements: - runs = list(ins_elem.getElementsByTagName("w:r")) - if not runs: - continue - - # Create deletion wrapper - del_wrapper = self.dom.createElement("w:del") - - # Process each run - for run in runs: - # Convert w:t → w:delText and w:rsidR → w:rsidDel - if run.hasAttribute("w:rsidR"): - run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR")) - run.removeAttribute("w:rsidR") - elif not run.hasAttribute("w:rsidDel"): - run.setAttribute("w:rsidDel", self.rsid) - - for t_elem in list(run.getElementsByTagName("w:t")): - del_text = self.dom.createElement("w:delText") - # Copy ALL child nodes (not just firstChild) to handle entities - while t_elem.firstChild: - del_text.appendChild(t_elem.firstChild) - for i in range(t_elem.attributes.length): - attr = t_elem.attributes.item(i) - del_text.setAttribute(attr.name, attr.value) - t_elem.parentNode.replaceChild(del_text, t_elem) - - # Move all children from ins to del wrapper - while ins_elem.firstChild: - del_wrapper.appendChild(ins_elem.firstChild) - - # Add del wrapper back to ins - ins_elem.appendChild(del_wrapper) - - # Inject attributes to the deletion wrapper - self._inject_attributes_to_nodes([del_wrapper]) - - return [elem] - - def revert_deletion(self, elem): - """Reject a deletion by re-inserting the deleted content. - - Creates w:ins elements after each w:del, copying deleted content and - converting w:delText back to w:t. - Can process a single w:del element or a container element with multiple w:del. - - Args: - elem: Element to process (w:del, w:p, w:body, etc.) - - Returns: - list: If elem is w:del, returns [elem, new_ins]. Otherwise returns [elem]. - - Raises: - ValueError: If the element contains no w:del elements - - Example: - # Reject a single deletion - returns [w:del, w:ins] - del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"}) - nodes = doc["word/document.xml"].revert_deletion(del_elem) - - # Reject all deletions in a paragraph - returns [para] - para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) - nodes = doc["word/document.xml"].revert_deletion(para) - """ - # Collect deletions FIRST - before we modify the DOM - del_elements = [] - is_single_del = elem.tagName == "w:del" - - if is_single_del: - del_elements.append(elem) - else: - del_elements.extend(elem.getElementsByTagName("w:del")) - - # Validate that there are deletions to reject - if not del_elements: - raise ValueError( - f"revert_deletion requires w:del elements. " - f"The provided element <{elem.tagName}> contains no deletions. " - ) - - # Track created insertion (only relevant if elem is a single w:del) - created_insertion = None - - # Process all deletions - create insertions that copy the deleted content - for del_elem in del_elements: - # Clone the deleted runs and convert them to insertions - runs = list(del_elem.getElementsByTagName("w:r")) - if not runs: - continue - - # Create insertion wrapper - ins_elem = self.dom.createElement("w:ins") - - for run in runs: - # Clone the run - new_run = run.cloneNode(True) - - # Convert w:delText → w:t - for del_text in list(new_run.getElementsByTagName("w:delText")): - t_elem = self.dom.createElement("w:t") - # Copy ALL child nodes (not just firstChild) to handle entities - while del_text.firstChild: - t_elem.appendChild(del_text.firstChild) - for i in range(del_text.attributes.length): - attr = del_text.attributes.item(i) - t_elem.setAttribute(attr.name, attr.value) - del_text.parentNode.replaceChild(t_elem, del_text) - - # Update run attributes: w:rsidDel → w:rsidR - if new_run.hasAttribute("w:rsidDel"): - new_run.setAttribute("w:rsidR", new_run.getAttribute("w:rsidDel")) - new_run.removeAttribute("w:rsidDel") - elif not new_run.hasAttribute("w:rsidR"): - new_run.setAttribute("w:rsidR", self.rsid) - - ins_elem.appendChild(new_run) - - # Insert the new insertion after the deletion - nodes = self.insert_after(del_elem, ins_elem.toxml()) - - # If processing a single w:del, track the created insertion - if is_single_del and nodes: - created_insertion = nodes[0] - - # Return based on input type - if is_single_del and created_insertion: - return [elem, created_insertion] - else: - return [elem] - - @staticmethod - def suggest_paragraph(xml_content: str) -> str: - """Transform paragraph XML to add tracked change wrapping for insertion. - - Wraps runs in and adds to w:rPr in w:pPr for numbered lists. - - Args: - xml_content: XML string containing a element - - Returns: - str: Transformed XML with tracked change wrapping - """ - wrapper = f'{xml_content}' - doc = minidom.parseString(wrapper) - para = doc.getElementsByTagName("w:p")[0] - - # Ensure w:pPr exists - pPr_list = para.getElementsByTagName("w:pPr") - if not pPr_list: - pPr = doc.createElement("w:pPr") - para.insertBefore( - pPr, para.firstChild - ) if para.firstChild else para.appendChild(pPr) - else: - pPr = pPr_list[0] - - # Ensure w:rPr exists in w:pPr - rPr_list = pPr.getElementsByTagName("w:rPr") - if not rPr_list: - rPr = doc.createElement("w:rPr") - pPr.appendChild(rPr) - else: - rPr = rPr_list[0] - - # Add to w:rPr - ins_marker = doc.createElement("w:ins") - rPr.insertBefore( - ins_marker, rPr.firstChild - ) if rPr.firstChild else rPr.appendChild(ins_marker) - - # Wrap all non-pPr children in - ins_wrapper = doc.createElement("w:ins") - for child in [c for c in para.childNodes if c.nodeName != "w:pPr"]: - para.removeChild(child) - ins_wrapper.appendChild(child) - para.appendChild(ins_wrapper) - - return para.toxml() - - def suggest_deletion(self, elem): - """Mark a w:r or w:p element as deleted with tracked changes (in-place DOM manipulation). - - For w:r: wraps in , converts to , preserves w:rPr - For w:p (regular): wraps content in , converts to - For w:p (numbered list): adds to w:rPr in w:pPr, wraps content in - - Args: - elem: A w:r or w:p DOM element without existing tracked changes - - Returns: - Element: The modified element - - Raises: - ValueError: If element has existing tracked changes or invalid structure - """ - if elem.nodeName == "w:r": - # Check for existing w:delText - if elem.getElementsByTagName("w:delText"): - raise ValueError("w:r element already contains w:delText") - - # Convert w:t → w:delText - for t_elem in list(elem.getElementsByTagName("w:t")): - del_text = self.dom.createElement("w:delText") - # Copy ALL child nodes (not just firstChild) to handle entities - while t_elem.firstChild: - del_text.appendChild(t_elem.firstChild) - # Preserve attributes like xml:space - for i in range(t_elem.attributes.length): - attr = t_elem.attributes.item(i) - del_text.setAttribute(attr.name, attr.value) - t_elem.parentNode.replaceChild(del_text, t_elem) - - # Update run attributes: w:rsidR → w:rsidDel - if elem.hasAttribute("w:rsidR"): - elem.setAttribute("w:rsidDel", elem.getAttribute("w:rsidR")) - elem.removeAttribute("w:rsidR") - elif not elem.hasAttribute("w:rsidDel"): - elem.setAttribute("w:rsidDel", self.rsid) - - # Wrap in w:del - del_wrapper = self.dom.createElement("w:del") - parent = elem.parentNode - parent.insertBefore(del_wrapper, elem) - parent.removeChild(elem) - del_wrapper.appendChild(elem) - - # Inject attributes to the deletion wrapper - self._inject_attributes_to_nodes([del_wrapper]) - - return del_wrapper - - elif elem.nodeName == "w:p": - # Check for existing tracked changes - if elem.getElementsByTagName("w:ins") or elem.getElementsByTagName("w:del"): - raise ValueError("w:p element already contains tracked changes") - - # Check if it's a numbered list item - pPr_list = elem.getElementsByTagName("w:pPr") - is_numbered = pPr_list and pPr_list[0].getElementsByTagName("w:numPr") - - if is_numbered: - # Add to w:rPr in w:pPr - pPr = pPr_list[0] - rPr_list = pPr.getElementsByTagName("w:rPr") - - if not rPr_list: - rPr = self.dom.createElement("w:rPr") - pPr.appendChild(rPr) - else: - rPr = rPr_list[0] - - # Add marker - del_marker = self.dom.createElement("w:del") - rPr.insertBefore( - del_marker, rPr.firstChild - ) if rPr.firstChild else rPr.appendChild(del_marker) - - # Convert w:t → w:delText in all runs - for t_elem in list(elem.getElementsByTagName("w:t")): - del_text = self.dom.createElement("w:delText") - # Copy ALL child nodes (not just firstChild) to handle entities - while t_elem.firstChild: - del_text.appendChild(t_elem.firstChild) - # Preserve attributes like xml:space - for i in range(t_elem.attributes.length): - attr = t_elem.attributes.item(i) - del_text.setAttribute(attr.name, attr.value) - t_elem.parentNode.replaceChild(del_text, t_elem) - - # Update run attributes: w:rsidR → w:rsidDel - for run in elem.getElementsByTagName("w:r"): - if run.hasAttribute("w:rsidR"): - run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR")) - run.removeAttribute("w:rsidR") - elif not run.hasAttribute("w:rsidDel"): - run.setAttribute("w:rsidDel", self.rsid) - - # Wrap all non-pPr children in - del_wrapper = self.dom.createElement("w:del") - for child in [c for c in elem.childNodes if c.nodeName != "w:pPr"]: - elem.removeChild(child) - del_wrapper.appendChild(child) - elem.appendChild(del_wrapper) - - # Inject attributes to the deletion wrapper - self._inject_attributes_to_nodes([del_wrapper]) - - return elem - - else: - raise ValueError(f"Element must be w:r or w:p, got {elem.nodeName}") - - -def _generate_hex_id() -> str: - """Generate random 8-character hex ID for para/durable IDs. - - Values are constrained to be less than 0x7FFFFFFF per OOXML spec: - - paraId must be < 0x80000000 - - durableId must be < 0x7FFFFFFF - We use the stricter constraint (0x7FFFFFFF) for both. - """ - return f"{random.randint(1, 0x7FFFFFFE):08X}" - - -def _generate_rsid() -> str: - """Generate random 8-character hex RSID.""" - return "".join(random.choices("0123456789ABCDEF", k=8)) - - -class Document: - """Manages comments in unpacked Word documents.""" - - def __init__( - self, - unpacked_dir, - rsid=None, - track_revisions=False, - author="Claude", - initials="C", - ): - """ - Initialize with path to unpacked Word document directory. - Automatically sets up comment infrastructure (people.xml, RSIDs). - - Args: - unpacked_dir: Path to unpacked DOCX directory (must contain word/ subdirectory) - rsid: Optional RSID to use for all comment elements. If not provided, one will be generated. - track_revisions: If True, enables track revisions in settings.xml (default: False) - author: Default author name for comments (default: "Claude") - initials: Default author initials for comments (default: "C") - """ - self.original_path = Path(unpacked_dir) - - if not self.original_path.exists() or not self.original_path.is_dir(): - raise ValueError(f"Directory not found: {unpacked_dir}") - - # Create temporary directory with subdirectories for unpacked content and baseline - self.temp_dir = tempfile.mkdtemp(prefix="docx_") - self.unpacked_path = Path(self.temp_dir) / "unpacked" - shutil.copytree(self.original_path, self.unpacked_path) - - # Pack original directory into temporary .docx for validation baseline (outside unpacked dir) - self.original_docx = Path(self.temp_dir) / "original.docx" - pack_document(self.original_path, self.original_docx, validate=False) - - self.word_path = self.unpacked_path / "word" - - # Generate RSID if not provided - self.rsid = rsid if rsid else _generate_rsid() - print(f"Using RSID: {self.rsid}") - - # Set default author and initials - self.author = author - self.initials = initials - - # Cache for lazy-loaded editors - self._editors = {} - - # Comment file paths - self.comments_path = self.word_path / "comments.xml" - self.comments_extended_path = self.word_path / "commentsExtended.xml" - self.comments_ids_path = self.word_path / "commentsIds.xml" - self.comments_extensible_path = self.word_path / "commentsExtensible.xml" - - # Load existing comments and determine next ID (before setup modifies files) - self.existing_comments = self._load_existing_comments() - self.next_comment_id = self._get_next_comment_id() - - # Convenient access to document.xml editor (semi-private) - self._document = self["word/document.xml"] - - # Setup tracked changes infrastructure - self._setup_tracking(track_revisions=track_revisions) - - # Add author to people.xml - self._add_author_to_people(author) - - def __getitem__(self, xml_path: str) -> DocxXMLEditor: - """ - Get or create a DocxXMLEditor for the specified XML file. - - Enables lazy-loaded editors with bracket notation: - node = doc["word/document.xml"].get_node(tag="w:p", line_number=42) - - Args: - xml_path: Relative path to XML file (e.g., "word/document.xml", "word/comments.xml") - - Returns: - DocxXMLEditor instance for the specified file - - Raises: - ValueError: If the file does not exist - - Example: - # Get node from document.xml - node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) - - # Get node from comments.xml - comment = doc["word/comments.xml"].get_node(tag="w:comment", attrs={"w:id": "0"}) - """ - if xml_path not in self._editors: - file_path = self.unpacked_path / xml_path - if not file_path.exists(): - raise ValueError(f"XML file not found: {xml_path}") - # Use DocxXMLEditor with RSID, author, and initials for all editors - self._editors[xml_path] = DocxXMLEditor( - file_path, rsid=self.rsid, author=self.author, initials=self.initials - ) - return self._editors[xml_path] - - def add_comment(self, start, end, text: str) -> int: - """ - Add a comment spanning from one element to another. - - Args: - start: DOM element for the starting point - end: DOM element for the ending point - text: Comment content - - Returns: - The comment ID that was created - - Example: - start_node = cm.get_document_node(tag="w:del", id="1") - end_node = cm.get_document_node(tag="w:ins", id="2") - cm.add_comment(start=start_node, end=end_node, text="Explanation") - """ - comment_id = self.next_comment_id - para_id = _generate_hex_id() - durable_id = _generate_hex_id() - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - # Add comment ranges to document.xml immediately - self._document.insert_before(start, self._comment_range_start_xml(comment_id)) - - # If end node is a paragraph, append comment markup inside it - # Otherwise insert after it (for run-level anchors) - if end.tagName == "w:p": - self._document.append_to(end, self._comment_range_end_xml(comment_id)) - else: - self._document.insert_after(end, self._comment_range_end_xml(comment_id)) - - # Add to comments.xml immediately - self._add_to_comments_xml( - comment_id, para_id, text, self.author, self.initials, timestamp - ) - - # Add to commentsExtended.xml immediately - self._add_to_comments_extended_xml(para_id, parent_para_id=None) - - # Add to commentsIds.xml immediately - self._add_to_comments_ids_xml(para_id, durable_id) - - # Add to commentsExtensible.xml immediately - self._add_to_comments_extensible_xml(durable_id) - - # Update existing_comments so replies work - self.existing_comments[comment_id] = {"para_id": para_id} - - self.next_comment_id += 1 - return comment_id - - def reply_to_comment( - self, - parent_comment_id: int, - text: str, - ) -> int: - """ - Add a reply to an existing comment. - - Args: - parent_comment_id: The w:id of the parent comment to reply to - text: Reply text - - Returns: - The comment ID that was created for the reply - - Example: - cm.reply_to_comment(parent_comment_id=0, text="I agree with this change") - """ - if parent_comment_id not in self.existing_comments: - raise ValueError(f"Parent comment with id={parent_comment_id} not found") - - parent_info = self.existing_comments[parent_comment_id] - comment_id = self.next_comment_id - para_id = _generate_hex_id() - durable_id = _generate_hex_id() - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - # Add comment ranges to document.xml immediately - parent_start_elem = self._document.get_node( - tag="w:commentRangeStart", attrs={"w:id": str(parent_comment_id)} - ) - parent_ref_elem = self._document.get_node( - tag="w:commentReference", attrs={"w:id": str(parent_comment_id)} - ) - - self._document.insert_after( - parent_start_elem, self._comment_range_start_xml(comment_id) - ) - parent_ref_run = parent_ref_elem.parentNode - self._document.insert_after( - parent_ref_run, f'' - ) - self._document.insert_after( - parent_ref_run, self._comment_ref_run_xml(comment_id) - ) - - # Add to comments.xml immediately - self._add_to_comments_xml( - comment_id, para_id, text, self.author, self.initials, timestamp - ) - - # Add to commentsExtended.xml immediately (with parent) - self._add_to_comments_extended_xml( - para_id, parent_para_id=parent_info["para_id"] - ) - - # Add to commentsIds.xml immediately - self._add_to_comments_ids_xml(para_id, durable_id) - - # Add to commentsExtensible.xml immediately - self._add_to_comments_extensible_xml(durable_id) - - # Update existing_comments so replies work - self.existing_comments[comment_id] = {"para_id": para_id} - - self.next_comment_id += 1 - return comment_id - - def __del__(self): - """Clean up temporary directory on deletion.""" - if hasattr(self, "temp_dir") and Path(self.temp_dir).exists(): - shutil.rmtree(self.temp_dir) - - def validate(self) -> None: - """ - Validate the document against XSD schema and redlining rules. - - Raises: - ValueError: If validation fails. - """ - # Create validators with current state - schema_validator = DOCXSchemaValidator( - self.unpacked_path, self.original_docx, verbose=False - ) - redlining_validator = RedliningValidator( - self.unpacked_path, self.original_docx, verbose=False - ) - - # Run validations - if not schema_validator.validate(): - raise ValueError("Schema validation failed") - if not redlining_validator.validate(): - raise ValueError("Redlining validation failed") - - def save(self, destination=None, validate=True) -> None: - """ - Save all modified XML files to disk and copy to destination directory. - - This persists all changes made via add_comment() and reply_to_comment(). - - Args: - destination: Optional path to save to. If None, saves back to original directory. - validate: If True, validates document before saving (default: True). - """ - # Only ensure comment relationships and content types if comment files exist - if self.comments_path.exists(): - self._ensure_comment_relationships() - self._ensure_comment_content_types() - - # Save all modified XML files in temp directory - for editor in self._editors.values(): - editor.save() - - # Validate by default - if validate: - self.validate() - - # Copy contents from temp directory to destination (or original directory) - target_path = Path(destination) if destination else self.original_path - shutil.copytree(self.unpacked_path, target_path, dirs_exist_ok=True) - - # ==================== Private: Initialization ==================== - - def _get_next_comment_id(self): - """Get the next available comment ID.""" - if not self.comments_path.exists(): - return 0 - - editor = self["word/comments.xml"] - max_id = -1 - for comment_elem in editor.dom.getElementsByTagName("w:comment"): - comment_id = comment_elem.getAttribute("w:id") - if comment_id: - try: - max_id = max(max_id, int(comment_id)) - except ValueError: - pass - return max_id + 1 - - def _load_existing_comments(self): - """Load existing comments from files to enable replies.""" - if not self.comments_path.exists(): - return {} - - editor = self["word/comments.xml"] - existing = {} - - for comment_elem in editor.dom.getElementsByTagName("w:comment"): - comment_id = comment_elem.getAttribute("w:id") - if not comment_id: - continue - - # Find para_id from the w:p element within the comment - para_id = None - for p_elem in comment_elem.getElementsByTagName("w:p"): - para_id = p_elem.getAttribute("w14:paraId") - if para_id: - break - - if not para_id: - continue - - existing[int(comment_id)] = {"para_id": para_id} - - return existing - - # ==================== Private: Setup Methods ==================== - - def _setup_tracking(self, track_revisions=False): - """Set up comment infrastructure in unpacked directory. - - Args: - track_revisions: If True, enables track revisions in settings.xml - """ - # Create or update word/people.xml - people_file = self.word_path / "people.xml" - self._update_people_xml(people_file) - - # Update XML files - self._add_content_type_for_people(self.unpacked_path / "[Content_Types].xml") - self._add_relationship_for_people( - self.word_path / "_rels" / "document.xml.rels" - ) - - # Always add RSID to settings.xml, optionally enable trackRevisions - self._update_settings( - self.word_path / "settings.xml", track_revisions=track_revisions - ) - - def _update_people_xml(self, path): - """Create people.xml if it doesn't exist.""" - if not path.exists(): - # Copy from template - shutil.copy(TEMPLATE_DIR / "people.xml", path) - - def _add_content_type_for_people(self, path): - """Add people.xml content type to [Content_Types].xml if not already present.""" - editor = self["[Content_Types].xml"] - - if self._has_override(editor, "/word/people.xml"): - return - - # Add Override element - root = editor.dom.documentElement - override_xml = '' - editor.append_to(root, override_xml) - - def _add_relationship_for_people(self, path): - """Add people.xml relationship to document.xml.rels if not already present.""" - editor = self["word/_rels/document.xml.rels"] - - if self._has_relationship(editor, "people.xml"): - return - - root = editor.dom.documentElement - root_tag = root.tagName # type: ignore - prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else "" - next_rid = editor.get_next_rid() - - # Create the relationship entry - rel_xml = f'<{prefix}Relationship Id="{next_rid}" Type="http://schemas.microsoft.com/office/2011/relationships/people" Target="people.xml"/>' - editor.append_to(root, rel_xml) - - def _update_settings(self, path, track_revisions=False): - """Add RSID and optionally enable track revisions in settings.xml. - - Args: - path: Path to settings.xml - track_revisions: If True, adds trackRevisions element - - Places elements per OOXML schema order: - - trackRevisions: early (before defaultTabStop) - - rsids: late (after compat) - """ - editor = self["word/settings.xml"] - root = editor.get_node(tag="w:settings") - prefix = root.tagName.split(":")[0] if ":" in root.tagName else "w" - - # Conditionally add trackRevisions if requested - if track_revisions: - track_revisions_exists = any( - elem.tagName == f"{prefix}:trackRevisions" - for elem in editor.dom.getElementsByTagName(f"{prefix}:trackRevisions") - ) - - if not track_revisions_exists: - track_rev_xml = f"<{prefix}:trackRevisions/>" - # Try to insert before documentProtection, defaultTabStop, or at start - inserted = False - for tag in [f"{prefix}:documentProtection", f"{prefix}:defaultTabStop"]: - elements = editor.dom.getElementsByTagName(tag) - if elements: - editor.insert_before(elements[0], track_rev_xml) - inserted = True - break - if not inserted: - # Insert as first child of settings - if root.firstChild: - editor.insert_before(root.firstChild, track_rev_xml) - else: - editor.append_to(root, track_rev_xml) - - # Always check if rsids section exists - rsids_elements = editor.dom.getElementsByTagName(f"{prefix}:rsids") - - if not rsids_elements: - # Add new rsids section - rsids_xml = f'''<{prefix}:rsids> - <{prefix}:rsidRoot {prefix}:val="{self.rsid}"/> - <{prefix}:rsid {prefix}:val="{self.rsid}"/> -''' - - # Try to insert after compat, before clrSchemeMapping, or before closing tag - inserted = False - compat_elements = editor.dom.getElementsByTagName(f"{prefix}:compat") - if compat_elements: - editor.insert_after(compat_elements[0], rsids_xml) - inserted = True - - if not inserted: - clr_elements = editor.dom.getElementsByTagName( - f"{prefix}:clrSchemeMapping" - ) - if clr_elements: - editor.insert_before(clr_elements[0], rsids_xml) - inserted = True - - if not inserted: - editor.append_to(root, rsids_xml) - else: - # Check if this rsid already exists - rsids_elem = rsids_elements[0] - rsid_exists = any( - elem.getAttribute(f"{prefix}:val") == self.rsid - for elem in rsids_elem.getElementsByTagName(f"{prefix}:rsid") - ) - - if not rsid_exists: - rsid_xml = f'<{prefix}:rsid {prefix}:val="{self.rsid}"/>' - editor.append_to(rsids_elem, rsid_xml) - - # ==================== Private: XML File Creation ==================== - - def _add_to_comments_xml( - self, comment_id, para_id, text, author, initials, timestamp - ): - """Add a single comment to comments.xml.""" - if not self.comments_path.exists(): - shutil.copy(TEMPLATE_DIR / "comments.xml", self.comments_path) - - editor = self["word/comments.xml"] - root = editor.get_node(tag="w:comments") - - escaped_text = ( - text.replace("&", "&").replace("<", "<").replace(">", ">") - ) - # Note: w:rsidR, w:rsidRDefault, w:rsidP on w:p, w:rsidR on w:r, - # and w:author, w:date, w:initials on w:comment are automatically added by DocxXMLEditor - comment_xml = f''' - - - {escaped_text} - -''' - editor.append_to(root, comment_xml) - - def _add_to_comments_extended_xml(self, para_id, parent_para_id): - """Add a single comment to commentsExtended.xml.""" - if not self.comments_extended_path.exists(): - shutil.copy( - TEMPLATE_DIR / "commentsExtended.xml", self.comments_extended_path - ) - - editor = self["word/commentsExtended.xml"] - root = editor.get_node(tag="w15:commentsEx") - - if parent_para_id: - xml = f'' - else: - xml = f'' - editor.append_to(root, xml) - - def _add_to_comments_ids_xml(self, para_id, durable_id): - """Add a single comment to commentsIds.xml.""" - if not self.comments_ids_path.exists(): - shutil.copy(TEMPLATE_DIR / "commentsIds.xml", self.comments_ids_path) - - editor = self["word/commentsIds.xml"] - root = editor.get_node(tag="w16cid:commentsIds") - - xml = f'' - editor.append_to(root, xml) - - def _add_to_comments_extensible_xml(self, durable_id): - """Add a single comment to commentsExtensible.xml.""" - if not self.comments_extensible_path.exists(): - shutil.copy( - TEMPLATE_DIR / "commentsExtensible.xml", self.comments_extensible_path - ) - - editor = self["word/commentsExtensible.xml"] - root = editor.get_node(tag="w16cex:commentsExtensible") - - xml = f'' - editor.append_to(root, xml) - - # ==================== Private: XML Fragments ==================== - - def _comment_range_start_xml(self, comment_id): - """Generate XML for comment range start.""" - return f'' - - def _comment_range_end_xml(self, comment_id): - """Generate XML for comment range end with reference run. - - Note: w:rsidR is automatically added by DocxXMLEditor. - """ - return f''' - - - -''' - - def _comment_ref_run_xml(self, comment_id): - """Generate XML for comment reference run. - - Note: w:rsidR is automatically added by DocxXMLEditor. - """ - return f''' - - -''' - - # ==================== Private: Metadata Updates ==================== - - def _has_relationship(self, editor, target): - """Check if a relationship with given target exists.""" - for rel_elem in editor.dom.getElementsByTagName("Relationship"): - if rel_elem.getAttribute("Target") == target: - return True - return False - - def _has_override(self, editor, part_name): - """Check if an override with given part name exists.""" - for override_elem in editor.dom.getElementsByTagName("Override"): - if override_elem.getAttribute("PartName") == part_name: - return True - return False - - def _has_author(self, editor, author): - """Check if an author already exists in people.xml.""" - for person_elem in editor.dom.getElementsByTagName("w15:person"): - if person_elem.getAttribute("w15:author") == author: - return True - return False - - def _add_author_to_people(self, author): - """Add author to people.xml (called during initialization).""" - people_path = self.word_path / "people.xml" - - # people.xml should already exist from _setup_tracking - if not people_path.exists(): - raise ValueError("people.xml should exist after _setup_tracking") - - editor = self["word/people.xml"] - root = editor.get_node(tag="w15:people") - - # Check if author already exists - if self._has_author(editor, author): - return - - # Add author with proper XML escaping to prevent injection - escaped_author = html.escape(author, quote=True) - person_xml = f''' - -''' - editor.append_to(root, person_xml) - - def _ensure_comment_relationships(self): - """Ensure word/_rels/document.xml.rels has comment relationships.""" - editor = self["word/_rels/document.xml.rels"] - - if self._has_relationship(editor, "comments.xml"): - return - - root = editor.dom.documentElement - root_tag = root.tagName # type: ignore - prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else "" - next_rid_num = int(editor.get_next_rid()[3:]) - - # Add relationship elements - rels = [ - ( - next_rid_num, - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", - "comments.xml", - ), - ( - next_rid_num + 1, - "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", - "commentsExtended.xml", - ), - ( - next_rid_num + 2, - "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", - "commentsIds.xml", - ), - ( - next_rid_num + 3, - "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", - "commentsExtensible.xml", - ), - ] - - for rel_id, rel_type, target in rels: - rel_xml = f'<{prefix}Relationship Id="rId{rel_id}" Type="{rel_type}" Target="{target}"/>' - editor.append_to(root, rel_xml) - - def _ensure_comment_content_types(self): - """Ensure [Content_Types].xml has comment content types.""" - editor = self["[Content_Types].xml"] - - if self._has_override(editor, "/word/comments.xml"): - return - - root = editor.dom.documentElement - - # Add Override elements - overrides = [ - ( - "/word/comments.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", - ), - ( - "/word/commentsExtended.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml", - ), - ( - "/word/commentsIds.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", - ), - ( - "/word/commentsExtensible.xml", - "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml", - ), - ] - - for part_name, content_type in overrides: - override_xml = ( - f'' - ) - editor.append_to(root, override_xml) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/merge_runs.py b/plugins/codex/plugins/docx/skills/docx/scripts/merge_runs.py new file mode 100755 index 00000000..4c7c1bf2 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/merge_runs.py @@ -0,0 +1,310 @@ +"""Merge adjacent identically-formatted runs in a DOCX. + +Word fragments paragraph text across many elements (revision ids, +spell-check markers, editing history), which makes find-and-replace on +word/document.xml unreliable — the string you're looking for is split +across runs. This coalesces adjacent runs whose formatting () is +identical, strips rsid attributes and proofErr markers, and consolidates the +text elements — , and for text inside a tracked deletion. + +Rendering is unchanged. The text you search is what Word draws, which is not +always the bytes in the file: an element without xml:space="preserve" has its +edge whitespace trimmed before it reaches the page, so `Hello ` +followed by `world` reads "Helloworld" and merges to exactly that. + +Runs in two different / wrappers are never merged: that would +rewrite tracked-change structure, collapsing separate revisions into one. + +Only word/document.xml is processed (not headers, footers, or footnotes). + +Usage: + python merge_runs.py unpacked/ # after unzip, before editing + python merge_runs.py document.docx # rewrite in place + python merge_runs.py document.docx -o out.docx +""" + + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from office.helpers import XML_SPACE, rendered_text, rezip, safe_extract + +WORDML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + run_names = _run_tag_names(root) + + _remove_elements(root, "proofErr") + + runs = _find_runs(root, run_names) + _strip_rsid_attrs(runs) + + merge_count = 0 + for container in {run.parentNode for run in runs}: + merge_count += _merge_runs_in(container, run_names) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _run_tag_names(root) -> set[str]: + names = set() + for attr in root.attributes.values(): + if attr.value == WORDML_NS: + if attr.name == "xmlns": + names.add("r") + elif attr.name.startswith("xmlns:"): + names.add(attr.name.split(":", 1)[1] + ":r") + return names or {"w:r", "r"} + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + if _is_element(node, tag): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _find_runs(root, run_names: set[str]) -> list: + return [e for e in _find_elements(root, "r") if _is_run(e, run_names)] + + +def _get_child(parent, tag: str): + return next(iter(_get_children(parent, tag)), None) + + +def _get_children(parent, tag: str) -> list: + return [ + child + for child in parent.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(XML_SPACE): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_rsid_attrs(runs: list): + for run in runs: + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container, run_names: set[str]) -> int: + merge_count = 0 + run = _first_child_run(container, run_names) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem, run_names) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run, run_names) + + return merge_count + + +def _first_child_run(container, run_names: set[str]): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child, run_names): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node, run_names: set[str]): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling, run_names): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node, run_names: set[str]) -> bool: + return node.tagName in run_names + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _element_text(elem) -> str: + return "".join( + child.data + for child in elem.childNodes + if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) + ) + + +def _has_preserve(elem) -> bool: + return elem.getAttribute("xml:space") == "preserve" + + +def _rendered_text(elem) -> str: + return rendered_text(_element_text(elem), _has_preserve(elem)) + + +def _consolidate_text(run): + for tag in ("t", "delText"): + _consolidate_text_elements(run, tag) + + +def _consolidate_text_elements(run, tag: str): + t_elements = _get_children(run, tag) + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + merged = _rendered_text(prev) + _rendered_text(curr) + had_preserve = _has_preserve(prev) or _has_preserve(curr) + + new_text = run.ownerDocument.createTextNode(merged) + for node in list(prev.childNodes): + if node.nodeType in (node.TEXT_NODE, node.CDATA_SECTION_NODE): + prev.removeChild(node) + else: + run.insertBefore(node, curr) + prev.appendChild(new_text) + for node in list(curr.childNodes): + if node.nodeType not in (node.TEXT_NODE, node.CDATA_SECTION_NODE): + run.insertBefore(node, curr) + + if merged != merged.strip(XML_SPACE) or had_preserve: + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) + + + + +def _merge_or_die(path: Path) -> str: + _, msg = merge_runs(str(path)) + if msg.startswith("Error"): + print(msg, file=sys.stderr) + sys.exit(1) + return msg + + +def main() -> None: + p = argparse.ArgumentParser( + description="Merge adjacent identically-formatted runs in a DOCX (directory or .docx file)." + ) + p.add_argument("input", help="Unpacked DOCX directory OR a .docx/.dotx file") + p.add_argument( + "-o", "--output", + help="Output .docx path (only valid when input is a .docx; default: overwrite input)", + ) + args = p.parse_args() + + src = Path(args.input) + + try: + if src.is_dir(): + if args.output: + p.error("--output is only valid for .docx input; directory input is modified in place") + print(_merge_or_die(src)) + elif src.is_file() and src.suffix.lower() in (".docx", ".dotx"): + out = Path(args.output) if args.output else src + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(src) as zf: + safe_extract(zf, tmp_path) + msg = _merge_or_die(tmp_path) + rezip(tmp_path, out) + print(f"{msg}; wrote {out}") + else: + print(f"Error: {src} is neither a directory nor a .docx/.dotx file", file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, zipfile.BadZipFile) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/__init__.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/__init__.py new file mode 100644 index 00000000..d3c5817c --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/__init__.py @@ -0,0 +1,111 @@ +import os +import posixpath +import re +import stat +import tempfile +import urllib.parse +import zipfile +from pathlib import Path + +OOXML_FAMILY = { + ".docx": "docx", + ".dotx": "docx", + ".pptx": "pptx", + ".potx": "pptx", + ".xlsx": "xlsx", + ".xltx": "xlsx", +} + +_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") + +SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" + + +def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: + if not target: + return None + if target_mode.lower() == "external": + return None + if _SCHEME_RE.match(target): + return None + + target = urllib.parse.unquote(target) + + if "\\" in target: + raise ValueError(f"relationship target is not a POSIX part name: {target!r}") + + if target.startswith("/"): + joined = target.lstrip("/") + else: + joined = posixpath.join(posixpath.dirname(source_part), target) + + parts: list[str] = [] + for segment in posixpath.normpath(joined).split("/"): + if segment in ("", "."): + continue + if segment == "..": + if not parts: + raise ValueError(f"relationship target escapes the package: {target!r}") + parts.pop() + else: + parts.append(segment) + + if not parts: + raise ValueError(f"relationship target resolves to nothing: {target!r}") + return "/".join(parts) + + +def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: + owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) + return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") + + +def part_text(data: bytes) -> str: + return data.decode("utf-8", "surrogateescape") + + +XML_SPACE = " \t\r\n" + + +def rendered_text(text: str, preserve: bool) -> str: + return text if preserve else text.strip(XML_SPACE) + + +def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: + dest = dest.resolve() + for m in zf.infolist(): + if stat.S_ISLNK(m.external_attr >> 16): + raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") + target = (dest / m.filename).resolve() + if not target.is_relative_to(dest): + raise ValueError(f"unsafe archive entry: {m.filename!r}") + zf.extract(m, dest) + + +def rezip(src_dir: Path, out_path: Path) -> None: + files = sorted(p for p in src_dir.rglob("*") if p.is_file()) + ct = src_dir / "[Content_Types].xml" + fd, tmp_name = tempfile.mkstemp( + prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent + ) + tmp_out = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: + if ct.exists(): + zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) + for f in files: + if f == ct: + continue + zf.write(f, f.relative_to(src_dir)) + if out_path.exists(): + mode = out_path.stat().st_mode & 0o777 + else: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + os.chmod(tmp_out, mode) + os.replace(tmp_out, out_path) + finally: + if tmp_out.exists(): + tmp_out.unlink() diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_chart.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_chart.py new file mode 100644 index 00000000..209cb7c5 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_chart.py @@ -0,0 +1,170 @@ +"""Find chart XML that PowerPoint refuses but the schema accepts. + +Detection only: for either fault more than one repair is valid, and only the +author knows which was meant. +""" + + +from __future__ import annotations + +import re +from typing import Mapping + +from . import part_text + + +_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") + +_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") +_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") + +def _strip_ext_lst(text: str) -> str: + out, cursor = [], 0 + for lo, hi in _ext_lst_spans(text): + out.append(text[cursor:lo]) + cursor = hi + out.append(text[cursor:]) + return "".join(out) + +_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) +ILLEGAL_ON_STACKED = frozenset({"outEnd"}) +LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") + + +def _check_stacked_label_positions(part: str, xml: str) -> list[str]: + problems: list[str] = [] + for match in _BAR_GROUP_RE.finditer(xml): + block = _strip_ext_lst(match.group(0)) + group = match.group(1) + + grouping = _GROUPING_RE.search(block) + if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: + continue + + bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] + for pos in sorted(set(bad)): + problems.append( + f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' + f"{grouping.group(1)} {group}; PowerPoint allows only " + f"{', '.join(LEGAL_ON_STACKED)} there" + ) + return problems + + + +_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +_AXID_RE = re.compile( + r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" +) + +_AXIS_DECL_RE = re.compile( + r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" +) + +AXID_LIMIT = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, + "surfaceChart": 3, "surface3DChart": 3, +} + +AXID_MINIMUM = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, + "line3DChart": 3, "surface3DChart": 3, +} + + +def _declared_axes(xml: str) -> dict[str, list[str]]: + axes: dict[str, list[str]] = {} + for kind, axid in _AXIS_DECL_RE.findall(xml): + axes.setdefault(kind, []).append(axid) + return axes + + +def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: + category = axes.get("catAx", []) + axes.get("dateAx", []) + value = axes.get("valAx", []) + series = axes.get("serAx", []) + if len(category) != 1 or len(value) != 1 or len(series) > 1: + return None + ids = [category[0], value[0]] + if limit >= 3 and series: + ids.append(series[0]) + return ids + + +def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: + if kind not in AXID_LIMIT: + return None + ids = _AXID_RE.findall(block) + declared = {i for group in axes.values() for i in group} + if len([i for i in ids if i in declared]) >= 2: + return None + return ids + + +def _check_chart_axis_references(part: str, xml: str) -> list[str]: + axes = _declared_axes(xml) + problems: list[str] = [] + declared = {i for group in axes.values() for i in group} + for match in _ANY_CHART_GROUP_RE.finditer(xml): + kind, block = match.group(1), match.group(0) + ids = _undeclared_axes(kind, block, axes) + if ids is None: + continue + if not ids: + problems.append( + f"{part}: declares no this part can resolve; a chart " + f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" + ) + continue + dead = [i for i in ids if i not in declared] + canonical = _canonical_ids(axes, AXID_LIMIT[kind]) + if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: + hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" + else: + hint = ("Fix: the part declares several axes of a kind -- declare the " + "secondary axes the series expects, or drop them") + detail = (f"of which {', '.join(dead)} name no declared axis" + if dead else f"only {len(ids)} of which this part declares") + problems.append( + f"{part}: references axId {', '.join(ids)}, {detail}, " + f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" + ) + return problems + + +def _ext_lst_spans(text: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + depth = 0 + start = 0 + for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): + closing, self_closing = match.group(1), match.group(2) + if self_closing: + continue + if closing: + depth -= 1 + if depth == 0: + spans.append((start, match.end())) + else: + if depth == 0: + start = match.start() + depth += 1 + return spans + + +CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) + + +def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: + problems: list[str] = [] + for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): + xml = part_text(files[part]) + for check in CHART_CHECKS: + problems.extend(check(part, xml)) + return problems diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_slide.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_slide.py new file mode 100644 index 00000000..22f9aee0 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_slide.py @@ -0,0 +1,60 @@ +"""Pick the slide-XML schema errors PowerPoint refuses the file over. + +A denylist over lxml's messages, so an unrecognised error class is a miss rather +than a false alarm. +""" + + +from __future__ import annotations + +import re + +SLIDE_PART_RE = re.compile( + r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" + r"/[^/]+\.xml" +) + +FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"\}tableStyleId': This element is not expected"), + "two in one (the schema allows one)", + ), + ( + re.compile(r"\}srgbClr', attribute 'val'"), + "a colour that is not six hex digits", + ), + ( + re.compile(r"\}txBody': Missing child element"), + "a with no children", + ), + ( + re.compile(r"\}miter', attribute 'lim'"), + 'a line join with lim="NaN"', + ), + ( + re.compile(r"\}uLnTx': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}overrideClrMapping': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}nvGrpSpPr': Missing child element"), + "a with no children", + ), +) + + +def is_schema_verdict(error: str) -> bool: + return error.startswith("Element ") + + +def fatal_slide_errors(errors: set[str]) -> list[str]: + out = [] + for error in sorted(errors): + for pattern, meaning in FATAL_SLIDE_ERRORS: + if pattern.search(error): + out.append(f"{meaning}: {error}") + break + return out diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_theme.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_theme.py new file mode 100644 index 00000000..84466201 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/helpers/pptx_theme.py @@ -0,0 +1,114 @@ +"""Find masters sharing a theme part in the way PowerPoint refuses to open. + +Reports only; the fix is to move back to directly after + in ppt/presentation.xml. +""" + + +from __future__ import annotations + +import posixpath +import re +from typing import Mapping + +from . import part_text + +THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" + +_MASTER_RE = re.compile( + r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" + r"(?:slide|notes|handout)Master(?P\d+)\.xml$" +) +_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} + +_RELATIONSHIP_RE = re.compile( + r"]*?(?:/>|>.*?)", re.DOTALL +) + + +def _sort_key(name: str) -> tuple[int, int]: + m = _MASTER_RE.match(name) + assert m is not None + return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) + + +def _rels_path(part: str) -> str: + directory, base = posixpath.split(part) + return f"{directory}/_rels/{base}.rels" + + +def _resolve(rels_path: str, target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + part_dir = posixpath.dirname(posixpath.dirname(rels_path)) + return posixpath.normpath(posixpath.join(part_dir, target)) + + +def _theme_rel(files: Mapping[str, bytes], master: str): + rels_path = _rels_path(master) + rels = files.get(rels_path) + if rels is None: + return None + for element in _RELATIONSHIP_RE.findall(part_text(rels)): + if f'Type="{THEME_REL_TYPE}"' not in element: + continue + target = re.search(r'\bTarget="([^"]+)"', element) + if target is None: + continue + return rels_path, element, _resolve(rels_path, target.group(1)) + return None + + +def _masters(files: Mapping[str, bytes]) -> list[str]: + return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) + + +_PRESENTATION = "ppt/presentation.xml" +_NOTES_MASTERS = "ppt/notesMasters/" +_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) +_AFTER_SLDIDLST_RE = re.compile( + r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL +) + + +def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: + data = files.get(_PRESENTATION) + if data is None: + return False + match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) + return match is not None and match.group(1) == " bool: + return inert_notes and master.startswith(_NOTES_MASTERS) + + +def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + ] + + +def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + inert_notes = _notes_master_share_is_inert(files) + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + if not _is_inert(master, inert_notes) + ] diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/mce/mc.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/mce/mc.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/mce/mc.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/mce/mc.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2010.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2010.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2012.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2012.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2018.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-2018.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd b/plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd rename to plugins/codex/plugins/docx/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/soffice.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/soffice.py new file mode 100644 index 00000000..49bfe9f9 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/soffice.py @@ -0,0 +1,61 @@ +"""Portable LibreOffice runner for Windows, macOS and Linux.""" + +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def resolve_soffice_binary(): + explicit = os.environ.get("SOFFICE_PATH") + if explicit: + candidate = Path(explicit).expanduser() + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError(f"SOFFICE_PATH does not exist: {candidate}") + names = ("soffice.com", "soffice.exe", "soffice") if platform.system() == "Windows" else ("soffice",) + for name in names: + binary = shutil.which(name) + if binary: + return binary + candidates = [Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")] + if platform.system() == "Windows": + candidates = [ + Path(root) / "LibreOffice/program" / name + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")) if root + for name in ("soffice.com", "soffice.exe") + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError("LibreOffice not found. Install it or set SOFFICE_PATH to its executable.") + + +def get_soffice_env(): + env = os.environ.copy() + env["PYTHONUTF8"] = "1" + if platform.system() == "Linux": + env.setdefault("SAL_USE_VCLPLUGIN", "svp") + return env + + +def run_soffice(args, **kwargs): + binary = resolve_soffice_binary() + kwargs.setdefault("env", get_soffice_env()) + kwargs.setdefault("timeout", 120) + if any(str(arg).startswith("-env:UserInstallation=") for arg in args): + return subprocess.run([binary, *args], **kwargs) + with tempfile.TemporaryDirectory(prefix="skill-lo-", ignore_cleanup_errors=True) as profile: + profile_arg = f"-env:UserInstallation={Path(profile).resolve().as_uri()}" + return subprocess.run([binary, profile_arg, *args], **kwargs) + + +if __name__ == "__main__": + try: + raise SystemExit(run_soffice(sys.argv[1:]).returncode) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/validate.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validate.py new file mode 100755 index 00000000..29ca186a --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/validate.py @@ -0,0 +1,173 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import OOXML_FAMILY, rezip, safe_extract +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def _fail(message: str): + print(f"Error: {message}", file=sys.stderr) + sys.exit(2) + + +def _has_tracked_changes(unpacked_dir: Path) -> bool: + document = unpacked_dir / "word" / "document.xml" + if not document.is_file(): + return False + try: + root = ET.parse(document).getroot() + except (ET.ParseError, DefusedXmlException): + return False + tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} + return any(elem.tag in tracked for elem in root.iter()) + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation). " + "Modifies the input in place: repairs to a packed file are written back to it.", + ) + parser.add_argument( + "--author", + default=None, + help="The name you are redlining under. Passing it turns on the " + "tracked-change check: any text differing from --original without a " + "/ recording it is reported. Untracked edits carry no " + "author, so the check covers them whoever made them — the name marks " + "the run as redlining work and is not used to filter. Requires " + "--original; docx only.", + ) + args = parser.parse_args() + + if args.author is not None and not args.original: + _fail("--author requires --original") + + path = Path(args.path) + if not path.exists(): + _fail(f"{path} does not exist") + + original_file = None + if args.original: + original_file = Path(args.original) + if not original_file.is_file(): + _fail(f"{original_file} is not a file") + if original_file.suffix.lower() not in OOXML_FAMILY: + _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") + + family = OOXML_FAMILY.get((original_file or path).suffix.lower()) + if family is None: + _fail( + f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." + ) + + if args.author is not None and family != "docx": + _fail(f"--author only applies to docx files, not {family}") + + packed_file = None + temp_dir_ctx = None + if path.is_file() and path.suffix.lower() in OOXML_FAMILY: + packed_file = path + temp_dir_ctx = tempfile.TemporaryDirectory() + unpacked_dir = Path(temp_dir_ctx.name) + try: + with zipfile.ZipFile(path, "r") as zf: + safe_extract(zf, unpacked_dir) + except (zipfile.BadZipFile, ValueError, OSError) as e: + _fail(f"cannot unpack {path}: {e}") + else: + if not path.is_dir(): + _fail(f"{path} is not a directory or Office file") + unpacked_dir = path + + match family: + case "docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if args.author is not None: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) + ) + elif original_file and _has_tracked_changes(unpacked_dir): + print( + "Note: this document has tracked changes; they were not " + "checked against the original (pass --author to check)." + ) + case "pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case "xlsx": + exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") + print( + f"No XSD schema validation is performed for xlsx-family files ({exts}). " + "For formula-error checking, use scripts/recalc.py instead." + ) + sys.exit(0) + case _: + print(f"Error: Validation not supported for file type {family}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + if packed_file is not None: + rezip(unpacked_dir, packed_file) + print(f"Wrote repaired file to {packed_file}") + + success = all([v.validate() for v in validators]) + + if temp_dir_ctx is not None: + temp_dir_ctx.cleanup() + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/__init__.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/__init__.py similarity index 100% rename from plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/__init__.py rename to plugins/codex/plugins/docx/skills/docx/scripts/office/validators/__init__.py diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/base.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/base.py similarity index 69% rename from plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/base.py rename to plugins/codex/plugins/docx/skills/docx/scripts/office/validators/base.py index 0681b199..33fc97bb 100644 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/base.py +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/base.py @@ -5,72 +5,74 @@ Base validator with common validation logic for document files. import re from pathlib import Path +import defusedxml.minidom +from functools import lru_cache + import lxml.etree +from helpers import safe_extract + + +@lru_cache(maxsize=None) +def _load_schema(schema_path: str): + with open(schema_path, "rb") as xsd_file: + xsd_doc = lxml.etree.parse( + xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path + ) + return lxml.etree.XMLSchema(xsd_doc) class BaseSchemaValidator: - """Base validator with common validation logic for document files.""" - # Elements whose 'id' attributes must be unique within their file - # Format: element_name -> (attribute_name, scope) - # scope can be 'file' (unique within file) or 'global' (unique across all files) + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + UNIQUE_ID_REQUIREMENTS = { - # Word elements - "comment": ("id", "file"), # Comment IDs in comments.xml - "commentrangestart": ("id", "file"), # Must match comment IDs - "commentrangeend": ("id", "file"), # Must match comment IDs - "bookmarkstart": ("id", "file"), # Bookmark start IDs - "bookmarkend": ("id", "file"), # Bookmark end IDs - # Note: ins and del (track changes) can share IDs when part of same revision - # PowerPoint elements - "sldid": ("id", "file"), # Slide IDs in presentation.xml - "sldmasterid": ("id", "global"), # Slide master IDs must be globally unique - "sldlayoutid": ("id", "global"), # Slide layout IDs must be globally unique - "cm": ("authorid", "file"), # Comment author IDs - # Excel elements - "sheet": ("sheetid", "file"), # Sheet IDs in workbook.xml - "definedname": ("id", "file"), # Named range IDs - # Drawing/Shape elements (all formats) - "cxnsp": ("id", "file"), # Connection shape IDs - "sp": ("id", "file"), # Shape IDs - "pic": ("id", "file"), # Picture IDs - "grpsp": ("id", "file"), # Group shape IDs + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", } - # Mapping of element names to expected relationship types - # Subclasses should override this with format-specific mappings ELEMENT_RELATIONSHIP_TYPES = {} - # Unified schema mappings for all Office document types SCHEMA_MAPPINGS = { - # Document type specific schemas - "word": "ISO-IEC29500-4_2016/wml.xsd", # Word documents - "ppt": "ISO-IEC29500-4_2016/pml.xsd", # PowerPoint presentations - "xl": "ISO-IEC29500-4_2016/sml.xsd", # Excel spreadsheets - # Common file types + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", ".rels": "ecma/fouth-edition/opc-relationships.xsd", - # Word-specific files "people.xml": "microsoft/wml-2012.xsd", "commentsIds.xml": "microsoft/wml-cid-2016.xsd", "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", "commentsExtended.xml": "microsoft/wml-2012.xsd", - # Chart files (common across document types) "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", - # Theme files (common across document types) "theme": "ISO-IEC29500-4_2016/dml-main.xsd", - # Drawing and media files "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", } - # Unified namespace constants MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" - # Common OOXML namespaces used across validators PACKAGE_RELATIONSHIPS_NAMESPACE = ( "http://schemas.openxmlformats.org/package/2006/relationships" ) @@ -81,10 +83,8 @@ class BaseSchemaValidator: "http://schemas.openxmlformats.org/package/2006/content-types" ) - # Folders where we should clean ignorable namespaces MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} - # All allowed OOXML namespaces (superset of all document types) OOXML_NAMESPACES = { "http://schemas.openxmlformats.org/officeDocument/2006/math", "http://schemas.openxmlformats.org/officeDocument/2006/relationships", @@ -103,15 +103,13 @@ class BaseSchemaValidator: "http://www.w3.org/XML/1998/namespace", } - def __init__(self, unpacked_dir, original_file, verbose=False): + def __init__(self, unpacked_dir, original_file=None, verbose=False): self.unpacked_dir = Path(unpacked_dir).resolve() - self.original_file = Path(original_file) + self.original_file = Path(original_file) if original_file else None self.verbose = verbose - # Set schemas directory - self.schemas_dir = Path(__file__).parent.parent.parent / "schemas" + self.schemas_dir = Path(__file__).parent.parent / "schemas" - # Get all XML and .rels files patterns = ["*.xml", "*.rels"] self.xml_files = [ f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) @@ -121,16 +119,51 @@ class BaseSchemaValidator: print(f"Warning: No XML files found in {self.unpacked_dir}") def validate(self): - """Run all validation checks and return True if all pass.""" raise NotImplementedError("Subclasses must implement the validate method") + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + pending = [] + + for elem in dom.getElementsByTagName("*"): + local_name = elem.tagName.rsplit(":", 1)[-1] + if local_name in ("t", "delText", "instrText", "delInstrText"): + text = "".join( + child.data + for child in elem.childNodes + if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) + ) + ws = (" ", "\t", "\n", "\r") + if text and (text.startswith(ws) or text.endswith(ws)): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + + if pending: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) + + except Exception: + pass + + return repairs + def validate_xml(self): - """Validate that all XML files are well-formed.""" errors = [] for xml_file in self.xml_files: try: - # Try to parse the XML file lxml.etree.parse(str(xml_file)) except lxml.etree.XMLSyntaxError as e: errors.append( @@ -154,13 +187,12 @@ class BaseSchemaValidator: return True def validate_namespaces(self): - """Validate that namespace prefixes in Ignorable attributes are declared.""" errors = [] for xml_file in self.xml_files: try: root = lxml.etree.parse(str(xml_file)).getroot() - declared = set(root.nsmap.keys()) - {None} # Exclude default namespace + declared = set(root.nsmap.keys()) - {None} for attr_val in [ v for k, v in root.attrib.items() if k.endswith("Ignorable") @@ -184,36 +216,39 @@ class BaseSchemaValidator: return True def validate_unique_ids(self): - """Validate that specific IDs are unique according to OOXML requirements.""" errors = [] - global_ids = {} # Track globally unique IDs across all files + global_ids = {} for xml_file in self.xml_files: try: root = lxml.etree.parse(str(xml_file)).getroot() - file_ids = {} # Track IDs that must be unique within this file + file_ids = {} - # Remove all mc:AlternateContent elements from the tree mc_elements = root.xpath( ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} ) for elem in mc_elements: elem.getparent().remove(elem) - # Now check IDs in the cleaned tree for elem in root.iter(): - # Get the element name without namespace + if not hasattr(elem, "tag") or callable(elem.tag): + continue tag = ( elem.tag.split("}")[-1].lower() if "}" in elem.tag else elem.tag.lower() ) - # Check if this element type has ID uniqueness requirements if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] - # Look for the specified attribute id_value = None for attr, value in elem.attrib.items(): attr_local = ( @@ -227,7 +262,6 @@ class BaseSchemaValidator: if id_value is not None: if scope == "global": - # Check global uniqueness if id_value in global_ids: prev_file, prev_line, prev_tag = global_ids[ id_value @@ -244,7 +278,6 @@ class BaseSchemaValidator: tag, ) elif scope == "file": - # Check file-level uniqueness key = (tag, attr_name) if key not in file_ids: file_ids[key] = {} @@ -275,12 +308,8 @@ class BaseSchemaValidator: return True def validate_file_references(self): - """ - Validate that all .rels files properly reference files and that all files are referenced. - """ errors = [] - # Find all .rels files rels_files = list(self.unpacked_dir.rglob("*.rels")) if not rels_files: @@ -288,17 +317,15 @@ class BaseSchemaValidator: print("PASSED - No .rels files found") return True - # Get all files in the unpacked directory (excluding reference files) all_files = [] for file_path in self.unpacked_dir.rglob("*"): if ( file_path.is_file() and file_path.name != "[Content_Types].xml" and not file_path.name.endswith(".rels") - ): # This file is not referenced by .rels + ): all_files.append(file_path.resolve()) - # Track all files that are referenced by any .rels file all_referenced_files = set() if self.verbose: @@ -306,16 +333,12 @@ class BaseSchemaValidator: f"Found {len(rels_files)} .rels files and {len(all_files)} target files" ) - # Check each .rels file for rels_file in rels_files: try: - # Parse relationships file rels_root = lxml.etree.parse(str(rels_file)).getroot() - # Get the directory where this .rels file is located rels_dir = rels_file.parent - # Find all relationships and their targets referenced_files = set() broken_refs = [] @@ -324,20 +347,19 @@ class BaseSchemaValidator: namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, ): target = rel.get("Target") + if rel.get("TargetMode") == "External": + continue if target and not target.startswith( ("http", "mailto:") - ): # Skip external URLs - # Resolve the target path relative to the .rels file location - if rels_file.name == ".rels": - # Root .rels file - targets are relative to unpacked_dir + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": target_path = self.unpacked_dir / target else: - # Other .rels files - targets are relative to their parent's parent - # e.g., word/_rels/document.xml.rels -> targets relative to word/ base_dir = rels_dir.parent target_path = base_dir / target - # Normalize the path and check if it exists try: target_path = target_path.resolve() if target_path.exists() and target_path.is_file(): @@ -348,7 +370,6 @@ class BaseSchemaValidator: except (OSError, ValueError): broken_refs.append((target, rel.sourceline)) - # Report broken references if broken_refs: rel_path = rels_file.relative_to(self.unpacked_dir) for broken_ref, line_num in broken_refs: @@ -360,7 +381,6 @@ class BaseSchemaValidator: rel_path = rels_file.relative_to(self.unpacked_dir) errors.append(f" Error parsing {rel_path}: {e}") - # Check for unreferenced files (files that exist but are not referenced anywhere) unreferenced_files = set(all_files) - all_referenced_files if unreferenced_files: @@ -386,31 +406,21 @@ class BaseSchemaValidator: return True def validate_all_relationship_ids(self): - """ - Validate that all r:id attributes in XML files reference existing IDs - in their corresponding .rels files, and optionally validate relationship types. - """ import lxml.etree errors = [] - # Process each XML file that might contain r:id references for xml_file in self.xml_files: - # Skip .rels files themselves if xml_file.suffix == ".rels": continue - # Determine the corresponding .rels file - # For dir/file.xml, it's dir/_rels/file.xml.rels rels_dir = xml_file.parent / "_rels" rels_file = rels_dir / f"{xml_file.name}.rels" - # Skip if there's no corresponding .rels file (that's okay) if not rels_file.exists(): continue try: - # Parse the .rels file to get valid relationship IDs and their types rels_root = lxml.etree.parse(str(rels_file)).getroot() rid_to_type = {} @@ -420,47 +430,45 @@ class BaseSchemaValidator: rid = rel.get("Id") rel_type = rel.get("Type", "") if rid: - # Check for duplicate rIds if rid in rid_to_type: rels_rel_path = rels_file.relative_to(self.unpacked_dir) errors.append( f" {rels_rel_path}: Line {rel.sourceline}: " f"Duplicate relationship ID '{rid}' (IDs must be unique)" ) - # Extract just the type name from the full URL type_name = ( rel_type.split("/")[-1] if "/" in rel_type else rel_type ) rid_to_type[rid] = type_name - # Parse the XML file to find all r:id references xml_root = lxml.etree.parse(str(xml_file)).getroot() - # Find all elements with r:id attributes + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] for elem in xml_root.iter(): - # Check for r:id attribute (relationship ID) - rid_attr = elem.get(f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id") - if rid_attr: + if not hasattr(elem, "tag") or callable(elem.tag): + continue + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue xml_rel_path = xml_file.relative_to(self.unpacked_dir) elem_name = ( elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag ) - # Check if the ID exists if rid_attr not in rid_to_type: errors.append( f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> references non-existent relationship '{rid_attr}' " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" ) - # Check if we have type expectations for this element - elif self.ELEMENT_RELATIONSHIP_TYPES: + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: expected_type = self._get_expected_relationship_type( elem_name ) if expected_type: actual_type = rid_to_type[rid_attr] - # Check if the actual type matches or contains the expected type if expected_type not in actual_type.lower(): errors.append( f" {xml_rel_path}: Line {elem.sourceline}: " @@ -484,58 +492,41 @@ class BaseSchemaValidator: return True def _get_expected_relationship_type(self, element_name): - """ - Get the expected relationship type for an element. - First checks the explicit mapping, then tries pattern detection. - """ - # Normalize element name to lowercase elem_lower = element_name.lower() - # Check explicit mapping first if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] - # Try pattern detection for common patterns - # Pattern 1: Elements ending in "Id" often expect a relationship of the prefix type if elem_lower.endswith("id") and len(elem_lower) > 2: - # e.g., "sldId" -> "sld", "sldMasterId" -> "sldMaster" - prefix = elem_lower[:-2] # Remove "id" - # Check if this might be a compound like "sldMasterId" + prefix = elem_lower[:-2] if prefix.endswith("master"): return prefix.lower() elif prefix.endswith("layout"): return prefix.lower() else: - # Simple case like "sldId" -> "slide" - # Common transformations if prefix == "sld": return "slide" return prefix.lower() - # Pattern 2: Elements ending in "Reference" expect a relationship of the prefix type if elem_lower.endswith("reference") and len(elem_lower) > 9: - prefix = elem_lower[:-9] # Remove "reference" + prefix = elem_lower[:-9] return prefix.lower() return None def validate_content_types(self): - """Validate that all content files are properly declared in [Content_Types].xml.""" errors = [] - # Find [Content_Types].xml file content_types_file = self.unpacked_dir / "[Content_Types].xml" if not content_types_file.exists(): print("FAILED - [Content_Types].xml file not found") return False try: - # Parse and get all declared parts and extensions root = lxml.etree.parse(str(content_types_file)).getroot() declared_parts = set() declared_extensions = set() - # Get Override declarations (specific files) for override in root.findall( f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" ): @@ -543,7 +534,6 @@ class BaseSchemaValidator: if part_name is not None: declared_parts.add(part_name.lstrip("/")) - # Get Default declarations (by extension) for default in root.findall( f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" ): @@ -551,19 +541,17 @@ class BaseSchemaValidator: if extension is not None: declared_extensions.add(extension.lower()) - # Root elements that require content type declaration declarable_roots = { "sld", "sldLayout", "sldMaster", - "presentation", # PowerPoint - "document", # Word + "presentation", + "document", "workbook", - "worksheet", # Excel - "theme", # Common + "worksheet", + "theme", } - # Common media file extensions that should be declared media_extensions = { "png": "image/png", "jpg": "image/jpeg", @@ -575,17 +563,14 @@ class BaseSchemaValidator: "emf": "image/x-emf", } - # Get all files in the unpacked directory all_files = list(self.unpacked_dir.rglob("*")) all_files = [f for f in all_files if f.is_file()] - # Check all XML files for Override declarations for xml_file in self.xml_files: path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( "\\", "/" ) - # Skip non-content files if any( skip in path_str for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] @@ -602,11 +587,9 @@ class BaseSchemaValidator: ) except Exception: - continue # Skip unparseable files + continue - # Check all non-XML files for Default extension declarations for file_path in all_files: - # Skip XML files and metadata files (already checked above) if file_path.suffix.lower() in {".xml", ".rels"}: continue if file_path.name == "[Content_Types].xml": @@ -616,7 +599,6 @@ class BaseSchemaValidator: extension = file_path.suffix.lstrip(".").lower() if extension and extension not in declared_extensions: - # Check if it's a known media extension that should be declared if extension in media_extensions: relative_path = file_path.relative_to(self.unpacked_dir) errors.append( @@ -639,36 +621,28 @@ class BaseSchemaValidator: return True def validate_file_against_xsd(self, xml_file, verbose=False): - """Validate a single XML file against XSD schema, comparing with original. - - Args: - xml_file: Path to XML file to validate - verbose: Enable verbose output - - Returns: - tuple: (is_valid, new_errors_set) where is_valid is True/False/None (skipped) - """ - # Resolve both paths to handle symlinks xml_file = Path(xml_file).resolve() unpacked_dir = self.unpacked_dir.resolve() - # Validate current file is_valid, current_errors = self._validate_single_file_xsd( xml_file, unpacked_dir ) if is_valid is None: - return None, set() # Skipped + return None, set() elif is_valid: - return True, set() # Valid, no errors + return True, set() - # Get errors from original file for this specific file original_errors = self._get_original_file_errors(xml_file) - # Compare with original (both are guaranteed to be sets here) assert current_errors is not None new_errors = current_errors - original_errors + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + if new_errors: if verbose: relative_path = xml_file.relative_to(unpacked_dir) @@ -678,7 +652,6 @@ class BaseSchemaValidator: print(f" - {truncated}") return False, new_errors else: - # All errors existed in original if verbose: print( f"PASSED - No new errors (original had {len(current_errors)} errors)" @@ -686,7 +659,6 @@ class BaseSchemaValidator: return True, set() def validate_against_xsd(self): - """Validate XML files against XSD schemas, showing only new errors compared to original.""" new_errors = [] original_error_count = 0 valid_count = 0 @@ -705,19 +677,16 @@ class BaseSchemaValidator: valid_count += 1 continue elif is_valid: - # Had errors but all existed in original original_error_count += 1 valid_count += 1 continue - # Has new errors new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") - for error in list(new_file_errors)[:3]: # Show first 3 errors + for error in list(new_file_errors)[:3]: new_errors.append( f" - {error[:250]}..." if len(error) > 250 else f" - {error}" ) - # Print summary if self.verbose: print(f"Validated {len(self.xml_files)} files:") print(f" - Valid: {valid_count}") @@ -739,62 +708,47 @@ class BaseSchemaValidator: return True def _get_schema_path(self, xml_file): - """Determine the appropriate schema path for an XML file.""" - # Check exact filename match if xml_file.name in self.SCHEMA_MAPPINGS: return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] - # Check .rels files if xml_file.suffix == ".rels": return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] - # Check chart files if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] - # Check theme files if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] - # Check if file is in a main content folder and use appropriate schema if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] return None def _clean_ignorable_namespaces(self, xml_doc): - """Remove attributes and elements not in allowed namespaces.""" - # Create a clean copy xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") xml_copy = lxml.etree.fromstring(xml_string) - # Remove attributes not in allowed namespaces for elem in xml_copy.iter(): attrs_to_remove = [] for attr in elem.attrib: - # Check if attribute is from a namespace other than allowed ones if "{" in attr: ns = attr.split("}")[0][1:] if ns not in self.OOXML_NAMESPACES: attrs_to_remove.append(attr) - # Remove collected attributes for attr in attrs_to_remove: del elem.attrib[attr] - # Remove elements not in allowed namespaces self._remove_ignorable_elements(xml_copy) return lxml.etree.ElementTree(xml_copy) def _remove_ignorable_elements(self, root): - """Recursively remove all elements not in allowed namespaces.""" elements_to_remove = [] - # Find elements to remove for elem in list(root): - # Skip non-element nodes (comments, processing instructions, etc.) if not hasattr(elem, "tag") or callable(elem.tag): continue @@ -805,47 +759,36 @@ class BaseSchemaValidator: elements_to_remove.append(elem) continue - # Recursively clean child elements self._remove_ignorable_elements(elem) - # Remove collected elements for elem in elements_to_remove: root.remove(elem) def _preprocess_for_mc_ignorable(self, xml_doc): - """Preprocess XML to handle mc:Ignorable attribute properly.""" - # Remove mc:Ignorable attributes before validation root = xml_doc.getroot() - # Remove mc:Ignorable attribute from root if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] return xml_doc - def _validate_single_file_xsd(self, xml_file, base_path): - """Validate a single XML file against XSD schema. Returns (is_valid, errors_set).""" - schema_path = self._get_schema_path(xml_file) + def _preprocess_for_schema(self, xml_doc, relative_path): + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): + schema_path = schema_path or self._get_schema_path(xml_file) if not schema_path: - return None, None # Skip file + return None, None try: - # Load schema - with open(schema_path, "rb") as xsd_file: - parser = lxml.etree.XMLParser() - xsd_doc = lxml.etree.parse( - xsd_file, parser=parser, base_url=str(schema_path) - ) - schema = lxml.etree.XMLSchema(xsd_doc) + schema = _load_schema(str(schema_path)) - # Load and preprocess XML with open(xml_file, "r") as f: xml_doc = lxml.etree.parse(f) xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) xml_doc = self._preprocess_for_mc_ignorable(xml_doc) - # Clean ignorable namespaces if needed relative_path = xml_file.relative_to(base_path) if ( relative_path.parts @@ -853,32 +796,26 @@ class BaseSchemaValidator: ): xml_doc = self._clean_ignorable_namespaces(xml_doc) - # Validate + xml_doc = self._preprocess_for_schema(xml_doc, relative_path) + if schema.validate(xml_doc): return True, set() else: errors = set() for error in schema.error_log: - # Store normalized error message (without line numbers for comparison) errors.add(error.message) return False, errors except Exception as e: return False, {str(e)} - def _get_original_file_errors(self, xml_file): - """Get XSD validation errors from a single file in the original document. + def _get_original_file_errors(self, xml_file, schema_path=None): + if self.original_file is None: + return set() - Args: - xml_file: Path to the XML file in unpacked_dir to check - - Returns: - set: Set of error messages from the original file - """ import tempfile import zipfile - # Resolve both paths to handle symlinks (e.g., /var vs /private/var on macOS) xml_file = Path(xml_file).resolve() unpacked_dir = self.unpacked_dir.resolve() relative_path = xml_file.relative_to(unpacked_dir) @@ -886,37 +823,26 @@ class BaseSchemaValidator: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - # Extract original file - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - zip_ref.extractall(temp_path) + try: + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + safe_extract(zip_ref, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() - # Find corresponding file in original original_xml_file = temp_path / relative_path if not original_xml_file.exists(): - # File didn't exist in original, so no original errors return set() - # Validate the specific file in original is_valid, errors = self._validate_single_file_xsd( - original_xml_file, temp_path + original_xml_file, temp_path, schema_path=schema_path ) return errors if errors else set() def _remove_template_tags_from_text_nodes(self, xml_doc): - """Remove template tags from XML text nodes and collect warnings. - - Template tags follow the pattern {{ ... }} and are used as placeholders - for content replacement. They should be removed from text content before - XSD validation while preserving XML structure. - - Returns: - tuple: (cleaned_xml_doc, warnings_list) - """ warnings = [] template_pattern = re.compile(r"\{\{[^}]*\}\}") - # Create a copy of the document to avoid modifying the original xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") xml_copy = lxml.etree.fromstring(xml_string) @@ -932,9 +858,7 @@ class BaseSchemaValidator: return template_pattern.sub("", text) return text - # Process all text nodes in the document for elem in xml_copy.iter(): - # Skip processing if this is a w:t element if not hasattr(elem, "tag") or callable(elem.tag): continue tag_str = str(elem.tag) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/docx.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/docx.py new file mode 100644 index 00000000..b1814994 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/docx.py @@ -0,0 +1,466 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + +from helpers import safe_extract + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + safe_extract(zip_ref, Path(temp_dir)) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + new_count = self.count_paragraphs_in_unpacked() + if self.original_file is None: + print(f"\nParagraphs: {new_count}") + return + + original_count = self.count_paragraphs_in_original() + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + try: + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"paraId={val} is not valid hex" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + try: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} is not valid hex" + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") + repairs = 0 + renames: dict = {} + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + is_numbering = xml_file.name == "numbering.xml" + base = 10 if is_numbering else 16 + pending = [] + seen_in_file = set() + modified = False + + for elem in dom.getElementsByTagName("*"): + for attr_name in DURABLE_ID_ATTRS: + if not elem.hasAttribute(attr_name): + continue + + durable_id = elem.getAttribute(attr_name) + try: + key = self._parse_id_value(durable_id, base=base) + needs_repair = key >= 0x7FFFFFFF + except ValueError: + key = durable_id + needs_repair = True + + if needs_repair: + if key in seen_in_file: + value = random.randint(1, 0x7FFFFFFE) + else: + seen_in_file.add(key) + if key not in renames: + renames[key] = random.randint(1, 0x7FFFFFFE) + value = renames[key] + new_id = str(value) if is_numbering else f"{value:08X}" + + elem.setAttribute(attr_name, new_id) + pending.append( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/pptx.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/pptx.py similarity index 58% rename from plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/pptx.py rename to plugins/codex/plugins/docx/skills/docx/scripts/office/validators/pptx.py index 66d5b1e2..318f0e61 100644 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/pptx.py +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/pptx.py @@ -3,19 +3,19 @@ Validator for PowerPoint presentation XML files against XSD schemas. """ import re +from pathlib import Path + +from helpers import opc_target, rels_source_part, safe_extract from .base import BaseSchemaValidator class PPTXSchemaValidator(BaseSchemaValidator): - """Validator for PowerPoint presentation XML files against XSD schemas.""" - # PowerPoint presentation namespace PRESENTATIONML_NAMESPACE = ( "http://schemas.openxmlformats.org/presentationml/2006/main" ) - # PowerPoint-specific element to relationship type mappings ELEMENT_RELATIONSHIP_TYPES = { "sldid": "slide", "sldmasterid": "slidemaster", @@ -26,60 +26,209 @@ class PPTXSchemaValidator(BaseSchemaValidator): } def validate(self): - """Run all validation checks and return True if all pass.""" - # Test 0: XML well-formedness if not self.validate_xml(): return False - # Test 1: Namespace declarations all_valid = True if not self.validate_namespaces(): all_valid = False - # Test 2: Unique IDs if not self.validate_unique_ids(): all_valid = False - # Test 3: UUID ID validation if not self.validate_uuid_ids(): all_valid = False - # Test 4: Relationship and file reference validation if not self.validate_file_references(): all_valid = False - # Test 5: Slide layout ID validation if not self.validate_slide_layout_ids(): all_valid = False - # Test 6: Content type declarations if not self.validate_content_types(): all_valid = False - # Test 7: XSD schema validation if not self.validate_against_xsd(): all_valid = False - # Test 8: Notes slide reference validation if not self.validate_notes_slide_references(): all_valid = False - # Test 9: Relationship ID reference validation if not self.validate_all_relationship_ids(): all_valid = False - # Test 10: Duplicate slide layout references validation if not self.validate_no_duplicate_slide_layouts(): all_valid = False + if not self.validate_master_theme_uniqueness(): + all_valid = False + + if not self.validate_charts(): + all_valid = False + + if not self.validate_slides(): + all_valid = False + return all_valid + def _package_map(self) -> dict: + wanted = [] + wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) + wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) + wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) + for group in ("slideMasters", "notesMasters", "handoutMasters"): + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) + return { + p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() + for p in wanted + if p.is_file() + } + + def validate_master_theme_uniqueness(self): + from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes + + shared = live_shared_master_themes(self._package_map()) + if shared: + print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") + for message in shared: + print(f" {message}") + if any(m.startswith(_NOTES_MASTERS) for m in shared): + print(" Fix: in ppt/presentation.xml, move back to " + "directly after . PowerPoint reads that happily.") + else: + print(" Fix: give each master its own theme part.") + return False + + if self.verbose: + print("PASSED - No master shares a theme part in a way PowerPoint refuses") + return True + + def validate_charts(self): + from helpers.pptx_chart import find_chart_problems + + problems = find_chart_problems(self._package_map()) + if problems: + print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") + for message in problems: + print(f" {message}") + return False + + if self.verbose: + print("PASSED - Charts satisfy the constraints PowerPoint enforces") + return True + + def _original_slide_defects(self, schema) -> set[str]: + import tempfile + import zipfile + + from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors + + if self.original_file is None: + return set() + + found: set[str] = set() + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + try: + with zipfile.ZipFile(self.original_file, "r") as zf: + safe_extract(zf, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() + + for part in sorted(temp_path.rglob("*.xml")): + relative = part.relative_to(temp_path).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + part.resolve(), temp_path.resolve(), schema_path=schema + ) + if ok is None or ok or not errors: + continue + found |= set(fatal_slide_errors(set(errors))) + return found + + def validate_slides(self): + from helpers.pptx_slide import ( + SLIDE_PART_RE, + fatal_slide_errors, + is_schema_verdict, + ) + + schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] + inherited = self._original_slide_defects(schema) + problems: list[str] = [] + broken: list[str] = [] + + for xml_file in self.xml_files: + relative = xml_file.relative_to(self.unpacked_dir).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema + ) + if ok is None or not errors: + continue + + unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] + if unreadable: + broken.extend(unreadable) + continue + if ok: + continue + + for message in fatal_slide_errors(set(errors)): + if message in inherited: + continue + problems.append(f"{relative}: {message}") + + if broken: + print(f"FAILED - Could not check {len(broken)} slide part(s):") + for message in sorted(broken): + print(f" {message[:240]}") + + if problems: + print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") + for message in sorted(problems): + print(f" {message[:240]}") + + if broken or problems: + return False + + if self.verbose: + print("PASSED - Slide XML has none of the defects PowerPoint refuses") + return True + + def _get_schema_path(self, xml_file): + if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): + return None + return super()._get_schema_path(xml_file) + + def _preprocess_for_schema(self, xml_doc, relative_path): + if relative_path.as_posix() != "ppt/presentation.xml": + return xml_doc + + root = xml_doc.getroot() + ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" + notes = root.find(f"{ns}notesMasterIdLst") + slides = root.find(f"{ns}sldIdLst") + if notes is None or slides is None: + return xml_doc + + children = list(root) + if children.index(notes) < children.index(slides): + return xml_doc + + root.remove(notes) + root.insert(list(root).index(slides), notes) + return xml_doc + def validate_uuid_ids(self): - """Validate that ID attributes that look like UUIDs contain only hex values.""" import lxml.etree errors = [] - # UUID pattern: 8-4-4-4-12 hex digits with optional braces/hyphens uuid_pattern = re.compile( r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" ) @@ -88,15 +237,11 @@ class PPTXSchemaValidator(BaseSchemaValidator): try: root = lxml.etree.parse(str(xml_file)).getroot() - # Check all elements for ID attributes for elem in root.iter(): for attr, value in elem.attrib.items(): - # Check if this is an ID attribute attr_name = attr.split("}")[-1].lower() if attr_name == "id" or attr_name.endswith("id"): - # Check if value looks like a UUID (has the right length and pattern structure) if self._looks_like_uuid(value): - # Validate that it contains only hex characters in the right positions if not uuid_pattern.match(value): errors.append( f" {xml_file.relative_to(self.unpacked_dir)}: " @@ -119,19 +264,14 @@ class PPTXSchemaValidator(BaseSchemaValidator): return True def _looks_like_uuid(self, value): - """Check if a value has the general structure of a UUID.""" - # Remove common UUID delimiters clean_value = value.strip("{}()").replace("-", "") - # Check if it's 32 hex-like characters (could include invalid hex chars) return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) def validate_slide_layout_ids(self): - """Validate that sldLayoutId elements in slide masters reference valid slide layouts.""" import lxml.etree errors = [] - # Find all slide master files slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) if not slide_masters: @@ -141,10 +281,8 @@ class PPTXSchemaValidator(BaseSchemaValidator): for slide_master in slide_masters: try: - # Parse the slide master file root = lxml.etree.parse(str(slide_master)).getroot() - # Find the corresponding _rels file for this slide master rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" if not rels_file.exists(): @@ -154,10 +292,8 @@ class PPTXSchemaValidator(BaseSchemaValidator): ) continue - # Parse the relationships file rels_root = lxml.etree.parse(str(rels_file)).getroot() - # Build a set of valid relationship IDs that point to slide layouts valid_layout_rids = set() for rel in rels_root.findall( f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" @@ -166,7 +302,6 @@ class PPTXSchemaValidator(BaseSchemaValidator): if "slideLayout" in rel_type: valid_layout_rids.add(rel.get("Id")) - # Find all sldLayoutId elements in the slide master for sld_layout_id in root.findall( f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" ): @@ -201,7 +336,6 @@ class PPTXSchemaValidator(BaseSchemaValidator): return True def validate_no_duplicate_slide_layouts(self): - """Validate that each slide has exactly one slideLayout reference.""" import lxml.etree errors = [] @@ -211,7 +345,6 @@ class PPTXSchemaValidator(BaseSchemaValidator): try: root = lxml.etree.parse(str(rels_file)).getroot() - # Find all slideLayout relationships layout_rels = [ rel for rel in root.findall( @@ -241,13 +374,11 @@ class PPTXSchemaValidator(BaseSchemaValidator): return True def validate_notes_slide_references(self): - """Validate that each notesSlide file is referenced by only one slide.""" import lxml.etree errors = [] - notes_slide_references = {} # Track which slides reference each notesSlide + notes_slide_references = {} - # Find all slide relationship files slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) if not slide_rels_files: @@ -257,28 +388,24 @@ class PPTXSchemaValidator(BaseSchemaValidator): for rels_file in slide_rels_files: try: - # Parse the relationships file root = lxml.etree.parse(str(rels_file)).getroot() - # Find all notesSlide relationships for rel in root.findall( f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" ): rel_type = rel.get("Type", "") if "notesSlide" in rel_type: - target = rel.get("Target", "") - if target: - # Normalize the target path to handle relative paths - normalized_target = target.replace("../", "") - - # Track which slide references this notesSlide + part = opc_target( + rel.get("Target", ""), + rels_source_part(rels_file, self.unpacked_dir), + rel.get("TargetMode", ""), + ) + if part: slide_name = rels_file.stem.replace( ".xml", "" - ) # e.g., "slide1" + ) - if normalized_target not in notes_slide_references: - notes_slide_references[normalized_target] = [] - notes_slide_references[normalized_target].append( + notes_slide_references.setdefault(part, []).append( (slide_name, rels_file) ) @@ -287,7 +414,6 @@ class PPTXSchemaValidator(BaseSchemaValidator): f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" ) - # Check for duplicate references for target, references in notes_slide_references.items(): if len(references) > 1: slide_names = [ref[0] for ref in references] diff --git a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/redlining.py b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/redlining.py similarity index 62% rename from plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/redlining.py rename to plugins/codex/plugins/docx/skills/docx/scripts/office/validators/redlining.py index 7ed425ed..4185c51f 100644 --- a/plugins/codex/plugins/docx/skills/docx/ooxml/scripts/validation/redlining.py +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office/validators/redlining.py @@ -1,5 +1,14 @@ """ Validator for tracked changes in Word documents. + +Detects untracked edits in word/document.xml: text that differs from the +original without a / wrapper recording it. The tracked changes +that are new relative to the original are undone, and the result is compared +against the original; whatever text still differs was edited without being +tracked. + +Only the document body is compared. Headers, footers, footnotes and endnotes +are separate parts and are not checked. """ import subprocess @@ -7,9 +16,13 @@ import tempfile import zipfile from pathlib import Path +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import rendered_text, safe_extract + class RedliningValidator: - """Validator for tracked changes in Word documents.""" def __init__(self, unpacked_dir, original_docx, verbose=False): self.unpacked_dir = Path(unpacked_dir) @@ -19,55 +32,21 @@ class RedliningValidator: "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" } + def repair(self) -> int: + return 0 + def validate(self): - """Main validation method that returns True if valid, False otherwise.""" - # Verify unpacked directory exists and has correct structure modified_file = self.unpacked_dir / "word" / "document.xml" if not modified_file.exists(): print(f"FAILED - Modified document.xml not found at {modified_file}") return False - # First, check if there are any tracked changes by Claude to validate - try: - import xml.etree.ElementTree as ET - - tree = ET.parse(modified_file) - root = tree.getroot() - - # Check for w:del or w:ins tags authored by Claude - del_elements = root.findall(".//w:del", self.namespaces) - ins_elements = root.findall(".//w:ins", self.namespaces) - - # Filter to only include changes by Claude - claude_del_elements = [ - elem - for elem in del_elements - if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" - ] - claude_ins_elements = [ - elem - for elem in ins_elements - if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" - ] - - # Redlining validation is only needed if tracked changes by Claude have been used. - if not claude_del_elements and not claude_ins_elements: - if self.verbose: - print("PASSED - No tracked changes by Claude found.") - return True - - except Exception: - # If we can't parse the XML, continue with full validation - pass - - # Create temporary directory for unpacking original docx with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - # Unpack original docx try: with zipfile.ZipFile(self.original_docx, "r") as zip_ref: - zip_ref.extractall(temp_path) + safe_extract(zip_ref, temp_path) except Exception as e: print(f"FAILED - Error unpacking original docx: {e}") return False @@ -79,28 +58,22 @@ class RedliningValidator: ) return False - # Parse both XML files using xml.etree.ElementTree for redlining validation try: - import xml.etree.ElementTree as ET - modified_tree = ET.parse(modified_file) modified_root = modified_tree.getroot() original_tree = ET.parse(original_file) original_root = original_tree.getroot() - except ET.ParseError as e: + except (ET.ParseError, DefusedXmlException) as e: print(f"FAILED - Error parsing XML files: {e}") return False - # Remove Claude's tracked changes from both documents - self._remove_claude_tracked_changes(original_root) - self._remove_claude_tracked_changes(modified_root) + new_changes = self._new_tracked_changes(original_root, modified_root) + self._remove_tracked_changes(modified_root, new_changes) - # Extract and compare text content modified_text = self._extract_text_content(modified_root) original_text = self._extract_text_content(original_root) if modified_text != original_text: - # Show detailed character-level differences for each paragraph error_message = self._generate_detailed_diff( original_text, modified_text ) @@ -108,26 +81,95 @@ class RedliningValidator: return False if self.verbose: - print("PASSED - All changes by Claude are properly tracked") + print( + f"PASSED - All {len(new_changes)} change(s) against the original " + "are properly tracked" + ) return True + def _tracked_change_elements(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] + + def _rendered_text(self, elem): + preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" + return rendered_text(elem.text or "", preserve) + + def _text_elements(self, elem): + w = self.namespaces["w"] + return [ + node + for node in elem.iter() + if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") + ] + + def _tracked_change_key(self, elem): + w = self.namespaces["w"] + text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) + return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) + + def _new_tracked_changes(self, original_root, modified_root): + original = self._tracked_change_elements(original_root) + modified = self._tracked_change_elements(modified_root) + + pool = {} + for elem in original: + pool.setdefault(self._tracked_change_key(elem), []).append(elem) + + matched, leftover = set(), [] + for elem in modified: + bucket = pool.get(self._tracked_change_key(elem)) + if bucket: + matched.add(bucket.pop()) + else: + leftover.append(elem) + + def group(elem): + return self._tracked_change_key(elem)[:3] + + def text_of(elems): + return "".join(self._tracked_change_key(e)[3] for e in elems) + + unmatched_original = {} + for elem in original: + if elem not in matched: + unmatched_original.setdefault(group(elem), []).append(elem) + + by_group = {} + for elem in leftover: + by_group.setdefault(group(elem), []).append(elem) + + new = set() + for key, elems in by_group.items(): + rebuilt = text_of(elems) + if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): + continue + new.update(elems) + return new + def _generate_detailed_diff(self, original_text, modified_text): - """Generate detailed word-level differences using git word diff.""" error_parts = [ - "FAILED - Document text doesn't match after removing Claude's tracked changes", + "FAILED - Document text doesn't match after removing the tracked changes", "", "Likely causes:", " 1. Modified text inside another author's or tags", " 2. Made edits without proper tracked changes", " 3. Didn't nest inside when deleting another's insertion", + " 4. Rewrote another author's / and changed its text on", + " the way. A tracked change from the original is recognised by its", + " author, date and text; anything that doesn't reproduce one exactly", + " reads as new, and the text it carried is reported missing.", "", "For pre-redlined documents, use correct patterns:", " - To reject another's INSERTION: Nest inside their ", + " - To reject PART of one: nest around only the runs you reject.", + " Their may be split around it, so long as the pieces keep", + " their author and date and still spell out the same text.", " - To restore another's DELETION: Add new AFTER their ", "", ] - # Show git word diff git_diff = self._get_git_word_diff(original_text, modified_text) if git_diff: error_parts.extend(["Differences:", "============", git_diff]) @@ -137,26 +179,23 @@ class RedliningValidator: return "\n".join(error_parts) def _get_git_word_diff(self, original_text, modified_text): - """Generate word diff using git with character-level precision.""" try: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - # Create two files original_file = temp_path / "original.txt" modified_file = temp_path / "modified.txt" original_file.write_text(original_text, encoding="utf-8") modified_file.write_text(modified_text, encoding="utf-8") - # Try character-level diff first for precise differences result = subprocess.run( [ "git", "diff", "--word-diff=plain", - "--word-diff-regex=.", # Character-by-character diff - "-U0", # Zero lines of context - show only changed lines + "--word-diff-regex=.", + "-U0", "--no-index", str(original_file), str(modified_file), @@ -166,9 +205,7 @@ class RedliningValidator: ) if result.stdout.strip(): - # Clean up the output - remove git diff header lines lines = result.stdout.split("\n") - # Skip the header lines (diff --git, index, +++, ---, @@) content_lines = [] in_content = False for line in lines: @@ -181,13 +218,12 @@ class RedliningValidator: if content_lines: return "\n".join(content_lines) - # Fallback to word-level diff if character-level is too verbose result = subprocess.run( [ "git", "diff", "--word-diff=plain", - "-U0", # Zero lines of context + "-U0", "--no-index", str(original_file), str(modified_file), @@ -209,66 +245,50 @@ class RedliningValidator: return "\n".join(content_lines) except (subprocess.CalledProcessError, FileNotFoundError, Exception): - # Git not available or other error, return None to use fallback pass return None - def _remove_claude_tracked_changes(self, root): - """Remove tracked changes authored by Claude from the XML root.""" + def _remove_tracked_changes(self, root, targets): ins_tag = f"{{{self.namespaces['w']}}}ins" del_tag = f"{{{self.namespaces['w']}}}del" - author_attr = f"{{{self.namespaces['w']}}}author" - # Remove w:ins elements for parent in root.iter(): to_remove = [] for child in parent: - if child.tag == ins_tag and child.get(author_attr) == "Claude": + if child.tag == ins_tag and child in targets: to_remove.append(child) for elem in to_remove: parent.remove(elem) - # Unwrap content in w:del elements where author is "Claude" deltext_tag = f"{{{self.namespaces['w']}}}delText" t_tag = f"{{{self.namespaces['w']}}}t" for parent in root.iter(): to_process = [] for child in parent: - if child.tag == del_tag and child.get(author_attr) == "Claude": + if child.tag == del_tag and child in targets: to_process.append((child, list(parent).index(child))) - # Process in reverse order to maintain indices for del_elem, del_index in reversed(to_process): - # Convert w:delText to w:t before moving for elem in del_elem.iter(): if elem.tag == deltext_tag: elem.tag = t_tag - # Move all children of w:del to its parent before removing w:del for child in reversed(list(del_elem)): parent.insert(del_index, child) parent.remove(del_elem) def _extract_text_content(self, root): - """Extract text content from Word XML, preserving paragraph structure. - - Empty paragraphs are skipped to avoid false positives when tracked - insertions add only structural elements without text content. - """ p_tag = f"{{{self.namespaces['w']}}}p" t_tag = f"{{{self.namespaces['w']}}}t" paragraphs = [] for p_elem in root.findall(f".//{p_tag}"): - # Get all text elements within this paragraph text_parts = [] for t_elem in p_elem.findall(f".//{t_tag}"): - if t_elem.text: - text_parts.append(t_elem.text) + text_parts.append(self._rendered_text(t_elem)) paragraph_text = "".join(text_parts) - # Skip empty paragraphs - they don't affect content validation if paragraph_text: paragraphs.append(paragraph_text) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/office_package.py b/plugins/codex/plugins/docx/skills/docx/scripts/office_package.py new file mode 100644 index 00000000..39b1f27b --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/office_package.py @@ -0,0 +1,52 @@ +"""Unpack or repack an OOXML document without shell-specific ZIP commands.""" + +import argparse +import os +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +def unpack(source, destination): + root = Path(destination).resolve() + if root.exists() and any(root.iterdir()): + raise ValueError("Unpack destination must be empty") + with zipfile.ZipFile(source) as archive: + for info in archive.infolist(): + name = PurePosixPath(info.filename.replace("\\", "/")) + if name.is_absolute() or ".." in name.parts or any(":" in part for part in name.parts): + raise ValueError(f"Unsafe ZIP entry: {info.filename}") + if (info.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"ZIP symlink is not supported: {info.filename}") + archive.extractall(root) + + +def pack(source, destination): + root = Path(source).resolve() + output = Path(destination).resolve() + if output.is_relative_to(root): + raise ValueError("Output must be outside the unpacked directory") + if not (root / "[Content_Types].xml").is_file(): + raise ValueError("Source must be the package root containing [Content_Types].xml") + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(suffix=output.suffix, dir=output.parent) + os.close(fd) + try: + with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: + for file in sorted(root.rglob("*")): + if file.is_symlink(): + raise ValueError(f"Package symlink is not supported: {file}") + if file.is_file(): + archive.write(file, file.relative_to(root).as_posix()) + os.replace(temporary, output) + finally: + Path(temporary).unlink(missing_ok=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("unpack", "pack")) + parser.add_argument("input") + parser.add_argument("output") + args = parser.parse_args() + {"unpack": unpack, "pack": pack}[args.action](args.input, args.output) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/render_pdf.py b/plugins/codex/plugins/docx/skills/docx/scripts/render_pdf.py new file mode 100644 index 00000000..4820bbdc --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/scripts/render_pdf.py @@ -0,0 +1,29 @@ +"""Render PDF pages without Poppler; requires PyMuPDF.""" + +import argparse +from pathlib import Path + +import pymupdf + + +def convert(pdf_path, output_dir, max_dim=1600): + if max_dim <= 0: + raise ValueError("max_dim must be positive") + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + with pymupdf.open(pdf_path) as document: + for index, page in enumerate(document): + scale = min(max_dim / page.rect.width, max_dim / page.rect.height) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False) + target = output / f"page_{index + 1}.png" + pixmap.save(str(target)) + print(target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--max-dim", type=int, default=1600) + args = parser.parse_args() + convert(args.input, args.output, args.max_dim) diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/templates/comments.xml b/plugins/codex/plugins/docx/skills/docx/scripts/templates/comments.xml index b5dace0e..cd01a7d7 100644 --- a/plugins/codex/plugins/docx/skills/docx/scripts/templates/comments.xml +++ b/plugins/codex/plugins/docx/skills/docx/scripts/templates/comments.xml @@ -1,3 +1,3 @@ - + - \ No newline at end of file + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtended.xml b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtended.xml index b4cf23e3..411003cc 100644 --- a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtended.xml +++ b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtended.xml @@ -1,3 +1,3 @@ - + - \ No newline at end of file + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtensible.xml b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtensible.xml index e32a05e0..f5572d71 100644 --- a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtensible.xml +++ b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsExtensible.xml @@ -1,3 +1,3 @@ - + - \ No newline at end of file + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsIds.xml b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsIds.xml index d04bc8e0..32f1629f 100644 --- a/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsIds.xml +++ b/plugins/codex/plugins/docx/skills/docx/scripts/templates/commentsIds.xml @@ -1,3 +1,3 @@ - + - \ No newline at end of file + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/templates/people.xml b/plugins/codex/plugins/docx/skills/docx/scripts/templates/people.xml index a839cafe..3803d2de 100644 --- a/plugins/codex/plugins/docx/skills/docx/scripts/templates/people.xml +++ b/plugins/codex/plugins/docx/skills/docx/scripts/templates/people.xml @@ -1,3 +1,3 @@ - + - \ No newline at end of file + diff --git a/plugins/codex/plugins/docx/skills/docx/scripts/utilities.py b/plugins/codex/plugins/docx/skills/docx/scripts/utilities.py deleted file mode 100644 index d92dae61..00000000 --- a/plugins/codex/plugins/docx/skills/docx/scripts/utilities.py +++ /dev/null @@ -1,374 +0,0 @@ -#!/usr/bin/env python3 -""" -Utilities for editing OOXML documents. - -This module provides XMLEditor, a tool for manipulating XML files with support for -line-number-based node finding and DOM manipulation. Each element is automatically -annotated with its original line and column position during parsing. - -Example usage: - editor = XMLEditor("document.xml") - - # Find node by line number or range - elem = editor.get_node(tag="w:r", line_number=519) - elem = editor.get_node(tag="w:p", line_number=range(100, 200)) - - # Find node by text content - elem = editor.get_node(tag="w:p", contains="specific text") - - # Find node by attributes - elem = editor.get_node(tag="w:r", attrs={"w:id": "target"}) - - # Combine filters - elem = editor.get_node(tag="w:p", line_number=range(1, 50), contains="text") - - # Replace, insert, or manipulate - new_elem = editor.replace_node(elem, "new text") - editor.insert_after(new_elem, "more") - - # Save changes - editor.save() -""" - -import html -from pathlib import Path -from typing import Optional, Union - -import defusedxml.minidom -import defusedxml.sax - - -class XMLEditor: - """ - Editor for manipulating OOXML XML files with line-number-based node finding. - - This class parses XML files and tracks the original line and column position - of each element. This enables finding nodes by their line number in the original - file, which is useful when working with Read tool output. - - Attributes: - xml_path: Path to the XML file being edited - encoding: Detected encoding of the XML file ('ascii' or 'utf-8') - dom: Parsed DOM tree with parse_position attributes on elements - """ - - def __init__(self, xml_path): - """ - Initialize with path to XML file and parse with line number tracking. - - Args: - xml_path: Path to XML file to edit (str or Path) - - Raises: - ValueError: If the XML file does not exist - """ - self.xml_path = Path(xml_path) - if not self.xml_path.exists(): - raise ValueError(f"XML file not found: {xml_path}") - - with open(self.xml_path, "rb") as f: - header = f.read(200).decode("utf-8", errors="ignore") - self.encoding = "ascii" if 'encoding="ascii"' in header else "utf-8" - - parser = _create_line_tracking_parser() - self.dom = defusedxml.minidom.parse(str(self.xml_path), parser) - - def get_node( - self, - tag: str, - attrs: Optional[dict[str, str]] = None, - line_number: Optional[Union[int, range]] = None, - contains: Optional[str] = None, - ): - """ - Get a DOM element by tag and identifier. - - Finds an element by either its line number in the original file or by - matching attribute values. Exactly one match must be found. - - Args: - tag: The XML tag name (e.g., "w:del", "w:ins", "w:r") - attrs: Dictionary of attribute name-value pairs to match (e.g., {"w:id": "1"}) - line_number: Line number (int) or line range (range) in original XML file (1-indexed) - contains: Text string that must appear in any text node within the element. - Supports both entity notation (“) and Unicode characters (\u201c). - - Returns: - defusedxml.minidom.Element: The matching DOM element - - Raises: - ValueError: If node not found or multiple matches found - - Example: - elem = editor.get_node(tag="w:r", line_number=519) - elem = editor.get_node(tag="w:r", line_number=range(100, 200)) - elem = editor.get_node(tag="w:del", attrs={"w:id": "1"}) - elem = editor.get_node(tag="w:p", attrs={"w14:paraId": "12345678"}) - elem = editor.get_node(tag="w:commentRangeStart", attrs={"w:id": "0"}) - elem = editor.get_node(tag="w:p", contains="specific text") - elem = editor.get_node(tag="w:t", contains="“Agreement") # Entity notation - elem = editor.get_node(tag="w:t", contains="\u201cAgreement") # Unicode character - """ - matches = [] - for elem in self.dom.getElementsByTagName(tag): - # Check line_number filter - if line_number is not None: - parse_pos = getattr(elem, "parse_position", (None,)) - elem_line = parse_pos[0] - - # Handle both single line number and range - if isinstance(line_number, range): - if elem_line not in line_number: - continue - else: - if elem_line != line_number: - continue - - # Check attrs filter - if attrs is not None: - if not all( - elem.getAttribute(attr_name) == attr_value - for attr_name, attr_value in attrs.items() - ): - continue - - # Check contains filter - if contains is not None: - elem_text = self._get_element_text(elem) - # Normalize the search string: convert HTML entities to Unicode characters - # This allows searching for both "“Rowan" and ""Rowan" - normalized_contains = html.unescape(contains) - if normalized_contains not in elem_text: - continue - - # If all applicable filters passed, this is a match - matches.append(elem) - - if not matches: - # Build descriptive error message - filters = [] - if line_number is not None: - line_str = ( - f"lines {line_number.start}-{line_number.stop - 1}" - if isinstance(line_number, range) - else f"line {line_number}" - ) - filters.append(f"at {line_str}") - if attrs is not None: - filters.append(f"with attributes {attrs}") - if contains is not None: - filters.append(f"containing '{contains}'") - - filter_desc = " ".join(filters) if filters else "" - base_msg = f"Node not found: <{tag}> {filter_desc}".strip() - - # Add helpful hint based on filters used - if contains: - hint = "Text may be split across elements or use different wording." - elif line_number: - hint = "Line numbers may have changed if document was modified." - elif attrs: - hint = "Verify attribute values are correct." - else: - hint = "Try adding filters (attrs, line_number, or contains)." - - raise ValueError(f"{base_msg}. {hint}") - if len(matches) > 1: - raise ValueError( - f"Multiple nodes found: <{tag}>. " - f"Add more filters (attrs, line_number, or contains) to narrow the search." - ) - return matches[0] - - def _get_element_text(self, elem): - """ - Recursively extract all text content from an element. - - Skips text nodes that contain only whitespace (spaces, tabs, newlines), - which typically represent XML formatting rather than document content. - - Args: - elem: defusedxml.minidom.Element to extract text from - - Returns: - str: Concatenated text from all non-whitespace text nodes within the element - """ - text_parts = [] - for node in elem.childNodes: - if node.nodeType == node.TEXT_NODE: - # Skip whitespace-only text nodes (XML formatting) - if node.data.strip(): - text_parts.append(node.data) - elif node.nodeType == node.ELEMENT_NODE: - text_parts.append(self._get_element_text(node)) - return "".join(text_parts) - - def replace_node(self, elem, new_content): - """ - Replace a DOM element with new XML content. - - Args: - elem: defusedxml.minidom.Element to replace - new_content: String containing XML to replace the node with - - Returns: - List[defusedxml.minidom.Node]: All inserted nodes - - Example: - new_nodes = editor.replace_node(old_elem, "text") - """ - parent = elem.parentNode - nodes = self._parse_fragment(new_content) - for node in nodes: - parent.insertBefore(node, elem) - parent.removeChild(elem) - return nodes - - def insert_after(self, elem, xml_content): - """ - Insert XML content after a DOM element. - - Args: - elem: defusedxml.minidom.Element to insert after - xml_content: String containing XML to insert - - Returns: - List[defusedxml.minidom.Node]: All inserted nodes - - Example: - new_nodes = editor.insert_after(elem, "text") - """ - parent = elem.parentNode - next_sibling = elem.nextSibling - nodes = self._parse_fragment(xml_content) - for node in nodes: - if next_sibling: - parent.insertBefore(node, next_sibling) - else: - parent.appendChild(node) - return nodes - - def insert_before(self, elem, xml_content): - """ - Insert XML content before a DOM element. - - Args: - elem: defusedxml.minidom.Element to insert before - xml_content: String containing XML to insert - - Returns: - List[defusedxml.minidom.Node]: All inserted nodes - - Example: - new_nodes = editor.insert_before(elem, "text") - """ - parent = elem.parentNode - nodes = self._parse_fragment(xml_content) - for node in nodes: - parent.insertBefore(node, elem) - return nodes - - def append_to(self, elem, xml_content): - """ - Append XML content as a child of a DOM element. - - Args: - elem: defusedxml.minidom.Element to append to - xml_content: String containing XML to append - - Returns: - List[defusedxml.minidom.Node]: All inserted nodes - - Example: - new_nodes = editor.append_to(elem, "text") - """ - nodes = self._parse_fragment(xml_content) - for node in nodes: - elem.appendChild(node) - return nodes - - def get_next_rid(self): - """Get the next available rId for relationships files.""" - max_id = 0 - for rel_elem in self.dom.getElementsByTagName("Relationship"): - rel_id = rel_elem.getAttribute("Id") - if rel_id.startswith("rId"): - try: - max_id = max(max_id, int(rel_id[3:])) - except ValueError: - pass - return f"rId{max_id + 1}" - - def save(self): - """ - Save the edited XML back to the file. - - Serializes the DOM tree and writes it back to the original file path, - preserving the original encoding (ascii or utf-8). - """ - content = self.dom.toxml(encoding=self.encoding) - self.xml_path.write_bytes(content) - - def _parse_fragment(self, xml_content): - """ - Parse XML fragment and return list of imported nodes. - - Args: - xml_content: String containing XML fragment - - Returns: - List of defusedxml.minidom.Node objects imported into this document - - Raises: - AssertionError: If fragment contains no element nodes - """ - # Extract namespace declarations from the root document element - root_elem = self.dom.documentElement - namespaces = [] - if root_elem and root_elem.attributes: - for i in range(root_elem.attributes.length): - attr = root_elem.attributes.item(i) - if attr.name.startswith("xmlns"): # type: ignore - namespaces.append(f'{attr.name}="{attr.value}"') # type: ignore - - ns_decl = " ".join(namespaces) - wrapper = f"{xml_content}" - fragment_doc = defusedxml.minidom.parseString(wrapper) - nodes = [ - self.dom.importNode(child, deep=True) - for child in fragment_doc.documentElement.childNodes # type: ignore - ] - elements = [n for n in nodes if n.nodeType == n.ELEMENT_NODE] - assert elements, "Fragment must contain at least one element" - return nodes - - -def _create_line_tracking_parser(): - """ - Create a SAX parser that tracks line and column numbers for each element. - - Monkey patches the SAX content handler to store the current line and column - position from the underlying expat parser onto each element as a parse_position - attribute (line, column) tuple. - - Returns: - defusedxml.sax.xmlreader.XMLReader: Configured SAX parser - """ - - def set_content_handler(dom_handler): - def startElementNS(name, tagName, attrs): - orig_start_cb(name, tagName, attrs) - cur_elem = dom_handler.elementStack[-1] - cur_elem.parse_position = ( - parser._parser.CurrentLineNumber, # type: ignore - parser._parser.CurrentColumnNumber, # type: ignore - ) - - orig_start_cb = dom_handler.startElementNS - dom_handler.startElementNS = startElementNS - orig_set_content_handler(dom_handler) - - parser = defusedxml.sax.make_parser() - orig_set_content_handler = parser.setContentHandler - parser.setContentHandler = set_content_handler # type: ignore - return parser diff --git a/plugins/codex/plugins/docx/skills/docx/upstream-guide.md b/plugins/codex/plugins/docx/skills/docx/upstream-guide.md new file mode 100644 index 00000000..fb954a46 --- /dev/null +++ b/plugins/codex/plugins/docx/skills/docx/upstream-guide.md @@ -0,0 +1,91 @@ +--- +name: docx +description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files) or Word templates (.dotx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', '.dotx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx or .dotx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation." +license: Proprietary. LICENSE.txt has complete terms +--- + +# DOCX creation, editing, and analysis + +A `.docx` is a ZIP archive of XML files. Choose your approach by task: + +| Task | Approach | +|---|---| +| **Create** a new document | Write a `docx` (npm) script — see gotchas below | +| **Edit** an existing document | `unzip` → edit `word/document.xml` → `zip` (docx-js cannot open existing files) | +| **Read** content | `pandoc -t markdown file.docx` | + +> Script paths below are relative to this skill's directory. + +## Creating with docx-js — gotchas + +`docx` is preinstalled — do not run `npm install` first; write the script and `require('docx')` directly. Only if that require fails: `npm install docx`. The model knows the API; these are the footguns: + +- **Page size defaults to A4.** For US Letter set `page: { size: { width: 12240, height: 15840 } }` (DXA; 1440 = 1″). +- **Landscape:** pass portrait dimensions and `orientation: PageOrientation.LANDSCAPE` — docx-js swaps width/height internally. +- **Tables need dual widths:** set `columnWidths` on the table AND `width` on every cell, both in `WidthType.DXA` (PERCENTAGE breaks in Google Docs). Column widths must sum to the table width. +- **Table shading:** use `ShadingType.CLEAR`, never `SOLID` (renders black). +- **Lists:** never insert `•` literally; use a `numbering` config with `LevelFormat.BULLET`. +- **`ImageRun` requires `type:`** (`"png"`, `"jpg"`, …). +- **`PageBreak` must be inside a `Paragraph`.** +- **Never use `\n`** — use separate `Paragraph` elements. +- **TOC:** headings must use built-in `HeadingLevel.*`; custom heading styles need `outlineLevel` set or they won't appear. +- **Don't use a table as a horizontal rule** — use a paragraph bottom border instead. +- **Dot-leader / right-aligned-on-same-line:** use `PositionalTab` (`alignment: PositionalTabAlignment.RIGHT`, `leader: PositionalTabLeader.DOT`) inside a `TextRun`, not literal `.` or space padding. + +## Verify the output + +After writing a `.docx`, render it and look at it: + +```bash +python scripts/office/soffice.py --headless --convert-to pdf output.docx +pdftoppm -jpeg -r 100 output.pdf page +ls page-*.jpg # then Read the images +``` + +`pdftoppm` zero-pads page numbers to the width of the page count (`page-01.jpg`…`page-12.jpg`). + +## Editing existing documents + +Legacy `.doc` files must be converted first: `python scripts/office/soffice.py --headless --convert-to docx file.doc`. + +```bash +unzip -q doc.docx -d unpacked/ +find unpacked -type l -delete # strip symlink entries — docx from external parties is untrusted +python scripts/merge_runs.py unpacked/ # coalesce fragmented runs so text is findable +# edit unpacked/word/document.xml in place — do NOT reformat or pretty-print +(cd unpacked && rm -f ../out.docx && zip -Xr ../out.docx .) +python scripts/office/validate.py out.docx --original doc.docx # XSD checks; --auto-repair fixes common issues +# redlining? add --author "" to check every edit is tracked +``` + +Word splits text across many `` runs (revision ids, spell-check markers), so a phrase you can see in the document often doesn't exist as a contiguous string in the XML. `merge_runs.py` merges adjacent identically-formatted runs in `word/document.xml` without changing content or rendering; it also accepts a `.docx` directly (`python scripts/merge_runs.py doc.docx -o merged.docx`). + +**Tracked changes:** when redlining, validate with `--author ""` (needs `--original`) — it reports any text you changed without a ``/`` around it, which is easy to do by accident and invisible in the accepted view. Wrap runs in ``/`` with `w:id`, `w:author`, `w:date` attributes. Inside ``, the text element is ``, not ``. A deleted paragraph mark (``) means "merge this paragraph into the next" — so deleting a paragraph outright is that plus a `` around every run. The `` must come before the rPr's other children; their order is schema-enforced. + +To produce a clean copy with all tracked changes accepted: `python scripts/accept_changes.py in.docx out.docx`. + +Accepting a deleted paragraph mark should join that paragraph to the one below it, so a paragraph whose runs are *all* deleted vanishes. Word does this; `accept_changes.py` and `pandoc --track-changes=accept` don't always. Both fail the same way — they strip the deleted text but leave the emptied paragraph behind, which reads as a stray empty bullet when it was auto-numbered: + +- `pandoc --track-changes=accept` never joins the paragraphs. +- `accept_changes.py` (LibreOffice) joins them correctly, except when the deleted paragraph is followed by an empty spacer paragraph. + +An empty bullet in either view is an artifact of that view, not a defect in the document. Check paragraph deletions in the XML. + +## Comments + +Comments require six cross-linked files. Use the helper — directory mode when you'll also be editing `document.xml` (saves an unzip/rezip cycle), `.docx`-direct mode otherwise: + +```bash +# Against an already-unpacked directory (preferred when also placing markers) +python scripts/comment.py unpacked/ "Fees & expenses cap is too low" +python scripts/comment.py unpacked/ "Agreed" --parent 0 + +# Against a .docx directly +python scripts/comment.py contract.docx "This cap is too low" -o annotated.docx +``` + +The script writes `comments.xml`, `commentsExtended.xml`, `commentsIds.xml`, `commentsExtensible.xml`, the relationships, and the content-type overrides. Comment IDs are auto-assigned. It then prints the ``/``/`` snippet to add to `word/document.xml` so the comment anchors to specific text — until you place those markers, the comment exists but is not visible. + +## Dependencies + +`docx` (npm, preinstalled — install only if `require('docx')` fails) · `pandoc` · LibreOffice (`soffice`) · `pdftoppm` (Poppler) diff --git a/plugins/codex/plugins/eapil-gpt2-image/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-gpt2-image/.codex-plugin/plugin.json index 8f2fb94b..974c12af 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/eapil-gpt2-image/.codex-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "eapil-gpt2-image", - "version": "2.0.0", - "description": "通过 公司提供的 OpenAI 兼容的图像 API 生成或编辑图像时使用,这包括针对 gpt-image-2、/v1/images/generations、/v1/images/edits 的请求,以及使用 Python 图像 API 进行的相关操作,还包括图像提示、掩码的处理、多部分图像的上传、图像文件的保存,以及解码 b64_json 格式的图像响应等内容。", + "version": "1.0.0", + "description": "通过 EAPIL 图像 API 生成、编辑图像或完善提示词,保留精确文字、参考图角色和用户设计意图。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-gpt2-image", + "repository": "https://git.playones.com/chenmingxuan/ManySkills", "license": "Proprietary", "keywords": [ "eapil", @@ -17,8 +17,8 @@ "skills": "./skills/", "interface": { "displayName": "eapil-gpt2-image", - "shortDescription": "通过 公司提供的 OpenAI 兼容的图像 API 生成或编辑图像时使用,这包括针对 gpt-image-2、/v1/images/generations、/v1/images/edits 的请求,以及使用 Python 图像 API 进行的相关操作,还包括图像提示、掩码的处理、多部分图像的上传、图像文件的保存,以及解码 b64_json 格式的图像响应等内容。", - "longDescription": "通过 公司提供的 OpenAI 兼容的图像 API 生成或编辑图像时使用,这包括针对 gpt-image-2、/v1/images/generations、/v1/images/edits 的请求,以及使用 Python 图像 API 进行的相关操作,还包括图像提示、掩码的处理、多部分图像的上传、图像文件的保存,以及解码 b64_json 格式的图像响应等内容。", + "shortDescription": "按需完善提示词,生成或编辑图像,并检查实际输出。", + "longDescription": "使用 EAPIL 的 gpt-image-2 调用别名完成图像生成和参考图编辑;按需补足提示词,协调实际画幅,保留文字与编辑约束,并保存和检查返回图像。", "developerName": "EAPIL", "category": "Creativity", "capabilities": [ diff --git a/plugins/codex/plugins/eapil-gpt2-image/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/eapil-gpt2-image/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..de7796e9 --- /dev/null +++ b/plugins/codex/plugins/eapil-gpt2-image/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "eapil-gpt2-image", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "adapter": "codex-plugin", + "sourcePath": "plugins/eapil-gpt2-image", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md index ebaf1628..4b4d0e13 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/SKILL.md @@ -1,216 +1,34 @@ --- name: eapil-gpt2-image -description: Use when generating or editing images through the EAPIL/Playones OpenAI-compatible Images API, including gpt-image-2, image prompt enhancement, reference-image edits, masks, multipart image uploads, local prompt galleries, saved image files, or decoded b64_json image responses. +description: Generate or edit images through the EAPIL Images API, or refine prompts for that workflow, including exact text and reference-image edits. --- -# EAPIL GPT Image 2 +# EAPIL image generation and editing -## Overview +Use `https://api-n-cd.playones.com/v1` with the provider's `gpt-image-2` alias. An alias does not identify the underlying model build. Public GPT Image 2.5 guidance informs prompting; its model IDs and capabilities are not evidence of EAPIL gateway support. -Call the deployed OpenAI-compatible Images API at `https://api-n-cd.playones.com/v1` for `gpt-image-2` image generation and image editing. The endpoint and model are fixed. The API key must come from the user at runtime, either as a temporary direct key or from an environment variable. +## Follow the requested outcome -This skill includes a local prompt reference library under `references/`, adapted from `https://github.com/wuyoscar/gpt_image_2_skill`, for prompt enhancement before generation. +- **Generate or edit:** Execute a sufficiently specified request. Refine an underspecified prompt only enough to express the user's intent; do not require a separate enhancement interview or approval when the user already asked to generate. Honor requests to review first. +- **Improve a prompt only:** Deliver the prompt without an image API call. If the user requests verbatim execution, preserve their prompt unchanged. +- **Edit:** Inspect supplied images before deciding what to change. Identify each reference's role and send it in that order; state the requested changes and important identity, text, geometry or composition that must remain. An independent Images API call needs the relevant source image again; conversation history alone is not an image input. -## Hard Rules +Ask only for missing decisions that materially affect the result. Do not invent product claims, prices, dates, scientific data or a new visual direction during enhancement. Preserve exact in-image wording and language; Chinese review text is not a request to translate image text. Provide a bilingual comparison when requested or useful, without a mandatory three-section template. -- Use base URL `https://api-n-cd.playones.com/v1`. -- Use model `gpt-image-2`. -- Accept keys in this order: direct `--api-key`, `EAPIL_API_KEY`, then `OPENAI_API_KEY`. -- Recommend `EAPIL_API_KEY` for repeated EAPIL use, but do not refuse generation only because the user supplied a direct key or only has `OPENAI_API_KEY` set. -- Never write real API keys into skill files, source files, docs, commits, screenshots, or logs. -- Never print the full `Authorization` header or full base64 image data. -- Save decoded images into the user's opened project root by default. Do not leave final images in `/tmp` unless the user asks. -- For image edits, use multipart form data. Do not force `Content-Type` in direct `curl`; let `curl -F` set the multipart boundary. -- Before generating or editing, ask whether the user wants prompt enhancement. If enhanced, show the final prompt with Chinese comparison and wait for explicit confirm/approval before calling the API. +For a specific prompt-design gap, read the relevant part of [craft.md](references/craft.md). For inspiration or a requested genre, use [gallery.md](references/gallery.md) to select relevant examples. Detailed prompts and straightforward edits do not need gallery loading. Gallery values, layouts and styles are examples, not defaults. -## API Key Policy +## Execute the request -Preferred long-term setup: +Read [API usage](references/api-usage.md) for commands, parameters, masks, response handling or errors. Prefer the bundled dependency-free [Python helper](scripts/eapil_gpt2_image.py); locate it relative to this skill, then run from the user's project so output paths resolve there. Use `--prompt-file` for long or multiline prompts rather than embedding them in shell code. -```bash -export EAPIL_API_KEY="" -``` +Set the actual `--size` to match the requested canvas; aspect ratio in prose does not change the API parameter. Keep quality and output format separate from visual descriptions. Preserve the existing endpoint and alias unless the user intentionally changes the integration. Do not add official-only parameters or silently replace unsupported settings with a different canvas. -Accepted fallback if already configured: +Keys resolve from `--api-key`, then `EAPIL_API_KEY`, then `OPENAI_API_KEY`. Accept the user's configured source; keep credentials out of files, logs and responses. Use an environment variable for repeated calls. Save final image files into the opened project by default, or the user's requested location. Do not overwrite a source/reference image when producing an edit. -```bash -export OPENAI_API_KEY="" -``` +## Check and deliver -Accepted temporary key for a one-off call: +Open the returned images and check the constraints that matter: exact text, subject, composition, reference identity, edit boundaries and output size; verify actual transparency when requested. File saving alone does not prove image quality. If image inspection is unavailable, report that limit. -```bash -python3 scripts/eapil_gpt2_image.py generate \ - --api-key "" \ - --prompt "A realistic white-and-orange cat near a sunny window" \ - --output white-orange-cat.png -``` +For an observed defect, make a focused correction with the relevant image and preserved constraints. Stay within the user's requested number of outputs or agreed iteration budget; do not start repeated paid retries solely because an idealized result is possible. A timeout can leave a billed generation with an unknown outcome. -When a user pastes a key in chat, use it only for the current command. Recommend moving it to `EAPIL_API_KEY`, but do not block the generation. - -## Prompt Enhancement Workflow - -For every generation or editing request: - -1. Confirm operation: generation or editing. -2. Ask: `Do you want prompt enhancement before I generate/edit the image?` -3. If the user says no, use the user's prompt unchanged. -4. If the user says yes, read `references/gallery.md` to choose the nearest category. -5. Read `references/craft.md` and the relevant `references/gallery-*.md` category file. For hybrid requests, read at most two or three relevant category files. -6. Draft an enhanced prompt using the user's intent, concrete visual constraints, and relevant gallery patterns. -7. Show the enhanced prompt in this exact three-section format: - -```markdown -**API execution prompt** - - -**中文对照** -<中文对照仅供用户确认理解,review only. Do not send this section to the API unless the user explicitly asks to use it.> - -**In-image text** -- "" — , keep exactly -``` - -8. Wait for the user to confirm the prompt is acceptable before API execution. -9. Run the bundled Python script from the user's opened project root unless there is a reason to use direct `curl`. -10. Save returned images as local image files in the project root, using a clear filename. - -For edits, the enhanced prompt must be surgical: state what changes, what stays unchanged, and any identity/layout/text/lighting/pose/composition invariants. - -Visible text inside the generated image must follow the user's requested language. The Chinese comparison is a review aid, not an instruction to translate on-image text. If the user asks for English poster copy, keep that exact English copy in `API execution prompt` and list it under `In-image text`; if the user asks for Simplified Chinese, Traditional Chinese, Japanese, or any other language in the image, preserve that exact language and wording. - -## Prompt References - -- `references/gallery.md`: category routing index for the full local prompt gallery. -- `references/craft.md`: cross-category prompt-craft checklist. -- `references/gallery-*.md`: concrete examples by category, including posters, UI mockups, photography, product, research figures, diagrams, anime, fashion, architecture, and edit workflows. -- `references/openai-cookbook.md`: official OpenAI image prompting guide mirror from the upstream reference set. -- `references/ATTRIBUTION.md`: source and license notes. - -Use these files for prompt enhancement only. Do not import external CLI assumptions from the upstream skill; this skill keeps the EAPIL endpoint, model, and dependency-free script. - -## Python Script - -Prefer the bundled dependency-free Python script for stable calls: `scripts/eapil_gpt2_image.py`. - -Run it from the user's project root so relative `--output` paths save into that project. It resolves the key from `--api-key`, `EAPIL_API_KEY`, or `OPENAI_API_KEY`, calls the fixed endpoint/model, decodes `b64_json` or data URL responses, writes image files, and prints JSON summaries with output paths and byte counts. - -Generation: - -```bash -python3 /root/.config/opencode/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py generate \ - --prompt "A realistic white-and-orange cat sitting near a sunny window, natural light, detailed fur" \ - --output white-orange-cat.png -``` - -Generation with a temporary direct key: - -```bash -python3 /root/.config/opencode/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py generate \ - --api-key "" \ - --prompt "A clean product photo of a white ceramic mug on a walnut desk, soft morning light" \ - --quality high \ - --output white-mug.png -``` - -Edit: - -```bash -python3 /root/.config/opencode/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py edit \ - --image input.png \ - --prompt "Turn the cat's scarf blue while preserving the cat, pose, background, and lighting" \ - --output edited-cat.png -``` - -Multi-reference edit: - -```bash -python3 /root/.config/opencode/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py edit \ - --image person.png \ - --image product.png \ - --prompt "Place the product from image 2 beside the person in image 1. Match lighting and preserve both identities." \ - --output combined.png -``` - -Edit with mask: - -```bash -python3 /root/.config/opencode/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py edit \ - --image input.png \ - --mask mask.png \ - --prompt "Fill the masked region with a small green plant matching the original lighting and perspective" \ - --output masked-edit.png -``` - -Common options: - -| Option | Applies to | Notes | -|---|---|---| -| `--api-key` | both | Temporary one-off key; prefer `EAPIL_API_KEY` for repeated use. | -| `--size` | both | Default `1024x1024`. | -| `--quality` | both | `auto`, `low`, `medium`, `high`; omit when unsure about gateway support. | -| `--n` | both | Multiple outputs are saved as `_0`, `_1`, etc. | -| `--background` | both | Forwarded only when supplied. | -| `--moderation` | generation | Forwarded only when supplied. | -| `--output-format` | both | `png`, `jpeg`, or `webp`. | -| `--output-compression` | both | Forwarded only when supplied. | -| `--user` | both | Optional end-user identifier. | - -## Direct API Calls - -Use `POST /images/generations` with JSON if making a direct request instead of the Python script. - -```bash -curl -sS "https://api-n-cd.playones.com/v1/images/generations" \ - -H "Authorization: Bearer ${EAPIL_API_KEY:-$OPENAI_API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-image-2", - "prompt": "A clean product photo of a white ceramic mug on a walnut desk, soft morning light", - "size": "1024x1024", - "response_format": "b64_json" - }' -``` - -Use `POST /images/edits` with multipart form fields for edits. Include one or more `image` files and optionally a `mask` file. - -```bash -curl -sS "https://api-n-cd.playones.com/v1/images/edits" \ - -H "Authorization: Bearer ${EAPIL_API_KEY:-$OPENAI_API_KEY}" \ - -F "model=gpt-image-2" \ - -F "image=@input.png" \ - -F "prompt=Replace the background with a minimal warm studio backdrop while preserving the subject" \ - -F "size=1024x1024" \ - -F "response_format=b64_json" -``` - -## Error Checks - -- `401` or `403`: missing, expired, or unauthorized key. -- `404`: wrong endpoint path or deployed gateway route unavailable. -- `400`: malformed JSON, unsupported field, missing prompt, invalid multipart form, bad image format, or an optional parameter the gateway does not accept. -- Empty `data`: upstream returned no image result; inspect the response body and request log if available. -- Multipart failures: remove any manual `Content-Type` header and use `curl -F`. - -## Prompt Guidance - -- Generation prompts should specify canvas/aspect, subject, style, composition, lighting, background, exact text, and exclusions. -- Put required in-image text in straight quotes. -- When prompt enhancement is used, provide `API execution prompt`, `中文对照`, and `In-image text` sections before asking for confirmation. -- The Chinese comparison must not translate or replace visible image text unless the user explicitly requested Chinese visible text. -- For posters, UI, research figures, data visualizations, product renders, and Chinese text, use `references/craft.md` plus the closest gallery category before drafting. -- Edit prompts should state both what changes and what must remain unchanged. -- Prefer `response_format=b64_json` for deterministic file saving. - -## Common Mistakes - -- Hardcoding the user's key: always reject this, even for "fully runnable" examples. -- Refusing a user-provided key just because it is not in `EAPIL_API_KEY`. -- Ignoring `OPENAI_API_KEY` when it is already configured. -- Generating immediately after prompt enhancement without user confirm/approval. -- Sending the `中文对照` section to the API when it is only meant for review. -- Translating required `In-image text` away from the user-requested language. -- Printing raw base64 responses: summarize length or save to a file instead. -- Saving generated images outside the project root without telling the user. -- Adding unsupported convenience fields without checking the endpoint behavior. -- Setting multipart `Content-Type` manually in direct `curl`: this can break boundary parsing. +Show saved images inline when supported and provide their local paths. Report material deviations and intentional prompt changes briefly. Do not claim identity preservation, exact text or a particular backend version without evidence. See [current official guidance](references/openai-cookbook.md) for source context and [attribution](references/ATTRIBUTION.md) for the retained gallery. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/ATTRIBUTION.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/ATTRIBUTION.md index ea1b8de4..d4153979 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/ATTRIBUTION.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/ATTRIBUTION.md @@ -6,6 +6,8 @@ The prompt craft and gallery reference files in this directory are adapted from: - Upstream skill path: `skills/gpt-image/references/` - Retrieved for local integration on 2026-05-01 +Maintenance review: 2026-09-30. The gallery entries and their original source labels remain retained. `craft.md` has been rewritten as this repository's task-dependent guidance, removing mandatory case remixing and unsupported prompt rituals. The former `openai-cookbook.md` mirror has been replaced by a dated, linked summary of current official guidance; its historical MIT provenance is preserved there. These updates do not establish the model behind the EAPIL alias. + The upstream repository describes its prompt patterns and gallery documentation as CC BY 4.0 unless a referenced upstream source states otherwise. Some individual gallery entries preserve author/source metadata from community examples or OpenAI Cookbook examples. Keep those source labels when adapting examples into prompts, docs, or generated prompt workflows. These files are local prompt references only. Execution behavior for this skill remains defined by `../SKILL.md` and `../scripts/eapil_gpt2_image.py`, including the fixed EAPIL base URL and `gpt-image-2` model. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/api-usage.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/api-usage.md new file mode 100644 index 00000000..8c87787c --- /dev/null +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/api-usage.md @@ -0,0 +1,71 @@ +# EAPIL Images API usage + +## Integration boundary + +The helper uses base URL `https://api-n-cd.playones.com/v1` and model alias `gpt-image-2`: JSON `POST /images/generations`, multipart `POST /images/edits`, requesting `b64_json`. These are this integration's existing contract. The provider may change the underlying model without changing its alias; neither an image's appearance nor public OpenAI documentation proves that mapping. + +Reviewed against public documentation on 2026-09-30. Keep public model capabilities separate from verified gateway behavior. The script forwards explicit optional parameters; forwarding is not evidence the provider supports them. Do not change the alias to `gpt-image-2.5-*` or add new quality values merely because they exist in the public API. + +## Commands + +Resolve `` from this installed skill's location. Run from the user's opened project. Commands below use PowerShell and `python`; use the interpreter available on the target host. This repository requires `rtk proxy` before external commands. + +Generation: + +```powershell +python '/scripts/eapil_gpt2_image.py' generate --prompt 'A small white ceramic vase, on the supplied brand background, soft side light.' --size 1024x1536 --output vase.png +``` + +Long, multiline or multilingual text: + +```powershell +python '/scripts/eapil_gpt2_image.py' generate --prompt-file prompt.txt --size 960x1280 --output menu.png +``` + +`prompt.txt` must contain the actual execution prompt in UTF-8, with an optional BOM. `--prompt` and `--prompt-file` are mutually exclusive. The helper does not translate or enhance the text. A review translation belongs outside this file. Create it using a file-writing tool, rather than shell interpolation of user text. In verbatim mode, do not rewrite the supplied prompt. + +Reference edit, with order matching the prompt's roles: + +```powershell +python '/scripts/eapil_gpt2_image.py' edit --image scene.png --image jacket.png --prompt-file edit-prompt.txt --size 1536x1024 --output scene-edited.png +``` + +Optional localized edit: + +```powershell +python '/scripts/eapil_gpt2_image.py' edit --image source.png --mask mask.png --prompt 'Change only the selected scarf to blue; preserve the person, pose, text, background and lighting.' --output scarf-edited.png +``` + +For direct multipart calls, let the client set the boundary; do not force a boundary-less Content-Type. The helper handles the boundary itself. The public guide requires matching image/mask format and dimensions and an alpha channel in the mask; when there are multiple images, the mask applies to the first. Prepare a PNG base and same-dimension PNG alpha mask for portability, with transparent areas marking the edit region. Confirm the provider accepts masks. A mask is guidance, not a guarantee that all outside pixels remain identical. + +## Parameters and canvas + +| Option | Behavior | +|---|---| +| `--api-key` | Temporary explicit key; otherwise `EAPIL_API_KEY`, then `OPENAI_API_KEY`. Prefer an environment variable so the key is not in command history. | +| `--size` | Default `1024x1024`; set explicitly for another canvas. The helper does not infer it from the prompt. | +| `--quality` | Existing choices `auto`, `low`, `medium`, `high`; absent means omitted. | +| `--n` | Positive number of requested outputs; default 1. Returned multiples use `_0`, `_1`, etc. | +| `--output-format` | `png`, `jpeg`, `webp`; default filename uses the selected format, or png. Match an explicit filename's extension to the expected format. | +| `--output-compression` | Integer 0–100; optional, provider-dependent. | +| `--background` | Optional forwarded field; transparent output depends on actual backend support and a compatible format. | +| `--moderation` | Optional, generation only. | +| `--user` | Optional end-user identifier. | + +Orientation examples: `1024x1536` is 2:3 portrait, `1536x1024` is 3:2 landscape. They are not exact 3:4 or 16:9 replacements. Under published GPT Image dimension rules, `960x1280` matches 3:4 and `1536x864` matches 16:9. Custom sizes still need gateway support; these examples are not live EAPIL verification. + +Public custom-size limits include multiples of 16, ratios within 1:3–3:1, maximum edge 3840 and 655,360–8,294,400 total pixels; sizes above 2560x1440 are experimental. These are upstream constraints, not a promise the gateway exposes every size. If a required size is rejected, report the conflict and choose a user-acceptable alternative; do not silently distort or crop the brief. Prompt words such as "4K" or "8K" cannot change request dimensions. + +Source: [OpenAI image generation](https://developers.openai.com/api/docs/guides/image-generation). + +## Results and recovery + +The helper saves Base64 or Base64 image data URLs. Remote image URLs are not downloaded. It checks decoded data for nonempty content and recognized PNG/JPEG/WebP signatures; this is not full image decoding or visual verification. Inspect the saved output with an image viewer. Check actual dimensions, text and any requested alpha channel using available image tools. + +- `401`/`403`: check configured key and access without printing it. +- `400`: inspect the relevant error for unsupported size/optional fields, invalid input or multipart issues. Do not drop user-critical constraints as an automatic fallback. +- `404`: provider route or model availability may differ; verify deployment rather than guessing another model ID. +- Empty/malformed image data: report the failure; do not present an empty file as success. +- Timeout/transport failure: the outcome may be unknown. Check provider status/logs where available before resending a potentially billed request. + +No automatic network retry is built in. Keep new calls within the requested output/iteration budget. Show concise errors rather than raw responses containing credentials, image data or echoed private content. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/craft.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/craft.md index 5902601d..6a5e1258 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/craft.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/craft.md @@ -1,355 +1,43 @@ -# GPT Image 2 Prompt Craft +# Image prompt craft -Cross-cutting principles distilled from the full 162-prompt Gallery Atlas. Use this file as the prompt-design checklist; use `gallery.md` as the routing index for the split concrete Scale/case atlas as `gallery-*.md` files. +Use a section only when it resolves a gap in the current task. These are repository recommendations, informed by [official guidance](openai-cookbook.md) and the attributed [gallery](gallery.md), not a model-specific syntax or a guarantee of results. -## Table of contents +## Refine without replacing the brief -0. Use the Scale before writing from scratch -1. Exact text goes in quotes -2. Put canvas, aspect ratio, and layout before subject -3. JSON / config-style prompts are a core pattern -4. Use fixed-region schemas for infographics and educational boards -5. Research/data figures need diagram grammar -6. UI prompts should read like product specs -7. Multi-panel boards need consistency constraints -8. Camera and capture context unlock photorealism -9. Scene density beats adjectives -10. Style anchors should be specific and bounded -11. Promotional hierarchy for commercial posters -12. Material, lighting, and palette are separate controls -13. Edit endpoint prompts must preserve invariants -14. Negation is for strong priors -15. Category-specific mini-schemas -16. Dense Chinese and multilingual layouts need extra constraints -17. Attribution and gallery metadata -18. Safety and copyright notes +Keep the user's subject, intended use, language, style and hard constraints. Add concrete information only where it helps the image meet that brief: a focal subject, an important spatial relationship, a material or a lighting condition. Leave creative latitude where the user did. More scene objects and adjectives are not automatically better. -## 0. Use the Scale before writing from scratch +Natural language is sufficient. Short labeled sections help complex layouts; JSON is optional when the user wants reusable structured input. A schema's version, render flags or the words "8K" do not configure image resolution. Set actual size and quality through the API. Do not require English translation, a noun quota, a fixed prompt order or a prescribed number of examples. -Before drafting a prompt, open `gallery.md` as the category index, then read 3–8 nearby cases from the matching `gallery-.md` file. The skill should not behave like a bare CLI wrapper: it should remix the repo's collected patterns. +When a visual reference is useful, describe the attributes to transfer: palette, linework, camera position, texture, spacing or medium. Do not copy an example's unrelated brand, text, numbers, components or composition into the user's request. -Fast routing examples: -- Anime/manga or multi-character boards → No. 1–12. -- Product/food commercial render, especially structured config prompts → No. 56–58. -- Brand, poster, typography, dense Chinese copy → No. 33–44, 59–61, 66–73. -- Research, data, technical, scientific figures → No. 75–95, 107–128. -- UI / app / dashboard mockups → No. 102–106. -- Edit endpoint / reference transformations → No. 100–101. +## Text and layout -## 1. Exact text goes in quotes +Quote the exact visible strings and associate each with a location or hierarchy. Keep supplied punctuation, prices, units and language verbatim. Say whether additional copy is allowed. If line breaks or repeated appearances matter, specify them. Reserve adequate space and contrast for required text rather than adding decorative microtext. -GPT Image 2 renders typography well when literal copy is explicit. +Repository example, for a brief that supplies these facts: -Weak: -> Create a tea poster with the brand name and promo copy. +> Create a portrait tea menu. At the top display "山川茶事"; below it display "冷泡系列". Show two clearly separated entries: "中杯 16 元" and "大杯 19 元". Preserve these exact strings. Use the supplied brand palette and keep the menu readable at its intended display size. Add no other copy. -Strong: -> Design a 3:4 vertical poster. The poster must accurately display the following exact Chinese copy: "山川茶事" / "冷泡系列" / "中杯 16 元" / "大杯 19 元". +The API canvas must also be portrait. When the user requires an exact ratio, choose matching dimensions supported by the gateway; do not silently substitute a different ratio. If only orientation is specified, established portrait/landscape sizes can be suitable. -Rules: -- Wrap every displayed string in `"…"`. -- Keep user-supplied Chinese verbatim; do not paraphrase. -- Separate text blocks with `/`, bullets, or layout labels. -- For dense text, include title, subtitle, module labels, legend labels, numbers, fine print, and any required axes/tabs. -- If text is only decorative, say so. If it must be readable, say `crisp`, `legible`, `large enough`, and `no garbled characters`. +For information graphics, specify the important zones, labels and relationships. For data or research figures, use supplied data and explicit visual encodings; inspect values, axes and arrow directions after generation. Model-generated charts are not a substitute for precise plotting when the output must preserve quantitative data exactly. -## 2. Put canvas, aspect ratio, and layout before subject +For UI mockups, use the product's supplied design rules and real copy. Describe relevant hierarchy and states; do not add a default dashboard of rounded cards or fictional account values. A raster mockup is not an implemented interface. -The strongest gallery prompts allocate space before describing surface detail. +For a multi-panel sheet, specify panel count/order and shared identity or style only where needed. Do not overpack a small canvas with more panels or copy than can remain readable. -Useful first clauses: -- `Landscape 16:9 academic concept figure…` (No. 75–95). -- `Design a 3:4 vertical poster…` (No. 33–45). -- `Create a square 3×3 grid…` (No. 22, No. 25). -- `A 6-panel film storyboard laid out as a 3×2 grid…` (No. 28). -- `Create one tall manga chapter proof sheet containing 19 numbered miniature pages…` (No. 10). +## Photography and product images -When structure matters, state the structure before the subject. Otherwise the model spends detail budget on the object and improvises the layout. +Use meaningful capture and material details when they serve the brief: viewpoint, subject scale, light direction, contact shadows, surface finish or depth of field. Avoid conflicting camera descriptions. Preserve approved packaging, logos and product geometry from references; do not invent marketing claims or labels. -## 3. JSON / config-style prompts are a core pattern +A request for an informal photo does not require a named phone or lens. A request for a sparse scene does not require a quota of background objects. Use targeted exclusions for likely errors rather than a long universal negative prompt. -Do not omit this. The gallery uses JSON-like structured prompts for premium product and food rendering (No. 57, No. 58). This pattern works when the output has many interacting systems: environment, subject, materials, lighting, particles, motion, and render goals. +## Reference edits -When to use: -- Product hero renders with material/lighting precision. -- Food photography with suspended ingredients or motion. -- Complex scenes where you want a controllable schema rather than prose. -- Any prompt that benefits from reusable slots. +Inspect the actual inputs. Distinguish the base image from subject, style, garment, logo or layout references. List roles by input order and use repeated `--image` arguments in that order. For example, if requested: -Recommended schema: +> Image 1 is the base scene; image 2 supplies only the jacket. Replace the jacket worn by the person in image 1 with the jacket from image 2. Preserve the person's face, pose, other clothing, background, camera angle and existing text. Match the base scene's lighting and contact shadows. -```text -/* PRODUCT_RENDER_CONFIG: Short Name - VERSION: 1.0.0 - AESTHETIC: Premium Commercial Photography */ -{ - "GLOBAL_SETTINGS": { - "aspect_ratio": "2:3 vertical", - "style": "hyper-realistic commercial photography", - "clarity": "sharp foreground, micro-texture visibility", - "render_flags": ["8K_UHD", "sharp_foreground", "editorial_finish"] - }, - "ENVIRONMENT": { - "background": "warm gradient studio backdrop", - "lighting": "directional softbox with glossy highlights", - "atmosphere": ["floating particles", "cinematic bokeh"] - }, - "CORE_ASSETS": { - "primary_subject": "hero product", - "materials": ["brushed metal", "condensation", "paper label"], - "composition": "diagonal zero-gravity arrangement" - }, - "MOTION_OR_DETAIL_SYSTEMS": [ - { "object": "ingredient fragments", "state": "suspended mid-air" }, - { "object": "liquid splash", "behavior": "thick glossy arc" } - ], - "OUTPUT": { - "mood": "premium, indulgent, editorial", - "avoid": ["cheap e-commerce banner", "plastic CGI", "fake brand logos"] - } -} -``` +For local edits, identify the target object/region and the surrounding elements to preserve. Use a mask when available and appropriate; it guides editing but does not promise pixel-perfect isolation. See [API usage](api-usage.md) for mask requirements. A new request does not automatically remember previous Images API inputs; pass the chosen base or latest accepted output again. -Craft rules: -- Keys should describe visual subsystems, not implementation internals. -- A short header comment can carry `VERSION:` and `AESTHETIC:`. The version is not for code execution; it makes the prompt feel like a deliberate spec and helps future agents compare variants. -- Values should be concrete visual constraints, not vague praise. -- Arrays are good for visible elements; nested objects are good for materials, physics, lighting, and output goals. -- Use `render_flags` / `quality_flags` for output-level constraints such as `8K_UHD`, `sharp_foreground`, `micro_texture`, `editorial_finish`, or `no_CGI_tell`. -- JSON does not have to be machine-valid if comments help the model, but keep it clean and readable. -- Still include aspect ratio and output mood inside the schema. - -## 4. Use fixed-region schemas for infographics and educational boards - -Several high-performing prompts are not just descriptions; they are layout contracts. See the museum catalog disassembly infographic (No. 68), field guides (No. 69, No. 74), travel/cooking cards (No. 70, No. 72), and anatomy/science posters (No. 122–127). - -Pattern: -1. Name the artifact type: `museum catalog-style Chinese disassembly infographic`, `field guide`, `classroom wall chart`. -2. Define the fixed layout zones: top title, left disassembly, right summary, bottom legend, etc. -3. Specify annotation behavior: lead lines, numbered labels, close-up details, material notes. -4. Specify style boundary: museum board, scientific poster, editorial card — not generic poster / anime / e-commerce. -5. Add exact label text where correctness matters. - -This is stronger than saying “make an infographic about X”. - -Educational anatomy / science poster pattern: -- Name the subject exactly (`human muscular system`, `periodic table spectral variant`, `geological strata cross-section`). -- Include view constraints where relevant, e.g. `anterior and posterior views` for anatomy. -- Add scale/context notes such as `Adult height reference 175 cm` when the prompt needs scientific believability. -- Require classroom-wall-chart clarity: clean hierarchy, thin labels, legend, muted academic palette, and no gore/excessive realism. - -## 5. Research/data figures need diagram grammar - -For academic and technical figures, use the language of diagrams, not illustration only. - -Include: -- Orientation and venue style: `Landscape 16:9`, `NeurIPS camera-ready`, `conference-paper figure`. -- Structural primitives: columns, zones, stacks, panels, nodes, ribbons, heatmaps, bars, dashed dividers. -- Directed relationships: arrows, residual arcs, feedback loops, dashed attack paths, numbered flow markers. -- Exact labels: module names, axes, legend values, titles, subtitles. -- Visual semantics: color meanings, line styles, thickness ∝ quantity, benign vs attack flows. -- Cleanliness constraints: `large readable labels`, `white background`, `uncluttered`, `publication-grade`. - -Examples: -- No. 79 uses left/right encoder-decoder columns with exact block labels. -- No. 81 uses zones, worker nodes, tool registry, memory panels, and trace timeline. -- No. 86 uses Sankey source blocks, processing blocks, final splits, and proportional ribbons. -- No. 95 uses four columns plus benign/injection arrow semantics. - -Security / agent-safety figure pattern: -- Show the attack surface as an explicit visual object, e.g. `Attacker-controlled document`, `Public Slack message`, or `Web page`. -- If illustrating prompt injection, quote the payload visibly as a harmless example string such as ``, then label it `injected instructions` / `payload`. -- Separate benign flow and attack flow with line semantics: solid slate-gray for benign, dashed terracotta/red for injection path. -- Keep the figure explanatory/defensive; do not turn the payload into operational instructions. - -Data visualization mini-schema (No. 107–111): -- Name the chart family first: `small-multiples grid`, `network graph`, `chord diagram`, `treemap`, `geographic choropleth`. -- Specify canvas and structure: `4×3`, node groups, ribbon groups, nested rectangles, map regions. -- Provide exact labels, panel names, legend values, units, and axis labels. -- Explain visual encoding: line = temperature, bars = precipitation, ribbon thickness ∝ flow, color = category/region/value. -- Require consistent scales/alignment across repeated panels. -- Keep styling editorial and readable: white background, generous margins, restrained palette, publication-grade labels. - -## 6. UI prompts should read like product specs - -The UI/UX examples (No. 102–106) succeed because they specify product context, device frame, information architecture, real copy, and data. - -Pattern: -- Fictional product name to avoid real-brand leakage. -- Device/canvas: `1290x2796 smartphone screen`, `16:10 monitor canvas`. -- Palette and component system. -- Top header phrasing: explicitly name the header area and its copy, e.g. `Include a top header with the in-image text "AURAE", "Good morning, Lina", and "Total balance $12,480.36"`. -- Cards, charts, nav, transaction/activity rows. -- Exact values and labels: balances, percentages, axis labels, button names. -- Quality constraints: `crisp typography`, `clean spacing`, `precise icon alignment`, `production-quality mockup`. - -Avoid generic UI words alone (`modern`, `clean`, `beautiful`). Add rows, labels, charts, and plausible product data. - -## 7. Multi-panel boards need consistency constraints - -For grids, proof sheets, storyboards, character sheets, and worldbuilding boards, the key is not “many images”; it is coherence across panels. - -Examples: -- No. 5: 16-panel expression grid. -- No. 9: ten-panel character grid. -- No. 10: 19-page manga proof sheet. -- No. 22: 3×3 dark-fantasy worldbuilding set. -- No. 28: 6-panel storyboard with shot/camera metadata. -- No. 31: official character reference sheet. -- No. 107: 4×3 small-multiples data grid with consistent axes. - -Rules: -- State the grid/page count exactly: `3×3`, `4×3`, `3×2`, `16-panel`, `19 numbered miniature pages`. -- For data grids, use the exact phrase `small-multiples grid`, specify rows/columns, and require consistent axes/labels across mini-panels. -- Give each panel a role or beat. -- Specify shared art direction, palette, costume motifs, symbols, lighting, and character identity. -- For storyboards, add camera language: WIDE, OTS, CU, low angle, aerial, match cut, pan/tilt/static, duration. -- For character sheets, require front/side/back views, expression variations, parts breakdown, palette, and setting notes. - -## 8. Camera and capture context unlock photorealism - -The strongest photorealistic prompts name how the image was captured, not just that it is realistic. - -Useful phrases: - -| Phrase | Effect | -|---|---| -| `RAW, unprocessed, full iPhone camera quality` | Reduces AI polish; adds casual realism. | -| `amateur iPhone photo` | Tourist / spectator feel. | -| `shot from the crowd at a distance` | Real-event perspective. | -| `eye level with a 28 mm lens feel` | Architectural realism. | -| `low three-quarter angle` | Product/vehicle hero composition. | -| `natural morning side light` | Beauty/lifestyle softness. | - -Pick one dominant capture frame. Too many camera specs can conflict. - -## 9. Scene density beats adjectives - -Vague: `a convenience store at night`. - -Strong: name concrete objects, surfaces, and situational details: freezer stickers, promotional posters, trash cans, entrance mats, glass reflections, shared bikes, water droplets, phone glow, wet asphalt. - -Rule of thumb: include 5–12 concrete nouns for the scene and 2–4 material/lighting constraints. Do not stack empty adjectives like `stunning`, `professional`, `beautiful`, `high quality` without visual anchors. - -## 10. Style anchors should be specific and bounded - -Name an aesthetic, medium, movement, or production context: -- `MAPPA-style digital 2D animation`, `Studio Pierrot Naruto-Shippuden aesthetic`. -- `New Chinese visual style, light-luxury and restrained`. -- `Swiss grid discipline meets friendly risograph community poster`. -- `gongbi-level architectural detail combined with loose ink atmosphere`. -- `traditional Japanese irezumi tattoo aesthetics`. -- `NeurIPS camera-ready style`. - -If using a living studio/IP aesthetic, keep characters original and avoid direct copying where the output is meant for publication. - -## 11. Promotional hierarchy for commercial posters - -Posters, ads, menus, and campaign visuals should specify hierarchy. - -Include: -- Product/event name largest. -- Prefer the wording `Exact readable text:` when every displayed string matters, especially event posters and wayfinding. -- Claim/tagline. -- SKU / variants / modules. -- Prices or dates if relevant. -- CTA or ordering/info module. -- Fine print. -- Distance readability: `legible from a distance`, `clear promotional hierarchy`. -- Add a targeted avoid-line for text failures: `avoid unreadable microtext`, `avoid fake sponsor logos`, `avoid garbled characters`. - -This prevents flat banner layouts where every text block has the same weight. - -Three-glances test for hero posters: -- First glance: the silhouette/theme is immediately recognizable. -- Second glance: the viewer can read the narrative world, product/event promise, or campaign message. -- Third glance: close inspection reveals texture, small labels, background details, and aftertaste. - -Use this for cinematic posters, city posters, fashion/editorial covers, and premium narrative ads where detail must work at multiple viewing distances. - -## 12. Material, lighting, and palette are separate controls - -Do not compress them into “premium”. Split them: -- Materials: brushed steel, brass, ruby jewel accents, travertine, linen, glass thickness, condensation, rice paper. -- Lighting: softbox, rim light, natural morning side light, neon reflections, warm-copper highlights, cold blue-grey evening. -- Palette: muted teal/rust/bone, cream/warm stone/pale green, slate/amber/teal, indigo/red-orange/cream. - -This is especially important for product renders, interiors, architecture, tattoo flash, beauty lifestyle, and technical exploded views. - -Brand/label control: -- For original commercial images, say `No visible brand logos` when you do not want fake trademarks. -- If tiny labels are acceptable but should not hallucinate brands, use `no readable fake labels except a tiny generic mark "AM ROUTINE"`. -- For event posters, avoid fake sponsor strips unless the prompt supplies exact sponsor names. - -## 13. Edit endpoint prompts must preserve invariants - -For `images.edit`, be surgical. State what changes and what must remain unchanged. - -Pattern: -> Make it a winter evening with heavy snowfall, snow dusted on the board and pieces, breath vapor in the air, cold blue-grey lighting, chess position still clearly readable. - -Rules: -- Name the target transformation first. -- Preserve identity/layout/position/readability explicitly. -- If editing a poster/mockup, preserve original text unless translation/replacement is requested. -- Use `-i` reference images; use masks for localized changes. - -Reference-based unlocks: -- `maintain composition and image details consistently` -- `same subject, absolutely consistent in appearance and coloring` -- `in the original positions` -- `change only X; keep everything else the same` - -Multi-reference rule: -- Identify each input by index and role: `Image 1: product photo`, `Image 2: style reference`, `Image 3: logo/packaging`. -- Say exactly how references interact: apply style from Image 2 to subject from Image 1; place logo from Image 3 on the package; preserve layout from Image 1. -- Repeat invariants on each iteration if identity, text, geometry, or brand elements must not drift. - -## 14. Negation is for strong priors - -Use explicit avoid-lines when the model has a likely bad default. - -Examples: -- `Avoid anime style, avoid modern cyberpunk, avoid random fake kanji clutter.` -- `Avoid motorcycle aggression, sci-fi excess, fake brand logos, and toy-like proportions.` -- `Avoid generic festival chaos, fake sponsor logos, and unreadable microtext.` -- `No gore, no body horror, no actual person, no photorealistic skin photo.` - -Avoid lists should be short and targeted. Too many negatives can dominate the prompt. - -## 15. Category-specific mini-schemas - -Reusable category formulas from the atlas: - -- **Anime/Manga:** style anchor + original characters + action/pose + environment + palette + line/cel-shading direction + safety/IP boundary. -- **Gaming:** game-camera context + HUD elements + playable scene detail + screenshot/monitor realism. -- **Cyberpunk/Retro:** board/grid format + named subcharacters/items + neon material vocabulary + original designs. -- **Brand systems:** logo/wordmark + color palette + type system + packaging/social/touchpoints in one showcase board. -- **Photography:** capture device + time/place + ordinary imperfections + real-world props. -- **Architecture/Interior:** room type + camera/lens + materials + light direction + negative space + realistic shadows. -- **Technical illustration:** exploded/cutaway structure + ordered components + numbered callouts + materials + blueprint/plate style. -- **Tattoo:** tattooable placement + linework/shading/color tradition + negative-space gaps + flash-sheet presentation + no real skin photo. Use exact style tokens where helpful, e.g. `BLACK & GREY`, `FOREARM SLEEVE`, `NEGATIVE SPACE`, `traditional Japanese irezumi`, `dark surrealism`. - -## 16. Dense Chinese and multilingual layouts need extra constraints - -For Chinese-heavy outputs: -- Say `Simplified Chinese` or `Traditional Chinese` if it matters. -- Provide all copy exactly. -- Specify layout modules and hierarchy. -- Require readable, neat text without garbled characters, typos, English, or pinyin unless desired. -- Use high quality for final assets. - -Chinese calligraphy or brush-signage prompts should specify style (`brush style`, `calligraphy-style labels`, `rice-paper texture`) and avoid fake clutter. - -## 17. Attribution and gallery metadata - -Every split `gallery-*.md` entry preserves either `Curated` metadata for repo-curated/reworked entries or visible `Author + Source` attribution when it came from an outside source. When adapting an outside-source pattern into README/gallery entries, keep attribution in the metadata/footer. - -Use `Curated` for repo-created, repo-curated, or substantially reworked prompts/images; keep `Author + Source` for outside-source entries. - -## 18. Safety and copyright notes - -- Real-person likeness edits often fail at the API moderation layer; surface the API error verbatim. -- Keep adult/fashion prompts clearly adult, tasteful, non-explicit, and non-nude. -- Brand/IP aesthetics appear in the gallery; use original characters/products when creating reusable or public examples. -- For research/security figures, preserve benign framing and avoid operational instructions beyond defensive illustration. +Inspect results before iterating. Correct the observed defect, repeat the constraints vulnerable to drift, and retain the accepted parts. When a user asks for a design change, update those constraints intentionally rather than retaining obsolete ones. Check spelling, reference identity, geometry, affected region and requested aspect/alpha behavior; do not treat words such as "exact" as verification. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-anime-and-manga.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-anime-and-manga.md index 1193005b..7a3ea192 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-anime-and-manga.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-anime-and-manga.md @@ -2,7 +2,7 @@ Range: No. 1–12 · Count: 12 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 1 · MAPPA-style anime action still (Jujutsu-Kaisen aesthetic) diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-architecture-and-interior.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-architecture-and-interior.md index da05e20d..17860c29 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-architecture-and-interior.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-architecture-and-interior.md @@ -2,7 +2,7 @@ Range: No. 117–121 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 117 · Japanese Minimalist Living Room diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-beauty-and-lifestyle.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-beauty-and-lifestyle.md index 33aeb41c..1bc67842 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-beauty-and-lifestyle.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-beauty-and-lifestyle.md @@ -2,7 +2,7 @@ Range: No. 153–154 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 153 · Quiet-luxury skincare morning tray diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-brand-systems-and-identity.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-brand-systems-and-identity.md index 1d26761c..97470dfc 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-brand-systems-and-identity.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-brand-systems-and-identity.md @@ -2,7 +2,7 @@ Range: No. 60–62 · Count: 3 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 60 · Moss Radio brand identity showcase board diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-character-design.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-character-design.md index d22f02b6..33569ef0 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-character-design.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-character-design.md @@ -2,7 +2,7 @@ Range: No. 31–32 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 31 · Official character reference sheet diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-and-animation.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-and-animation.md index 0d561793..bba7e1b7 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-and-animation.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-and-animation.md @@ -2,7 +2,7 @@ Range: No. 26–30 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 26 · Pixar-style 3D animation still (kitten) diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-film-references.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-film-references.md index c7a5f983..c7549a5d 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-film-references.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-cinematic-film-references.md @@ -2,7 +2,7 @@ Range: No. 147–152 · Count: 6 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 147 · Symmetric Pastel: The Grand Conservatory diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-data-visualization.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-data-visualization.md index ff9bb5ea..6c30f198 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-data-visualization.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-data-visualization.md @@ -2,7 +2,7 @@ Range: No. 107–111 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 107 · Small Multiples Climate Grid diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-edit-endpoint-showcase.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-edit-endpoint-showcase.md index 2312555f..3ae5c808 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-edit-endpoint-showcase.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-edit-endpoint-showcase.md @@ -2,7 +2,7 @@ Range: No. 100–101 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 100 · Chess board → winter evening (edit via `/v1/images/edits`) 🆕 diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-events-and-experience.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-events-and-experience.md index 827a4cbd..4d85e9b2 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-events-and-experience.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-events-and-experience.md @@ -2,7 +2,7 @@ Range: No. 155–156 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 155 · Zoo visitor wayfinding map diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fashion-editorial.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fashion-editorial.md index 11e3e86c..da0963a1 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fashion-editorial.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fashion-editorial.md @@ -2,7 +2,7 @@ Range: No. 129–135 · Count: 7 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 129 · Urban Streetwear Lookbook: Shibuya Night diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fine-art-painting.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fine-art-painting.md index 8e42cd54..79972d77 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fine-art-painting.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-fine-art-painting.md @@ -2,7 +2,7 @@ Range: No. 136–140 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 136 · Vibrant Impasto: Floral Rhythms diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-gaming.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-gaming.md index 5d14fc96..6b58b13d 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-gaming.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-gaming.md @@ -2,7 +2,7 @@ Range: No. 13–22 · Count: 10 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 13 · Hitman gameplay — OpenAI HQ diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-illustration.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-illustration.md index da6a6c30..4be6088b 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-illustration.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-illustration.md @@ -2,7 +2,7 @@ Range: No. 46–47 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 46 · Vintage Amalfi Coast travel poster diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-infographics-and-field-guides.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-infographics-and-field-guides.md index 1b680740..0eee6b7b 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-infographics-and-field-guides.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-infographics-and-field-guides.md @@ -2,7 +2,7 @@ Range: No. 67–74 · Count: 8 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 67 · Song Dynasty social-media feed diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ink-and-chinese.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ink-and-chinese.md index 7c50c99c..98b2ee21 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ink-and-chinese.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ink-and-chinese.md @@ -2,7 +2,7 @@ Range: No. 50–51 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 50 · Chinese ink-wash mountain landscape diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-isometric.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-isometric.md index 4c2c3c4c..529f3e20 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-isometric.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-isometric.md @@ -2,7 +2,7 @@ Range: No. 54–55 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 54 · Isometric miniature cafe district diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-more-illustration-styles.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-more-illustration-styles.md index 41c71c85..8babcde0 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-more-illustration-styles.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-more-illustration-styles.md @@ -2,7 +2,7 @@ Range: No. 141–146 · Count: 6 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 141 · Flat Design: Modern Wellness diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-official-openai-cookbook-examples.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-official-openai-cookbook-examples.md index 94a4f7d4..86634d89 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-official-openai-cookbook-examples.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-official-openai-cookbook-examples.md @@ -2,7 +2,7 @@ Range: No. 96–99 · Count: 4 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 96 · Automatic coffee machine infographic diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-photography.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-photography.md index 232837b8..e8a3a741 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-photography.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-photography.md @@ -2,7 +2,7 @@ Range: No. 63–66 · Count: 4 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 63 · RAW iPhone — 42nd Street subway diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-pixel-art.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-pixel-art.md index 610b90d5..5f2ce6c2 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-pixel-art.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-pixel-art.md @@ -2,7 +2,7 @@ Range: No. 52–53 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 52 · Pixel art car sprite sheet diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-product-and-food.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-product-and-food.md index 8ec2fc21..e45c2786 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-product-and-food.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-product-and-food.md @@ -2,7 +2,7 @@ Range: No. 56–59 · Count: 4 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 56 · 3D product box from dieline diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-research-paper-figures.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-research-paper-figures.md index f205b9f6..312b883b 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-research-paper-figures.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-research-paper-figures.md @@ -2,7 +2,7 @@ Range: No. 75–95 · Count: 21 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 75 · Patient cohort and multimodal biomarker workflow diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-retro-and-cyberpunk.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-retro-and-cyberpunk.md index fce70105..e71beed4 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-retro-and-cyberpunk.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-retro-and-cyberpunk.md @@ -2,7 +2,7 @@ Range: No. 23–25 · Count: 3 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 23 · Cyberpunk mecha girl over sea fortress diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-scientific-and-educational.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-scientific-and-educational.md index 17edeed8..952477e3 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-scientific-and-educational.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-scientific-and-educational.md @@ -2,7 +2,7 @@ Range: No. 122–128 · Count: 7 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 122 · Anatomy Poster diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-screen-photography.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-screen-photography.md index ad611ae3..ef729d75 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-screen-photography.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-screen-photography.md @@ -2,7 +2,7 @@ Range: No. 161–162 · Count: 2 -Load this file only when the request matches photo-of-screen realism, laptop-screen POV shots, moiré/RGB-pixel artifacts, or “not a screenshot” interface photography. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches photo-of-screen realism, laptop-screen POV shots, moiré/RGB-pixel artifacts, or “not a screenshot” interface photography. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 161 · Music app + webcam preview diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-tattoo-design.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-tattoo-design.md index 87b43f1f..b140de92 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-tattoo-design.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-tattoo-design.md @@ -2,7 +2,7 @@ Range: No. 157–160 · Count: 4 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 157 · Realistic black-and-grey sleeve study diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-technical-illustration.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-technical-illustration.md index f9da30ed..61b8fdb8 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-technical-illustration.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-technical-illustration.md @@ -2,7 +2,7 @@ Range: No. 112–116 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 112 · Mechanical Watch Exploded View diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-typography-and-posters.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-typography-and-posters.md index d2ed6057..0e49b022 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-typography-and-posters.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-typography-and-posters.md @@ -2,7 +2,7 @@ Range: No. 33–45 · Count: 13 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 33 · Chinese tea launch poster diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ui-ux-mockups.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ui-ux-mockups.md index 2350adb4..42a1da04 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ui-ux-mockups.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-ui-ux-mockups.md @@ -2,7 +2,7 @@ Range: No. 102–106 · Count: 5 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 102 · Mobile Budgeting App Mockup diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-watercolor.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-watercolor.md index 3ee7b7ed..5692911f 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-watercolor.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery-watercolor.md @@ -2,7 +2,7 @@ Range: No. 48–49 · Count: 2 -Load this file only when the request matches this category. For cross-cutting writing rules, pair it with `craft.md`. +Load this file only when the request matches this category. Use a relevant part of `craft.md` only if additional guidance is needed. Examples are optional inspiration; retain their source labels. ### No. 48 · Dreamy watercolor — young woman at lily pond diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery.md index 598fd263..9f8c1c12 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/gallery.md @@ -1,8 +1,8 @@ # GPT Image 2 Prompt Gallery Index -Routing index for the full prompt Scale / Reference Gallery. This is the canonical full catalog; README is a selected showcase. Do **not** load every category file by default. Pick the closest category below, then read only that file (or at most 2–3 neighboring files) plus `craft.md` when drafting prompts. +Optional index of the retained 162-example community collection, imported in May 2026. Use it for a requested genre or a specific gap in visual direction; a detailed prompt does not need example loading. Examples are inspiration, not current API documentation or evidence of backend capability. -Each `gallery-*.md` category file contains the concrete prompts, direct image previews, image paths, metadata, and attribution/source markers for that slice of the Gallery Atlas. +Each category contains prompts and source metadata. The `docs/...` image paths and previews refer to the upstream repository; those image assets are not bundled here. Use the attributed upstream source when a preview is needed. Do not treat these paths as local edit inputs. ## Category files @@ -42,8 +42,8 @@ Each `gallery-*.md` category file contains the concrete prompts, direct image pr ## Loading policy -- Start here to choose a category; do not read the whole Scale into context. -- Read `craft.md` for general prompt-writing principles. -- Read exactly one `gallery-*.md` category file for normal requests; read two or three only when the user asks for hybrid styles. +- Read only examples relevant to the current gap; do not load the collection by default. +- Read a relevant part of `craft.md` only when additional prompt guidance is useful. +- Keep the user's visual direction, exact copy and facts; do not import an example's unrelated style, dimensions or invented values. - Preserve `Curated` versus `Author + Source` metadata when adapting examples into README/gallery entries. -- If entries move, update both this index and the corresponding category file in the same PR. Promote to README only when the example belongs in the selected visual showcase. +- If entries move, update this index and their category file together. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/openai-cookbook.md b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/openai-cookbook.md index c46a12b0..1fc5380b 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/openai-cookbook.md +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/references/openai-cookbook.md @@ -1,1004 +1,25 @@ ---- -source: https://github.com/openai/openai-cookbook/blob/main/examples/multimodal/image-gen-models-prompting-guide.ipynb -captured: 2026-04-23 -license: MIT (OpenAI cookbook) ---- +# Current official image guidance -> **Local copy of OpenAI's official GPT Image prompting guide.** Loaded on demand from the skill's `references/` directory — do NOT inline into SKILL.md. See the source URL above for the living document. +Reviewed 2026-09-30. This maintained summary replaces the April 2026 Cookbook mirror; the filename is retained for existing links. -# GPT Image Generation Models Prompting Guide +The public API now lists GPT Image 2.5 Flare and Sunburst, with speed and quality priorities respectively. This does not establish the backend behind EAPIL's `gpt-image-2` alias. -## 1. Introduction +Relevant prompting guidance: -OpenAI's gpt-image generation models are designed for production-quality visuals and highly controllable creative workflows. They are well-suited for both professional design tasks and iterative content creation, and support both high-quality rendering and lower-latency use cases depending on the workflow. +- Describe the intended visual result and important constraints. Use explicit text and layout requirements where needed. +- Assign references specific roles. For edits, identify changes and what should be retained. +- Set size, quality, background and format as API parameters; prompt prose does not configure them. +- Inspect results for text, factual relationships, subject preservation and unintended changes. Change a specific failed condition when iterating. +- Compare models/settings with the same prompts and inputs; higher quality is not automatically necessary for every task. -Key Capabilities include: +Source: [OpenAI image prompting](https://developers.openai.com/api/docs/guides/image-prompting). -- **High-fidelity photorealism** with natural lighting, accurate materials, and rich color rendering -- **Flexible quality–latency tradeoffs**, allowing faster generation at lower settings while still exceeding the visual quality of prior-generation image models -- **Robust facial and identity preservation** for edits, character consistency, and multi-step workflows -- **Reliable text rendering** with crisp lettering, consistent layout, and strong contrast inside images -- **Complex structured visuals**, including infographics, diagrams, and multi-panel compositions -- **Precise style control and style transfer** with minimal prompting, supporting everything from branded design systems to fine-art styles -- **Strong real-world knowledge and reasoning**, enabling accurate depictions of objects, environments, and scenarios +The [image generation guide](https://developers.openai.com/api/docs/guides/image-generation) documents model-specific parameters and limits. Verify provider compatibility before adopting them; public 2.5 IDs, quality levels or transparency support are not automatically available through this integration. Legacy `gpt-image-2` does not use `input_fidelity`. -This guide highlights prompting patterns, best practices, and example prompts drawn from real production use cases for `gpt-image-2`. It is our most capable image model, with stronger image quality, improved editing performance, and broader support for production workflows. The `low` quality setting is especially strong for latency-sensitive use cases, while `medium` and `high` remain good fits when maximum fidelity matters. +For local execution, use [API usage](api-usage.md); for this skill's adaptation choices, use [craft](craft.md). -## 1.1 OpenAI Image Model Parameters +## Sources and provenance -This section is a reference for the image models covered in this guide, focused on: - -- model name -- supported `outputQuality` values -- supported `input_fidelity` values -- supported `size` / resolution behavior -- recommended use cases by workflow - -## Model summary -As of April 21, 2026, OpenAI has the following image models available. - -| Model | `outputQuality` | `input_fidelity` | Resolutions | Recommended use | -| --- | --- | --- | --- | --- | -| `gpt-image-2` | `low`, `medium`, `high` | Disabled. `input_fidelity` does not work for this model because output is already high fidelity by default | Any resolution that satisfies the constraints below | Recommended default for new builds. Use for highest-quality generation and editing, text-heavy images, photorealism, compositing, identity-sensitive edits, and workflows where fewer retries matter more than the lowest possible cost. | -| `gpt-image-1.5` | `low`, `medium`, `high` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Keep for existing validated workflows during migration. For new work, prefer `gpt-image-2`, especially when quality, editing reliability, or flexible sizing matter. | -| `gpt-image-1` | `low`, `medium`, `high` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Legacy compatibility only. If you are starting a new workflow or refreshing prompts, move to `gpt-image-2`; keep `gpt-image-1` only when you need short-term stability while validating the upgrade. | -| `gpt-image-1-mini` | `low`, `medium`, `high` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Use when cost and throughput are the main constraint: large batch variant generation, rapid ideation, previews, lightweight personalization, and draft assets that do not require the strongest generation or editing performance. | - -### `gpt-image-2` size options - -`gpt-image-2` supports any resolution passed in the `size` parameter as long as all of these constraints are met: - -- Maximum edge length must be less than `3840px` -- Both edges must be a multiple of `16` -- Ratio between the long edge and short edge must not be greater than `3:1` -- Total pixels must not exceed `8,294,400` -- Total pixels must not be less than `655,360` - -If the output image exceeds `2560x1440` pixels (`3,686,400` total pixels), commonly referred to as 2K, treat it as experimental because results can be more variable above this size. - -### Popular `gpt-image-2` sizes - -These are useful reference points that fit the constraints above: - -| Label | Resolution | Notes | -| --- | --- | --- | -| HD portrait | `1024x1536` | Standard portrait option | -| HD landscape | `1536x1024` | Standard landscape option | -| Square | `1024x1024` | Good general-purpose default | -| 2K / QHD | `2560x1440` | Popular widescreen format and recommended upper reliability boundary for `gpt-image-2` | -| 4K / UHD | `3840x2160` | Experimental upper-end target. If the max-edge rule is enforced literally as `< 3840`, round down to the nearest valid size such as `3824x2144` | - -### When to use which model - -- Choose `gpt-image-2` as the default for most production workflows. It is the strongest overall model and the right upgrade target for teams currently using `gpt-image-1.5` or `gpt-image-1` for high-quality outputs. -- Choose `gpt-image-2` with quality: low when speed and unit economics dominate the decision. This setting has good quality for a lot of use cases and it a strong fit for high-volume generation and experimentation. You can also try `gpt-image-1-mini` for these use cases, but we have seen quality: low works just as well. -- Keep `gpt-image-1.5` or `gpt-image-1` only for backward compatibility while you validate prompt migrations, regression-test outputs, or maintain older workflows that are not yet ready to move. - -### Recommended upgrade path from `gpt-image-1.5` and `gpt-image-1` - -For workflows currently using `gpt-image-1.5` or `gpt-image-1`, the recommendation is: - -- Upgrade to `gpt-image-2` for customer-facing assets, photorealistic generation, editing-heavy flows, brand-sensitive creative, text-in-image work, and any workflow where better first-pass quality reduces manual review or reruns. -- Consider `gpt-image-1-mini` instead of legacy models only when the main goal is lowering cost for large batches of exploratory or lower-stakes images. -- During migration, keep prompts largely the same at first, then retune only after you have compared output quality, latency, and retry rates on your real workload. - -## 2. Prompting Fundamentals - -The following prompting fundamentals are applicable to GPT image generation models. They are based on patterns that showed up repeatedly in alpha testing across generation, edits, infographics, ads, human images, UI mockups, and compositing workflows. - -* **Structure + goal:** Write prompts in a consistent order (background/scene → subject → key details → constraints) and include the intended use (ad, UI mock, infographic) to set the “mode” and level of polish. For complex requests, use short labeled segments or line breaks instead of one long paragraph. - -* **Prompt format:** Use the format that is easiest to maintain. Minimal prompts, descriptive paragraphs, JSON-like structures, instruction-style prompts, and tag-based prompts can all work well as long as the intent and constraints are clear. For production systems, prioritize a skimmable template over clever prompt syntax. - -* **Specificity + quality cues:** Be concrete about materials, shapes, textures, and the visual medium (photo, watercolor, 3D render), and add targeted “quality levers” only when needed (e.g., *film grain*, *textured brushstrokes*, *macro detail*). For photorealism, include the word “photorealistic” directly in the prompt to strongly engage the model’s photorealistic mode. Similar phrases like “real photograph,” “taken on a real camera,” “professional photography,” or “iPhone photo” can also help, but detailed camera specs may be interpreted loosely, so use them mainly for high-level look and composition rather than exact physical simulation. - -* **Latency vs fidelity:** For latency-sensitive or high-volume use cases, start with `quality="low"` and evaluate whether it meets your visual requirements. In many cases, it provides sufficient fidelity with significantly faster generation. For small or dense text, detailed infographics, close-up portraits, identity-sensitive edits, and high-resolution outputs, compare `medium` or `high` before shipping. - -* **Composition:** Specify framing and viewpoint (close-up, wide, top-down), perspective/angle (eye-level, low-angle), and lighting/mood (soft diffuse, golden hour, high-contrast) to control the shot. If layout matters, call out placement (e.g., “logo top-right,” “subject centered with negative space on left”). For wide, cinematic, low-light, rain, or neon scenes, add extra detail about scale, atmosphere, and color so the model does not trade mood for surface realism. - -* **People, pose, and action:** For people in scenes, describe scale, body framing, gaze, and object interactions. Examples: “full body visible, feet included,” “child-sized relative to the table,” “looking down at the open book, not at the camera,” or “hands naturally gripping the handlebars.” These details help with body proportion, action geometry, and gaze alignment. - -* **Constraints (what to change vs preserve):** State exclusions and invariants explicitly (e.g., “no watermark,” “no extra text,” “no logos/trademarks,” “preserve identity/geometry/layout/brand elements”). For edits, use “change only X” + “keep everything else the same,” and repeat the preserve list on each iteration to reduce drift. If the edit should be surgical, also say not to alter saturation, contrast, layout, arrows, labels, camera angle, or surrounding objects. - -* **Text in images:** Put literal text in **quotes** or **ALL CAPS** and specify typography details (font style, size, color, placement) as constraints. For tricky words (brand names, uncommon spellings), spell them out letter-by-letter to improve character accuracy. Use `medium` or `high` quality for small text, dense information panels, and multi-font layouts. - -* **Multi-image inputs:** Reference each input by **index and description** (“Image 1: product photo… Image 2: style reference…”) and describe how they interact (“apply Image 2’s style to Image 1”). When compositing, be explicit about which elements move where (“put the bird from Image 1 on the elephant in Image 2”). - -* **Iterate instead of overloading:** Long prompts can work well, but debugging is easier when you start with a clean base prompt and refine with small, single-change follow-ups (“make lighting warmer,” “remove the extra tree,” “restore the original background”). Use references like “same style as before” or “the subject” to leverage context, but re-specify critical details if they start to drift. - -## 3. Setup - -Run this once. It: -- creates the API client -- creates `output_images/` in the images folder. -- adds a small helper to save base64 images - -Put any reference images used for edits into `input_images/` (or update the paths in the examples). - -```python -import os -import base64 -from openai import OpenAI - -client = OpenAI() - -os.makedirs("../../images/input_images", exist_ok=True) -os.makedirs("../../images/output_images", exist_ok=True) - -def save_image(result, filename: str) -> None: - """ - Saves the first returned image to the given filename inside the output_images folder. - """ - image_base64 = result.data[0].b64_json - out_path = os.path.join("../../images/output_images", filename) - with open(out_path, "wb") as f: - f.write(base64.b64decode(image_base64)) - -from IPython.display import HTML, Image, display - -def display_image_grid(items, width=240): - cards = [] - for item in items: - title = item.get("title", "") - label = f'
{title}
' if title else "" - cards.append( - '
' - + label - + f'' - + '
' - ) - display(HTML('
' + ''.join(cards) + '
')) -``` - -The examples below uses our most capable image model `gpt-image-2` - -## 4. Use Cases — Generate (text → image) - -## 4.1 Infographics -Use infographics to explain structured information for a specific audience: students, executives, customers, or the general public. Examples include explainers, posters, labeled diagrams, timelines, and “visual wiki” assets. For dense layouts or heavy in-image text, it's recommedned to set output generation quality to "high". - -```python -prompt = """ -Create a detailed Infographic of the functioning and flow of an automatic coffee machine like a Jura. -From bean basket, to grinding, to scale, water tank, boiler, etc. -I'd like to understand technically and visually the flow. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "infographic_coffee_machine_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.2 Translation in Images - -Used for localizing existing designs (ads, UI screenshots, packaging, infographics) into another language without rebuilding the layout from scratch. The key is to preserve everything except the text—keep typography style, placement, spacing, and hierarchy consistent—while translating verbatim and accurately, with no extra words, no reflow unless necessary, and no unintended edits to logos, icons, or imagery. - -```python -prompt = """ -Translate the text in the infographic to Spanish. Do not change any other aspect of the image. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/output_images/infographic_coffee_machine_gpt-image-2.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - - -save_image(result, "infographic_coffee_machine_sp_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.3 Photorealistic Images that Feel “natural” - -To get believable photorealism, prompt the model as if a real photo is being captured in the moment. Use photography language (lens, lighting, framing) and explicitly ask for real texture (pores, wrinkles, fabric wear, imperfections). Avoid words that imply studio polish or staging. When detail matters, set quality="high". - -```python -prompt = """ -Create a photorealistic candid photograph of an elderly sailor standing on a small fishing boat. -He has weathered skin with visible wrinkles, pores, and sun texture, and a few faded traditional sailor tattoos on his arms. -He is calmly adjusting a net while his dog sits nearby on the deck. Shot like a 35mm film photograph, medium close-up at eye level, using a 50mm lens. -Soft coastal daylight, shallow depth of field, subtle film grain, natural color balance. -The image should feel honest and unposed, with real skin texture, worn materials, and everyday detail. No glamorization, no heavy retouching. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "photorealism-gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.4 World knowledge - -GPT image generation models can pair strong reasoning with world knowledge. For example, when asked to generate a scene set in Bethel, New York in August 1969, they can infer Woodstock and produce an accurate, context-appropriate image without being explicitly told about the event. - -```python -prompt = """ -Create a realistic outdoor crowd scene in Bethel, New York on August 16, 1969. -Photorealistic, period-accurate clothing, staging, and environment. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "world_knowledge-gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.5 Logo Generation - -Strong logo generation comes from clear brand constraints and simplicity. Describe the brand’s personality and use case, then ask for a clean, original mark with strong shape, balanced negative space, and scalability across sizes. - -You can specify parameter "n" to denote the number of variations you would like to generate. - -```python -prompt = """ -Create an original, non-infringing logo for a company called Field & Flour, a local bakery. -The logo should feel warm, simple, and timeless. Use clean, vector-like shapes, a strong silhouette, and balanced negative space. -Favor simplicity over detail so it reads clearly at small and large sizes. Flat design, minimal strokes, no gradients unless essential. -Plain background. Deliver a single centered logo with generous padding. No watermark. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", - n=4 # Generate 4 versions of the logo -) - -# Save all 4 images to separate files -for i, item in enumerate(result.data, start=1): - image_base64 = item.b64_json - image_bytes = base64.b64decode(image_base64) - with open(f"../../images/output_images/logo_generation_{i}_gpt-image-2.png", "wb") as f: - f.write(image_bytes) -``` - -Output Images: - -| Option 1 | Option 2 | Option 3 | Option 4 | -|:--------:|:--------:|:--------:|:--------:| -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_| - -## 4.6 Ads Generation - -Ad generation works best when the prompt is written like a creative brief rather than a purely technical image spec. Describe the brand, audience, culture, concept, composition, and exact copy, then let the model make taste-driven creative decisions inside those boundaries. This is useful for early campaign exploration because the model can interpret audience cues, infer art direction, and propose visual details that make the ad feel considered rather than merely rendered. - -For stronger results, include the brand positioning, desired vibe, target audience, scene, and tagline in the same prompt. If the text must appear in the image, quote it exactly and ask for clean, legible typography. - -```python -prompt = """ -Give me a cool in culture ad / fashion shot for a brand called Thread. -It's a hip young street brand. The ad shows a group of friends hanging out together with the tagline "Yours to Create." -Make it feel like a polished campaign image for a youth streetwear audience: stylish, contemporary, energetic, and tasteful. -Use clean composition, strong color direction, natural poses, and premium fashion photography cues. -Render the tagline exactly once, clearly and legibly, integrated into the ad layout. -No extra text, no watermarks, no unrelated logos. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "thread_ad_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.7 Story-to-Comic Strip - -For story-to-comic generation, define the narrative as a sequence of clear visual beats, one per panel. Keep descriptions concrete and action-focused so the model can translate the story into readable, well-paced panels. - -```python -prompt = """ -Create a short vertical comic-style reel with 4 equal-sized panels. -Panel 1: The owner leaves through the front door. The pet is framed in the window behind them, small against the glass, eyes wide, paws pressed high, the house suddenly quiet. -Panel 2: The door clicks shut. Silence breaks. The pet slowly turns toward the empty house, posture shifting, eyes sharp with possibility. -Panel 3: The house transformed. The pet sprawls across the couch like it owns the place, crumbs nearby, sunlight cutting across the room like a spotlight. -Panel 4: The door opens. The pet is seated perfectly by the entrance, alert and composed, as if nothing happened. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "comic_reel-gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.8 UI Mockups - -UI mockups work best when you describe the product as if it already exists. Focus on layout, hierarchy, spacing, and real interface elements, and avoid concept art language so the result looks like a usable, shipped interface rather than a design sketch. - -```python -prompt = """ -Create a realistic mobile app UI mockup for a local farmers market. -Show today’s market with a simple header, a short list of vendors with small photos and categories, a small “Today’s specials” section, and basic information for location and hours. -Design it to be practical, and easy to use. White background, subtle natural accent colors, clear typography, and minimal decoration. -It should look like a real, well-designed, beautiful app for a small local market. -Place the UI mockup in an iPhone frame. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "ui_farmers_market_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.9 Scientific / Educational Visuals - -Scientific and educational visuals are strong fits for biology, chemistry, classroom explainers, flat scientific icon systems, diagrams, and learning assets. Prompt them like an instructional design brief: define the audience, lesson objective, visual format, required labels, and scientific constraints. For best results, ask for a clean, flat visual system with consistent icon style, clear arrows, readable labels, and enough white space for students to scan the concept quickly. - -When accuracy matters, list the required components explicitly and say what should not be included. Use `quality="high"` for dense labels, diagrams, or assets that will be used in slides or course materials. - -```python -prompt = """ -Create a simple biology diagram titled "Cellular Respiration at a Glance" for high school students. - -Show how glucose turns into energy inside a cell. Include glycolysis, the Krebs cycle, and the electron transport chain. -Use arrows to connect the steps, and label the main molecules: glucose, pyruvate, ATP, NADH, FADH2, CO2, O2, and H2O. -Make it look like a clean classroom handout or slide, with a white background, simple icons, clear labels, and easy-to-read text. - -Avoid tiny text, extra decoration, or anything that makes the diagram hard to understand. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1536x1024", - quality="high", -) - -save_image(result, "scientific_educational_cellular_respiration_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 4.10 Slides, Diagrams, Charts, and Productivity Images - -Productivity visuals work best when the prompt is written like an artifact spec rather than an illustration request. Name the exact deliverable (slide, workflow diagram, chart, page image), define the canvas and hierarchy, provide the real text or data, and describe the visual language. These prompts should include practical constraints: readable typography, polished spacing, no decorative clutter, and no generic stock-photo treatment. - -For slides, charts, and diagram-heavy assets, include the numbers and labels directly in the prompt. Use a landscape size for deck-style outputs and `quality="high"` when the image contains small text, legends, axes, or footnotes. - -```python -prompt = """ -Create one pitch-deck slide titled **"Market Opportunity"** that feels like a real Series A fundraising slide from a YC-backed startup. - -Use a clean white background, modern sans-serif typography like Inter, and a crisp, minimal layout. The slide should include: - -* A TAM/SAM/SOM concentric-circle diagram in muted blues and grays -* Specific, believable market sizing numbers: - - * **TAM:** $42B - * **SAM:** $8.7B - * **SOM:** $340M -* A clean bar chart below showing market growth from **2021 to 2026**, with a subtle upward trend -* Small footnotes: **"AGI Research, 2024"** and **"Internal analysis"** -* A company logo placeholder in the bottom-right corner - -The design should look like it belongs in a deck that actually raised money: highly readable text, clear data hierarchy, polished spacing, and professional startup-style visual language. - -Avoid clip art, stock photography, gradients, shadows, decorative elements, or anything that feels generic or overdesigned. -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1536x864", - quality="high", -) - -save_image(result, "market_opportunity_slide_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5. Use cases — Edit (text + image → image) - -## 5.1 Style Transfer - -Style transfer is useful when you want to keep the *visual language* of a reference image (palette, texture, brushwork, film grain, etc.) while changing the subject or scene. For best results, describe what must stay consistent (style cues) and what must change (new content), and add hard constraints like background, framing, and “no extra elements” to prevent drift. - -```python -prompt = """ -Use the same style from the input image and generate a man riding a motorcycle on a white background. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/pixels.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "motorcycle_gpt-image-2.png") -``` - -Input Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.2 Virtual Clothing Try-On - -Virtual try-on is ideal for ecommerce previews where identity preservation is critical. The key is to explicitly lock the person (face, body shape, pose, hair, expression) and allow changes *only* to garments, then require realistic fit (draping, folds, occlusion) plus consistent lighting/shadows so the outfit looks naturally worn—not pasted on. - -```python -prompt = """ -Edit the image to dress the woman using the provided clothing images. Do not change her face, facial features, skin tone, body shape, pose, or identity in any way. Preserve her exact likeness, expression, hairstyle, and proportions. Replace only the clothing, fitting the garments naturally to her existing pose and body geometry with realistic fabric behavior. Match lighting, shadows, and color temperature to the original photo so the outfit integrates photorealistically, without looking pasted on. Do not change the background, camera angle, framing, or image quality, and do not add accessories, text, logos, or watermarks. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/woman_in_museum.png", "rb"), - open("../../images/input_images/tank_top.png", "rb"), - open("../../images/input_images/jacket.png", "rb"), - open("../../images/input_images/tank_top.png", "rb"), - open("../../images/input_images/boots.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "outfit_gpt-image-2.png") -``` - -Input Images: - -| Full Body | Item 1 | -|:------------:|:--------------:| -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | -| Item 2 | Item 3 | -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.3 Drawing → Image (Rendering) - -Sketch-to-render workflows are great for turning rough drawings into photorealistic concepts while keeping the original intent. Treat the prompt like a spec: preserve layout and perspective, then *add realism* by specifying plausible materials, lighting, and environment. Include “do not add new elements/text” to avoid creative reinterpretations. - -```python -prompt = """ -Turn this drawing into a photorealistic image. -Preserve the exact layout, proportions, and perspective. -Choose realistic materials and lighting consistent with the sketch intent. -Do not add new elements or text. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/drawings.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "realistic_valley_gpt-image-2.png") -``` - -Input Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.4 Product Mockups (clean background + label integrity) - -Product extraction and mockup prep is commonly used for catalogs, marketplaces, and design systems. Success depends on edge quality (clean silhouette, no fringing/halos) and label integrity (text stays sharp and unchanged). For `gpt-image-2`, keep the output background opaque and use a downstream background-removal step if you need a final transparent asset. If you want realism without re-styling, ask for only light polishing and optionally a subtle contact shadow on a plain background. - -```python -prompt = """ -Extract the product from the input image and place it on a plain white opaque background. -Output: centered product, crisp silhouette, no halos/fringing. -Preserve product geometry and label legibility exactly. -Add only light polishing and a subtle realistic contact shadow. -Do not restyle the product; only remove background and lightly polish. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/shampoo.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", - background="opaque", -) - -save_image(result, "extract_product_gpt-image-2.png") -``` - -Input Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.5 Marketing Creatives with Real Text In-Image - -Marketing creatives with real in-image text are great for rapid ad concepting, but typography needs explicit constraints. Put the exact copy in quotes, demand verbatim rendering (no extra characters), and describe placement and font style. If text fidelity is imperfect, keep the prompt strict and iterate—small wording/layout tweaks usually improve legibility. - -```python -prompt = """ -Create a realistic billboard mockup of the shampoo on a highway scene during sunset. -Billboard text (EXACT, verbatim, no extra characters): -"Fresh and clean" -Typography: bold sans-serif, high contrast, centered, clean kerning. -Ensure text appears once and is perfectly legible. -No watermarks, no logos. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/shampoo.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "billboard_gpt-image-2.png") -``` - -Input Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.6 Lighting and Weather Transformation - -Used to re-stage a photo for different moods, seasons, or time-of-day variants (e.g., sunny → overcast, daytime → dusk, clear → snowy) while keeping the scene composition intact. The key is to change only environmental conditions—lighting direction/quality, shadows, atmosphere, precipitation, and ground wetness—while preserving identity, geometry, camera angle, and object placement so it still reads as the same original photo. - -```python -prompt = """ -Make it look like a winter evening with snowfall. -""" - -result = client.images.edit( - model="gpt-image-2", - input_fidelity="high", - image=[ - open("../../images/output_images/billboard_gpt-image-2.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "billboard_winter_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 5.7 Object Removal - -Person-in-scene compositing is useful for storyboards, campaigns, and “what if” scenarios where facial/identity preservation matters. Anchor realism by specifying a grounded photographic look (natural lighting, believable detail, no cinematic grading), and lock what must not change about the subject. When available, higher input fidelity helps maintain likeness during larger scene edits. - -```python -prompt = """ -Remove the flower from man's hand. Do not change anything else. -""" - - -result = client.images.edit( - model="gpt-image-2", - input_fidelity="high", - image=[ - open("../../images/output_images/man_with_blue_hat.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "man_with_no_flower_gpt-image-2.png") -``` - -Input and output images: - -| Original Input | Output Image | -|:------------:|:--------------:| -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | - -## 5.8 Insert the Person Into a Scene - -Person-in-scene compositing is useful for storyboards, campaigns, and “what if” scenarios where facial/identity preservation matters. Anchor realism by specifying a grounded photographic look (natural lighting, believable detail, no cinematic grading), and lock what must not change about the subject. When available, higher input fidelity helps maintain likeness during larger scene edits. - -```python -prompt = """ -Generate a highly realistic action scene where this person is running away from a large, realistic brown bear attacking a campsite. The image should look like a real photograph someone could have taken, not an overly enhanced or cinematic movie-poster image. -She is centered in the image but looking away from the camera, wearing outdoorsy camping attire, with dirt on her face and tears in her clothing. She is clearly afraid but focused on escaping, running away from the bear as it destroys the campsite behind her. -The campsite is in Yosemite National Park, with believable natural details. The time of day is dusk, with natural lighting and realistic colors. Everything should feel grounded, authentic, and unstyled, as if captured in a real moment. Avoid cinematic lighting, dramatic color grading, or stylized composition. -""" - -result = client.images.edit( - model="gpt-image-2", - input_fidelity="high", - image=[ - open("../../images/input_images/woman_in_museum.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "scene_gpt-image-2.png") -``` - -Output Image: - -```python -from IPython.display import Image, display -display(Image(filename="../../images/output_images/scene_gpt-image-2.png", width=500)) -``` - -## 5.9 Multi-Image Referencing and Compositing - -Used to combine elements from multiple inputs into a single, believable image—great for “insert this object/person into that scene” workflows without re-generating everything. The key is to clearly specify what to transplant (the dog from image 2), where it should go (right next to the woman in image 1), and what must remain unchanged (scene, background, framing), while matching lighting, perspective, scale, and shadows so the composite looks naturally captured in the original photo. - -```python -prompt = """ -Place the dog from the second image into the setting of image 1, right next to the woman, use the same style of lighting, composition and background. Do not change anything else. -""" - -result = client.images.edit( - model="gpt-image-2", - input_fidelity="high", - image=[ - open("../../images/output_images/test_woman.png", "rb"), - open("../../images/output_images/test_woman_2.png", "rb"), - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - - -save_image(result, "test_woman_with_dog_gpt-image-2.png") -``` - -Input and output images: - -| Original Input | Remove Red Stripes | Change Hat Color | -|:--------------:|:------------------:|:----------------:| -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | - -## 6. Additional High-Value Use Cases - -## 6.1 Interior design “swap” (precision edits) -Used for visualizing furniture or decor changes in real spaces without re-rendering the entire scene. The goal is surgical realism: swap a single object while preserving camera angle, lighting, shadows, and surrounding context so the edit looks like a real photograph, not a redesign. - -```python -prompt = """ -In this room photo, replace ONLY white with chairs made of wood. -Preserve camera angle, room lighting, floor shadows, and surrounding objects. -Keep all other aspects of the image unchanged. -Photorealistic contact shadows and fabric texture. -""" - -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/input_images/kitchen.jpeg", "rb"), - ], - prompt=prompt, - size="1536x1024", - quality="medium", -) - -save_image(result, "kitchen-chairs_gpt-image-2.png") -``` - -Input and output images: - -| Input Image | Output Image | -|------------|--------------| -| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | - -## 6.2 3D pop-up holiday card (product-style mock) -Ideal for seasonal marketing concepts and print previews. Emphasizes tactile realism—paper layers, fibers, folds, and soft studio lighting—so the result reads as a photographed physical product rather than a flat illustration. - -```python -scene_description = ( - "a cozy Christmas scene with an old teddy bear sitting inside a keepsake box, " - "slightly worn fur, soft stitching repairs, placed near a window with falling snow outside. " - "The scene suggests the child has grown up, but the memories remain." -) - -short_copy = "Merry Christmas — some memories never fade." - -prompt = f""" -Create a Christmas holiday card illustration. - -Scene: -{scene_description} - -Mood: -Warm, nostalgic, gentle, emotional. - -Style: -Premium holiday card photography, soft cinematic lighting, -realistic textures, shallow depth of field, -tasteful bokeh lights, high print-quality composition. - -Constraints: -- Original artwork only -- No trademarks -- No watermarks -- No logos - -Include ONLY this card text (verbatim): -"{short_copy}" -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "christmas_holiday_card_teddy_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 6.3 Collectible Action Figure / Plush Keychain (merch concept) - -Used for early merch ideation and pitch visuals. Focuses on premium product photography cues (materials, packaging, print clarity) while keeping designs original and non-infringing. Works well for testing multiple character or packaging variants quickly. - -```python -# ---- Inputs ---- -character_description = ( - "a vintage-style toy propeller airplane with rounded wings, " - "a front-mounted spinning propeller, slightly worn paint edges, " - "classic childhood proportions, designed as a nostalgic holiday collectible" -) - -short_copy = "Christmas Memories Edition" - -# ---- Prompt ---- -prompt = f""" -Create a collectible action figure of {character_description}, in blister packaging. - -Concept: -A nostalgic holiday collectible inspired by the simple toy airplanes -children used to play with during winter holidays. -Evokes warmth, imagination, and childhood wonder. - -Style: -Premium toy photography, realistic plastic and painted metal textures, -studio lighting, shallow depth of field, -sharp label printing, high-end retail presentation. - -Constraints: -- Original design only -- No trademarks -- No watermarks -- No logos - -Include ONLY this packaging text (verbatim): -"{short_copy}" -""" - -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "christmas_collectible_toy_airplane_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## 6.4 Children’s Book Art with Character Consistency (multi-image workflow) -Designed for multi-page illustration pipelines where character drift is unacceptable. A reusable “character anchor” ensures visual continuity across scenes, poses, and pages while allowing environmental and narrative variation. - -1️⃣ Character Anchor — establish the reusable main character - -Goal: Lock the character’s appearance, proportions, outfit, and tone. - -```python -# ---- Inputs ---- -prompt = """ -Create a children’s book illustration introducing a main character. - -Character: -A young, storybook-style hero inspired by a little forest outlaw, -wearing a simple green hooded tunic, soft brown boots, and a small belt pouch. -The character has a kind expression, gentle eyes, and a brave but warm demeanor. -Carries a small wooden bow used only for helping, never harming. - -Theme: -The character protects and rescues small forest animals like squirrels, birds, and rabbits. - -Style: -Children’s book illustration, hand-painted watercolor look, -soft outlines, warm earthy colors, whimsical and friendly. -Proportions suitable for picture books (slightly oversized head, expressive face). - -Constraints: -- Original character (no copyrighted characters) -- No text -- No watermarks -- Plain forest background to clearly showcase the character -""" - -# ---- Image generation ---- -result = client.images.generate( - model="gpt-image-2", - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "childrens_book_illustration_1_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -2️⃣ Story continuation — reuse character, advance the narrative - -Goal: Same character, new scene + action. -Character appearance must remain unchanged. - -```python -# ---- Inputs ---- -prompt = """ -Continue the children’s book story using the same character. - -Scene: -The same young forest hero is gently helping a frightened squirrel -out of a fallen tree after a winter storm. -The character kneels beside the squirrel, offering reassurance. - -Character Consistency: -- Same green hooded tunic -- Same facial features, proportions, and color palette -- Same gentle, heroic personality - -Style: -Children’s book watercolor illustration, -soft lighting, snowy forest environment, -warm and comforting mood. - -Constraints: -- Do not redesign the character -- No text -- No watermarks -""" -# ---- Image generation ---- -result = client.images.edit( - model="gpt-image-2", - image=[ - open("../../images/output_images/childrens_book_illustration_1_gpt-image-2.png", "rb"), # use image from step 1 - ], - prompt=prompt, - size="1024x1536", - quality="medium", -) - -save_image(result, "childrens_book_illustration_2_gpt-image-2.png") -``` - -Output Image: - -> _[OpenAI cookbook rendered output — see original notebook for the image]_ - -## Conclusion - -In this notebook, we demonstrate how to use gpt-image generation models to build high-quality, controllable image generation and editing workflows that hold up in real production settings. The cookbook emphasizes prompt structure, explicit constraints, and small iterative changes as the primary tools for controlling realism, layout, text accuracy, and identity preservation. We cover both generation and editing patterns, ranging from infographics, photorealism, UI mockups, and logos to translation, style transfer, virtual try-on, compositing, and lighting changes. Throughout the examples, the cookbook reinforces the importance of clearly separating what should change from what must remain invariant, and of restating those invariants on every iteration to prevent drift. We also highlight how quality and input-fidelity settings enable deliberate tradeoffs between latency and visual precision depending on the use case. Together, these examples form a practical, repeatable playbook for applying gpt-image generation models in production image workflows. - ---- - -## Parameter coverage cross-check (for gpt-image-cli maintainers) - -Parameters used in the cookbook notebook: - -| Notebook parameter | Used in | CLI flag | Exposed? | -|---|---|---|---| -| `model` | both | `--model` | ✅ | -| `prompt` | both | `-p` / `--prompt` | ✅ | -| `size` | both | `--size` | ✅ | -| `quality` | both | `--quality` | ✅ | -| `n` | both | `-n` / `--n` | ✅ | -| `background` | generations + edits | `--background` | ✅ | -| `moderation` | generations | `--moderation` | ✅ | -| `output_format` | both | `--format` | ✅ | -| `output_compression` | both | `--compression` | ✅ | -| `input_fidelity` | edits | `--input-fidelity` | ✅ (ignored by gpt-image-2) | -| `image` | edits | `-i` / `--image` | ✅ | -| `mask` | edits | `-m` / `--mask` | ✅ | -| `user` | both | `--user` | ✅ | - -**All cookbook parameters covered by `gpt-image-cli` v1.3+.** No gaps. - ---- - -*Captured from the official OpenAI cookbook for local skill reference. See the source URL above for the living document.* +- [OpenAI Images 2.5 announcement](https://openai.com/index/introducing-chatgpt-images-2-5/) +- [Original Cookbook notebook](https://github.com/openai/openai-cookbook/blob/main/examples/multimodal/image-gen-models-prompting-guide.ipynb), previously mirrored under its MIT license. +- [Original community skill](https://github.com/wuyoscar/GPT-Image2-Skill/blob/main/skills/gpt-image/SKILL.md), whose current routing distinguishes gallery-heavy Image 2 workflows from targeted reference use for 2.5. Community patterns remain examples; this skill's execution uses EAPIL. diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py index 170f8ddd..bb9614a4 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse import base64 +import binascii import json import mimetypes import os @@ -49,6 +50,41 @@ def compact(values: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in values.items() if value is not None} +def resolve_prompt(args: argparse.Namespace) -> str: + prompt_file = getattr(args, "prompt_file", None) + if prompt_file is not None: + try: + with Path(prompt_file).open(encoding="utf-8-sig", newline="") as source: + prompt = source.read() + except (OSError, UnicodeError) as err: + fail(f"could not read UTF-8 prompt file: {err}") + else: + prompt = args.prompt + if not isinstance(prompt, str) or not prompt.strip(): + fail("prompt must not be blank") + return prompt + + +def positive_integer(value: str) -> int: + try: + number = int(value) + except ValueError: + raise argparse.ArgumentTypeError("must be a positive integer") from None + if number < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return number + + +def compression_percentage(value: str) -> int: + try: + number = int(value) + except ValueError: + raise argparse.ArgumentTypeError("must be an integer from 0 to 100") from None + if not 0 <= number <= 100: + raise argparse.ArgumentTypeError("must be an integer from 0 to 100") + return number + + def output_path(value: str | None, default_name: str) -> Path: path = Path(value or default_name) if not path.is_absolute(): @@ -138,19 +174,50 @@ def read_json_response(request: urllib.request.Request) -> dict[str, Any]: fail(f"response was not JSON: {err}") +def decode_image_base64(value: str, expected_type: str | None = None) -> bytes: + """Validate base64 and a supported image signature, not the full image encoding.""" + try: + image_bytes = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError): + fail("image response contained malformed base64") + if not image_bytes: + fail("image response contained empty image data") + if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + image_type = "image/png" + elif image_bytes.startswith(b"\xff\xd8\xff"): + image_type = "image/jpeg" + elif ( + len(image_bytes) >= 12 + and image_bytes[:4] == b"RIFF" + and image_bytes[8:12] == b"WEBP" + ): + image_type = "image/webp" + else: + fail("image response did not have a PNG, JPEG, or WebP signature") + if expected_type is not None and image_type != expected_type: + fail("image data URL type did not match its image signature") + return image_bytes + + def extract_image_bytes_from_item(item: Any) -> bytes: if not isinstance(item, dict): fail("response data item was not an object") b64_value = item.get("b64_json") - if isinstance(b64_value, str) and b64_value.strip(): - return base64.b64decode(b64_value) + if isinstance(b64_value, str): + return decode_image_base64(b64_value) url_value = item.get("url") if isinstance(url_value, str) and url_value.startswith("data:"): - _, _, payload = url_value.partition(",") - if payload: - return base64.b64decode(payload) + header, separator, payload = url_value.partition(",") + parts = header[5:].lower().split(";") + if ( + not separator + or parts[0] not in ("image/png", "image/jpeg", "image/webp") + or parts[-1] != "base64" + ): + fail("response contained an unsupported image data URL") + return decode_image_base64(payload, parts[0]) fail("response item did not contain b64_json or a data URL") @@ -172,13 +239,13 @@ def save_response_images(response: dict[str, Any], path: Path, n: int) -> list[P if not isinstance(data, list) or not data: fail("response did not contain data") + images = [extract_image_bytes_from_item(item) for item in data] written: list[Path] = [] byte_counts: list[int] = [] use_suffix = n != 1 or len(data) != 1 path.parent.mkdir(parents=True, exist_ok=True) - for index, item in enumerate(data): - image_bytes = extract_image_bytes_from_item(item) + for index, image_bytes in enumerate(images): target = numbered_output_path(path, index) if use_suffix else path target.write_bytes(image_bytes) written.append(target) @@ -202,11 +269,12 @@ def save_response_image(response: dict[str, Any], path: Path) -> None: def command_generate(args: argparse.Namespace) -> None: + prompt = resolve_prompt(args) key = resolve_api_key(args.api_key) payload = compact( { "model": MODEL, - "prompt": args.prompt, + "prompt": prompt, "size": args.size, "quality": args.quality, "n": args.n, @@ -224,11 +292,12 @@ def command_generate(args: argparse.Namespace) -> None: def command_edit(args: argparse.Namespace) -> None: + prompt = resolve_prompt(args) key = resolve_api_key(args.api_key) fields = compact( { "model": MODEL, - "prompt": args.prompt, + "prompt": prompt, "size": args.size, "quality": args.quality, "n": args.n, @@ -250,18 +319,21 @@ def command_edit(args: argparse.Namespace) -> None: def add_common_options( command: argparse.ArgumentParser, *, include_moderation: bool = False ) -> None: + prompts = command.add_mutually_exclusive_group(required=True) + prompts.add_argument("--prompt", help="Exact prompt text.") + prompts.add_argument("--prompt-file", help="UTF-8 prompt file; an optional BOM is accepted.") command.add_argument( "--api-key", help="Temporary API key. Prefer EAPIL_API_KEY for repeated use; OPENAI_API_KEY is also accepted.", ) command.add_argument("--size", default="1024x1024") command.add_argument("--quality", choices=["auto", "low", "medium", "high"]) - command.add_argument("--n", type=int, default=1) + command.add_argument("--n", type=positive_integer, default=1) command.add_argument("--background") if include_moderation: command.add_argument("--moderation") command.add_argument("--output-format", choices=["png", "jpeg", "webp"]) - command.add_argument("--output-compression", type=int) + command.add_argument("--output-compression", type=compression_percentage) command.add_argument("--user") @@ -272,13 +344,11 @@ def parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) generate = subparsers.add_parser("generate", help="generate a new image") - generate.add_argument("--prompt", required=True) generate.add_argument("--output") add_common_options(generate, include_moderation=True) generate.set_defaults(func=command_generate) edit = subparsers.add_parser("edit", help="edit an existing image") - edit.add_argument("--prompt", required=True) edit.add_argument("--image", action="append", required=True) edit.add_argument("--mask") edit.add_argument("--output") diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py index 77d88155..e36f94ad 100644 --- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py +++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py @@ -4,17 +4,24 @@ import base64 import contextlib import importlib.util import io +import json import os import tempfile import unittest +from email import policy +from email.parser import BytesParser from pathlib import Path -from types import SimpleNamespace from unittest import mock - ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "eapil_gpt2_image.py" -SKILL = ROOT / "SKILL.md" +# Complete 1x1 PNG. Tests never make an image-generation request. +PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8" + "/x8AAwMCAO+jRZkAAAAASUVORK5CYII=" +) +PNG = base64.b64decode(PNG_BASE64) +PROMPT = '海报文字:"春日茶会"\r\nKeep "TEA & CO."; literal $HOME, `code`, \\path.\n' def load_module(): @@ -28,109 +35,228 @@ def load_module(): class EapilGpt2ImageTests(unittest.TestCase): def setUp(self): self.module = load_module() + self.network = mock.patch.object(self.module.urllib.request, "urlopen") + self.urlopen = self.network.start() + self.addCleanup(self.network.stop) + self.urlopen.side_effect = AssertionError("unexpected network call") - def test_api_key_prefers_cli_key_then_eapil_then_openai(self): - with mock.patch.dict( - os.environ, - {"EAPIL_API_KEY": "eapil", "OPENAI_API_KEY": "openai"}, - clear=True, - ): - self.assertEqual(self.module.resolve_api_key("direct"), "direct") - self.assertEqual(self.module.resolve_api_key(None), "eapil") + def response(self): + self.urlopen.side_effect = None + self.urlopen.return_value = io.BytesIO(json.dumps({ + "data": [{"b64_json": PNG_BASE64}] + }).encode("utf-8")) + + def run_command(self, argv): + args = self.module.parser().parse_args(argv) + with contextlib.redirect_stdout(io.StringIO()): + args.func(args) + + def assert_fails(self, operation): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + operation() + + def test_api_key_priority_and_whitespace(self): + with mock.patch.dict(os.environ, { + "EAPIL_API_KEY": " eapil ", "OPENAI_API_KEY": "openai" + }, clear=True): + self.assertEqual(self.module.resolve_api_key(" direct "), "direct") + self.assertEqual(self.module.resolve_api_key(" "), "eapil") with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "openai"}, clear=True): self.assertEqual(self.module.resolve_api_key(None), "openai") - def test_missing_api_key_mentions_all_supported_sources(self): + def test_missing_key_reports_supported_sources(self): with mock.patch.dict(os.environ, {}, clear=True): stderr = io.StringIO() with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit): self.module.resolve_api_key(None) - message = stderr.getvalue() - self.assertIn("--api-key", message) - self.assertIn("EAPIL_API_KEY", message) - self.assertIn("OPENAI_API_KEY", message) + for source in ("--api-key", "EAPIL_API_KEY", "OPENAI_API_KEY"): + self.assertIn(source, stderr.getvalue()) - def test_generation_payload_omits_absent_optional_values_and_keeps_supplied_values(self): - captured = {} + def test_json_request_preserves_file_prompt_size_and_omits_absent_options(self): + for bom in (b"", b"\xef\xbb\xbf"): + with self.subTest(bom=bool(bom)), tempfile.TemporaryDirectory() as tmp: + prompt_file = Path(tmp) / "prompt.txt" + prompt_file.write_bytes(bom + PROMPT.encode("utf-8")) + output = Path(tmp) / "result.png" + self.response() + self.run_command([ + "generate", "--prompt-file", str(prompt_file), "--api-key", "direct", + "--size", "1024x1536", "--output", str(output), + ]) + request = self.urlopen.call_args.args[0] + self.assertEqual(request.full_url, self.module.BASE_URL + "/images/generations") + self.assertEqual(request.get_method(), "POST") + self.assertEqual(request.get_header("Content-type"), "application/json") + self.assertEqual(request.get_header("Authorization"), "Bearer direct") + self.assertEqual(json.loads(request.data), { + "model": "gpt-image-2", "prompt": PROMPT, "size": "1024x1536", + "n": 1, "response_format": "b64_json", + }) + self.assertEqual(output.read_bytes(), PNG) - def fake_request_json(path, payload, key): - captured["path"] = path - captured["payload"] = payload - captured["key"] = key - return { - "data": [{"b64_json": base64.b64encode(b"png").decode("ascii")}] + def test_generation_supplied_options_reach_wire_payload(self): + with tempfile.TemporaryDirectory() as tmp: + self.response() + self.run_command([ + "generate", "--prompt", PROMPT, "--api-key", "direct", "--n", "2", + "--quality", "medium", "--background", "opaque", "--moderation", "low", + "--output-format", "webp", "--output-compression", "80", "--user", "tester", + "--output", str(Path(tmp) / "result.png"), + ]) + payload = json.loads(self.urlopen.call_args.args[0].data) + for name, value in { + "prompt": PROMPT, "n": 2, "quality": "medium", "background": "opaque", + "moderation": "low", "output_format": "webp", + "output_compression": 80, "user": "tester", + }.items(): + self.assertEqual(payload[name], value) + + def test_edit_wire_prompt_image_order_mask_and_boundary(self): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + prompt_file = directory / "prompt.txt" + prompt_file.write_bytes(PROMPT.encode("utf-8")) + files = [directory / name for name in ("subject.png", "style.png", "mask.png")] + for index, path in enumerate(files): + path.write_bytes(PNG + bytes([index])) + self.response() + self.run_command([ + "edit", "--prompt-file", str(prompt_file), "--api-key", "direct", + "--size", "1536x1024", "--image", str(files[0]), "--image", str(files[1]), + "--mask", str(files[2]), "--output", str(directory / "edited.png"), + ]) + request = self.urlopen.call_args.args[0] + self.assertEqual(request.full_url, self.module.BASE_URL + "/images/edits") + content_type = request.get_header("Content-type") + message = BytesParser(policy=policy.default).parsebytes( + f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + + request.data + ) + self.assertTrue(message.is_multipart()) + boundary = message.get_boundary().encode() + self.assertTrue(request.data.endswith(b"--" + boundary + b"--\r\n")) + parts = list(message.iter_parts()) + fields = { + part.get_param("name", header="content-disposition"): + part.get_payload(decode=True).decode("utf-8") + for part in parts if part.get_filename() is None } + self.assertEqual(fields, { + "model": "gpt-image-2", "prompt": PROMPT, "size": "1536x1024", + "n": "1", "response_format": "b64_json", + }) + uploads = [part for part in parts if part.get_filename() is not None] + self.assertEqual([part.get_filename() for part in uploads], [p.name for p in files]) + self.assertEqual([p.get_param("name", header="content-disposition") for p in uploads], + ["image", "image", "mask"]) + self.assertEqual([p.get_payload(decode=True) for p in uploads], + [p.read_bytes() for p in files]) - self.module.request_json = fake_request_json - self.module.save_response_images = lambda response, path, n: None - args = SimpleNamespace( - prompt="test prompt", - size="1024x1024", - output=None, - api_key="direct", - quality="medium", - n=2, - background="opaque", - moderation="low", - output_format="webp", - output_compression=80, - user="tester", - ) + def test_prompt_sources_required_and_mutually_exclusive(self): + for operation in ("generate", "edit"): + base = [operation] + (["--image", "input.png"] if operation == "edit" else []) + for extra in ([], ["--prompt", "text", "--prompt-file", "prompt.txt"]): + with self.subTest(operation=operation, extra=extra): + self.assert_fails(lambda: self.module.parser().parse_args(base + extra)) + self.urlopen.assert_not_called() - self.module.command_generate(args) + def test_bad_prompt_inputs_fail_before_request(self): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + blank, invalid = directory / "blank.txt", directory / "invalid.txt" + blank.write_bytes(b" \r\n\t") + invalid.write_bytes(b"\xff\xfe") + for operation in ("generate", "edit"): + base = [operation, "--api-key", "direct"] + if operation == "edit": + base += ["--image", "input.png"] + for options in ( + ["--prompt", " \n\t"], ["--prompt-file", str(blank)], + ["--prompt-file", str(invalid)], + ["--prompt-file", str(directory / "missing.txt")], + ["--prompt-file", str(directory)], + ): + with self.subTest(operation=operation, options=options): + self.assert_fails(lambda: self.run_command(base + options)) + self.urlopen.assert_not_called() - self.assertEqual(captured["path"], "/images/generations") - self.assertEqual(captured["key"], "direct") - self.assertEqual(captured["payload"]["quality"], "medium") - self.assertEqual(captured["payload"]["n"], 2) - self.assertEqual(captured["payload"]["background"], "opaque") - self.assertEqual(captured["payload"]["moderation"], "low") - self.assertEqual(captured["payload"]["output_format"], "webp") - self.assertEqual(captured["payload"]["output_compression"], 80) - self.assertEqual(captured["payload"]["user"], "tester") + def test_n_and_compression_bounds_fail_locally(self): + for flag, values in (("--n", ("0", "-1", "1.5")), + ("--output-compression", ("-1", "101", "half"))): + for value in values: + with self.subTest(flag=flag, value=value): + self.assert_fails(lambda: self.run_command([ + "generate", "--prompt", "test", "--api-key", "direct", flag, value + ])) + for value in ("0", "100"): + args = self.module.parser().parse_args([ + "generate", "--prompt", "test", "--output-compression", value, + ]) + self.assertEqual(args.output_compression, int(value)) + self.urlopen.assert_not_called() - def test_save_response_images_suffixes_multiple_outputs(self): - response = { - "data": [ - {"b64_json": base64.b64encode(b"one").decode("ascii")}, - {"b64_json": base64.b64encode(b"two").decode("ascii")}, - ] - } + def test_supported_signatures_and_png_data_url(self): + # JPEG/WebP samples test only signatures, not full image decoding. + for data in (PNG, b"\xff\xd8\xff\xe0signature", b"RIFF\x04\x00\x00\x00WEBP"): + with self.subTest(prefix=data[:12]): + self.assertEqual(self.module.extract_image_bytes_from_item({ + "b64_json": base64.b64encode(data).decode("ascii") + }), data) + self.assertEqual(self.module.extract_image_bytes_from_item({ + "url": "data:image/png;base64," + PNG_BASE64 + }), PNG) + + def test_malformed_empty_and_nonimage_data_rejected_without_writes(self): + bad_items = [ + {"b64_json": "!!!!"}, {"b64_json": ""}, {"b64_json": "abcd"}, + {"b64_json": PNG_BASE64[:-1]}, {"b64_json": "图像"}, + {"url": "data:image/png;base64,"}, + {"url": "data:text/plain;base64," + PNG_BASE64}, + {"url": "data:image/png," + PNG_BASE64}, + {"url": "data:image/jpeg;base64," + PNG_BASE64}, + {"url": "data:image/png;base64,!!!!"}, + {"url": "https://example.invalid/image.png"}, {}, "not an object", + ] with tempfile.TemporaryDirectory() as tmp: target = Path(tmp) / "image.png" - with contextlib.redirect_stdout(io.StringIO()): - outputs = self.module.save_response_images(response, target, 2) + for item in bad_items: + with self.subTest(item=item): + self.assert_fails(lambda: self.module.save_response_images({"data": [item]}, target, 1)) + self.assertFalse(target.exists()) + + def test_bad_later_result_preserves_existing_first_output(self): + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "image.png" + existing = Path(tmp) / "image_0.png" + existing.write_bytes(b"existing user output") + self.assert_fails(lambda: self.module.save_response_images({"data": [ + {"b64_json": PNG_BASE64}, {"b64_json": "!!!!"} + ]}, target, 2)) + self.assertEqual(existing.read_bytes(), b"existing user output") + self.assertFalse((Path(tmp) / "image_1.png").exists()) + + def test_multiple_images_saved_and_actual_outputs_summarized(self): + with tempfile.TemporaryDirectory() as tmp: + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + outputs = self.module.save_response_images({"data": [ + {"b64_json": PNG_BASE64}, {"url": "data:image/png;base64," + PNG_BASE64} + ]}, Path(tmp) / "image.png", 2) self.assertEqual([p.name for p in outputs], ["image_0.png", "image_1.png"]) - self.assertEqual(outputs[0].read_bytes(), b"one") - self.assertEqual(outputs[1].read_bytes(), b"two") + self.assertEqual([p.read_bytes() for p in outputs], [PNG, PNG]) + summary = json.loads(stdout.getvalue()) + self.assertEqual(summary["outputs"], [str(p) for p in outputs]) + self.assertEqual(summary["bytes"], [len(PNG), len(PNG)]) + self.assertEqual(summary["data_items"], 2) + self.assertNotIn(PNG_BASE64, stdout.getvalue()) - def test_parser_accepts_repeated_reference_images(self): - args = self.module.parser().parse_args( - [ - "edit", - "--prompt", - "combine", - "--image", - "one.png", - "--image", - "two.png", - ] - ) - self.assertEqual(args.image, ["one.png", "two.png"]) - - def test_skill_documents_prompt_enhancement_gate_and_key_fallbacks(self): - text = SKILL.read_text(encoding="utf-8") - self.assertIn("OPENAI_API_KEY", text) - self.assertIn("--api-key", text) - self.assertIn("prompt enhancement", text.lower()) - self.assertIn("confirm", text.lower()) - self.assertIn("references/gallery.md", text) - self.assertIn("references/craft.md", text) - self.assertIn("API execution prompt", text) - self.assertIn("中文对照", text) - self.assertIn("In-image text", text) - self.assertIn("review only", text.lower()) - self.assertIn("user-requested language", text.lower()) + def test_empty_or_missing_response_data_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + for response in ({}, {"data": []}, {"data": "image"}): + with self.subTest(response=response): + self.assert_fails(lambda: self.module.save_response_images( + response, Path(tmp) / "image.png", 1 + )) if __name__ == "__main__": diff --git a/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json index af24a840..61d66a29 100644 --- a/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "eapil-llmapi-structured", - "version": "0.1.0", - "description": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。", + "version": "1.0.0", + "description": "易瞳 LLM API 通用调用指南:选择、构建和实测 Chat、Responses、结构化输出、工具、视觉、流式、Embedding、Rerank 与 Decisions 请求,核对模型和中转渠道的实际兼容性。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-llmapi-structured", + "repository": "https://git.playones.com/chenmingxuan/ManySkills", "license": "Proprietary", "keywords": [ "eapil", @@ -16,9 +16,9 @@ ], "skills": "./skills/", "interface": { - "displayName": "易瞳LLM结构化调用", - "shortDescription": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。", - "longDescription": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。", + "displayName": "易瞳LLM调用指南", + "shortDescription": "Chat、Responses、结构化输出、Embedding、Rerank 和 Decisions 的调用与实测指南。", + "longDescription": "按任务选择易瞳 LLM API 调用模式,提供端点参数、工具与视觉输入、流式解析、结构化校验、检索和决策示例。配套单请求探测脚本与有日期的实测记录,明确模型和渠道差异;保留原插件 ID 兼容已有引用。", "developerName": "EAPIL", "category": "Developer Tools", "capabilities": [ @@ -26,7 +26,7 @@ "Write" ], "defaultPrompt": [ - "使用 易瞳LLM结构化调用 帮我完成这个任务。" + "使用 易瞳LLM调用指南 帮我选择端点、构建请求并验证结果。" ], "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", diff --git a/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..822ce355 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "eapil-llmapi-structured", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "adapter": "codex-plugin", + "sourcePath": "plugins/eapil-llmapi-structured", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md index 8b88613e..69ae2817 100644 --- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md @@ -1,175 +1,41 @@ --- name: eapil-llmapi-structured -description: Guidance for EAPIL LLM API/OpenAI-compatible GPT-5.5 structured JSON output calls through /v1/responses or /v1/chat/completions, especially vision labeling, strict JSON schema, reasoning_effort, token/latency diagnostics, and gateway troubleshooting. Use when building, debugging, or reviewing structured-output requests to EAPIL LLM API relay domains such as api-n-cd.playones.com or similar OpenAI-compatible relays. +description: Build, test, or debug EAPIL LLM API requests across Chat, Responses, structured output, tools, streaming, embeddings, reranking, and typed Decisions. --- -# EAPIL LLMAPI Structured Output +# EAPIL LLM API guide -Use this skill when implementing or debugging structured JSON calls against the EAPIL LLM API OpenAI-compatible gateway. +Choose the protocol for the task and model, then verify the deployed gateway's behavior. The historical skill ID remains for compatibility; this guide covers general LLM API calls, not only structured JSON. -## Default Choice +## Choose the mode -Prefer `/v1/responses` with strict JSON schema for GPT-5.5 teacher labeling. +| Need | EAPIL endpoint | Read when relevant | +|---|---|---| +| Conversation, compatible clients, vision or client tools | `/v1/chat/completions` | [Chat and Responses](references/chat-responses.md) | +| Responses-native input/output, tools or streaming | `/v1/responses` | [Chat and Responses](references/chat-responses.md) | +| JSON conforming to a schema | Either text endpoint, if the model/channel supports it | [Structured output](references/structured-output.md) | +| Text vectors for retrieval or similarity | `/v1/embeddings` | [Embedding and Rerank](references/retrieval.md) | +| Reorder candidate documents against a query | `/v1/rerank` | [Embedding and Rerank](references/retrieval.md) | +| Probabilistic yes/no, categorical choice or ordinal scoring | `/v1/alpha/decisions`, alias `/api/alpha/decisions` | [Decisions](references/decisions.md) | -Keep `/v1/chat/completions` as a fallback for compatibility testing, not the default, unless live smoke tests prove it is cheaper and faster for the same schema. +Chat is a first-class mode, not a universal fallback. Responses is not inherently cheaper or faster. Decisions uses its own `state`/`questions` protocol; the inspected gateway does not automatically convert Chat/Responses requests into Decisions. Image and voice workflows have their own repository skills. -## Known Good Responses Shape +## Use the actual integration -Use this shape for low-latency structured output: +Default base URL is `https://api-n-cd.playones.com/v1`. Respect the user's intended deployment and available model; do not force a model/version from an old example. Query `/v1/models` when availability matters. A listed ID does not prove each endpoint, parameter, tool, schema or modality is supported. Model aliases and channel conversion can differ from the public provider's defaults. -```json -{ - "model": "gpt-5.5", - "input": [ - { - "role": "user", - "content": [ - { "type": "input_text", "text": "只输出一个 JSON object,不要 markdown。" }, - { "type": "input_image", "image_url": "data:image/jpeg;base64,...", "detail": "low" } - ] - } - ], - "text": { - "format": { - "type": "json_schema", - "name": "task_schema_name", - "strict": true, - "schema": { - "type": "object", - "additionalProperties": false, - "properties": { - "ok": { "type": "boolean" }, - "score": { "type": "number", "minimum": 0, "maximum": 1 }, - "label": { "type": "string", "maxLength": 64 } - }, - "required": ["ok", "score", "label"] - } - } - }, - "reasoning": { "effort": "none" }, - "truncation": "disabled", - "max_output_tokens": 700 -} -``` +Use `EAPIL_API_KEY`, then `OPENAI_API_KEY`, or the user's specified environment source. Keep credentials, private inputs, vectors and raw provider responses out of committed artifacts and routine logs. Use synthetic small probes for compatibility; do not replay production data or fan out broad paid benchmarks merely to verify an example. -Call `POST {base_url}/responses`, where `base_url` may already include `/v1`. +Read only the relevant mode reference. Keep endpoint-specific field shapes distinct: Responses `input` and `text.format`, Chat `messages` and `response_format`, Decisions `state` and `questions`. Pass explicit supported values without dropping `0`, `false` or empty collections that carry meaning. Omit optional model-specific parameters when their support is unknown. -## Chat Fallback Shape +## Test the contract -Use this only for fallback comparison: +Use [smoke_api.py](scripts/smoke_api.py) with an explicit UTF-8 JSON payload for each mode. It supports dry runs and Chat/Responses SSE, omits generated text and vectors from summaries, and performs mode-specific structural checks. Decisions summaries include labels/answers; dry runs print the payload. It makes one request and executes no returned tools. For a ready-made structured/vision probe, use [smoke_structured_output.py](scripts/smoke_structured_output.py). -```json -{ - "model": "gpt-5.5", - "messages": [ - { - "role": "user", - "content": [ - { "type": "text", "text": "只输出一个 JSON object,不要 markdown。" }, - { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,...", "detail": "low" } } - ] - } - ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "task_schema_name", - "strict": true, - "schema": { - "type": "object", - "additionalProperties": false, - "properties": { - "ok": { "type": "boolean" }, - "score": { "type": "number", "minimum": 0, "maximum": 1 }, - "label": { "type": "string", "maxLength": 64 } - }, - "required": ["ok", "score", "label"] - } - } - }, - "reasoning_effort": "none", - "temperature": 0, - "max_tokens": 700 -} -``` +Check the result, not only HTTP 200: completed text or a tool call, usable indices/dimensions, expected typed answers, or valid application JSON. Parsing JSON alone does not validate a schema. For streaming, consume deltas/items and require a terminal success; truncated output, refusals and error events are separate outcomes. -Call `POST {base_url}/chat/completions`. +Start with one request and a small supported output budget. A timeout can leave an unknown billed outcome; inspect status before retrying. Compare latency and usage only for equivalent prompts, inputs, settings and successful results. Reasoning token fields, encrypted reasoning and route conversion are diagnostic evidence, not universal proof of a forced reasoning setting. -## Schema Rules +## Deliver -- Keep schemas compact. Prefer arrays of present tags over objects containing every boolean flag. -- Use `additionalProperties: false` and `required` for every field in strict schemas. -- Bound free text with `maxLength`; keep notes short. -- Use enum arrays for tags: - -```json -{ - "negative_tags": { - "type": "array", - "maxItems": 8, - "items": { - "type": "string", - "enum": ["empty_frame", "screen_distraction", "subject_too_small"] - } - } -} -``` - -## Prompt Rules - -- State that the model must output only one JSON object, no markdown and no explanation. -- If human decisions are already fixed, state that the model must not change them. -- For visual labeling, require all camera IDs to be present. -- Tell the model to list only actually present negative tags; use `[]` when none are visible. -- Avoid asking for long rationales. Use short notes for human audit only. - -## Token And Latency Diagnostics - -First compare visible output with usage: - -- `raw_text_chars` should roughly match a small structured JSON response. -- `usage.output_tokens_details.reasoning_tokens` should be `0` when reasoning effort is `none`. -- Responses output should not contain an `output` item with `type: "reasoning"` or `encrypted_content`. - -If `output_tokens` is thousands while visible JSON is small: - -1. Check EAPIL gateway settings for forced reasoning overrides such as XHIGH. -2. Re-test with `/v1/responses`, strict schema, and `reasoning: {"effort":"none"}`. -3. Inspect whether `encrypted_content` appears in the raw response. -4. Do not blame the prompt until gateway forced reasoning is ruled out. - -Known good reference from this project after gateway fix: - -```text -endpoint: /v1/responses -model: gpt-5.5 -reasoning: none -input_tokens: 1704 -output_tokens: 230 -reasoning_tokens: 0 -visible JSON: about 786 chars -elapsed: about 8s -raw response: no encrypted_content -``` - -## Operational Rules - -- Start teacher-labeling concurrency at 1-3. Raise only after observing stable latency and no 429/5xx bursts. -- Persist job status so interrupted labeling can resume from pending/error jobs. -- Log request mode, reasoning effort, usage, elapsed seconds, visible JSON length, parse status, and whether hidden reasoning exists. -- Keep real API keys out of logs and saved request artifacts. - -## Smoke Test Script - -Use `scripts/smoke_structured_output.py` to verify gateway behavior before large labeling runs: - -```bash -python /root/.codex/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py \ - --base-url https://api-n-cd.playones.com/v1 \ - --model gpt-5.5 \ - --endpoint responses \ - --reasoning-effort none \ - --api-key-env OPENAI_API_KEY -``` - -Add `--image /path/to/frame.jpg` one or more times for vision tests. Add `--dry-run` to print the request shape without calling the API. +Identify endpoint, deployed model alias, relevant parameters, parsing/validation and observed limitations. Use the dated [verification record](references/verification.md) to distinguish source inspection, this run's live tests and untested features. A tiny smoke test proves the tested contract, not accuracy or reliability for all workloads. diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml index 6c2e27be..d5f18f60 100644 --- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "EAPIL LLMAPI Structured" - short_description: "EAPIL LLM API structured JSON guide" - default_prompt: "Use $eapil-llmapi-structured to build or debug an EAPIL LLM API structured-output request." + display_name: "EAPIL LLM API" + short_description: "EAPIL text, retrieval and Decisions API guide" + default_prompt: "Use $eapil-llmapi-structured to build, test or debug an EAPIL LLM API request using the appropriate endpoint." diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md new file mode 100644 index 00000000..91970865 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md @@ -0,0 +1,98 @@ +# Chat, Responses, tools and streaming + +Use the model and protocol supported by the selected channel. Examples use `gpt-6-luna`, tested on this deployment on 2026-09-30; it is an example, not a permanent default. See [verification](verification.md) for limitations. + +## Ordinary text + +Chat request to `/v1/chat/completions`: + +```json +{ + "model": "gpt-6-luna", + "messages": [{"role": "user", "content": "Reply with exactly PONG."}], + "reasoning_effort": "low", + "max_completion_tokens": 256 +} +``` + +Read `choices[*].message.content` for text, or `message.tool_calls` for requested tools. Check `finish_reason`; `length`, content filtering and refusals are not ordinary successful answers. A `stop` with empty content and no tool call does not prove the task succeeded. + +Responses request to `/v1/responses`: + +```json +{ + "model": "gpt-6-luna", + "input": "Reply with exactly PONG.", + "reasoning": {"effort": "low"}, + "max_output_tokens": 256, + "store": false +} +``` + +Read message items with `content[*].type == "output_text"` from `output`; an SDK's `output_text` convenience property need not exist in raw JSON. Other output items can be reasoning or function calls. Check `status`, refusal parts, `error` and `incomplete_details`. Do not parse a serialized copy of the entire response as if it were the assistant's text. + +Reasoning effort values, token limits, sampling controls and context limits depend on the model/channel. Do not send `temperature: 0` or `reasoning: none` universally. Legacy Chat models may use `max_tokens`; current reasoning models commonly use `max_completion_tokens`. Responses uses `max_output_tokens`. These budgets may include reasoning, so a small budget can truncate before visible output. + +## Vision + +Chat uses: + +```json +{"type":"image_url","image_url":{"url":"data:image/png;base64,...","detail":"low"}} +``` + +Responses uses: + +```json +{"type":"input_image","image_url":"data:image/png;base64,...","detail":"low"} +``` + +Place these alongside `text` or `input_text` content respectively. Preserve reference order and ensure MIME matches the file. Choose image detail according to the task and model; low detail is not appropriate for every OCR or small-object task. Remote URLs, files, audio and video formats have model-specific support; a text probe does not establish them. + +## Client function tools + +Chat tool definition: + +```json +{"type":"function","function":{"name":"echo","description":"Echo a string.","parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},"strict":true}} +``` + +Responses tool definition: + +```json +{"type":"function","name":"echo","description":"Echo a string.","parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},"strict":true} +``` + +Chat tool choice wraps the function name in `function`; Responses puts `name` directly on the choice. This deployment accepted both shapes in live probes. Validate tool names and JSON arguments before invoking the application's authorized action. The gateway and smoke script do not execute client tools for you. + +For continuation, Chat appends the assistant tool-call message and a `role: tool` message using the matching `tool_call_id`. Responses supplies the function-call item and `type: function_call_output`, with its matching `call_id` and string `output`. Keep relevant earlier input/output in the next request when stored state is unavailable. A `previous_response_id` depends on provider storage and routing support; `store:false` is not a guarantee of remote conversation persistence. Do not synthesize a mismatched call ID. + +Hosted tools, WebSocket `/v1/responses`, realtime, `/v1/responses/compact`, `/v1/alpha/search` and `/v1/memories/trace_summarize` exist in the inspected source or upstream protocols, but this guide's live coverage does not establish all of them. Use their actual deployment/provider protocol before adding them to an application; do not infer support from an ordinary HTTP Responses call. + +## SSE + +Set `stream:true` for supported Chat/Responses requests. Chat can request `stream_options:{"include_usage":true}` when supported. Consume SSE frames, not individual network chunks; join multiline `data:` and decode complete UTF-8 lines. + +Chat delivers content/tool argument deltas and a finishing choice, often followed by a separate usage chunk and `[DONE]`. Responses delivers typed events such as `response.output_text.delta`, output-item events and `response.completed`. Accumulate the items/deltas: on this gateway the final completed snapshot may have an empty `output` even though earlier events contain the full answer. Do not double-count `delta`, text-done and item-done copies. + +Require a terminal success and handle refusal, failed/incomplete/error events and premature EOF separately. Do not use a received first token as proof of completion. First-token latency and total elapsed time are different metrics; the bundled probe reports total time. + +## Smoke commands + +Write the selected request as UTF-8 JSON in `request.json`, then run from the project. Resolve `` from the actual installed location: + +```powershell +python '/scripts/smoke_api.py' --mode chat --payload-file request.json --dry-run +python '/scripts/smoke_api.py' --mode responses --payload-file request.json --expect-text PONG +``` + +Use the matching mode for the payload. Add `rtk proxy` in this repository. Credentials default to `EAPIL_API_KEY`, then `OPENAI_API_KEY`; `--api-key-env` selects another explicitly configured variable. Live calls make one request with no retry. Dry run prints the supplied payload, so use synthetic input when sharing that output. + +## Sources + +Reviewed 2026-09-30: local New-API `router/relay-router.go`, `service/relayconvert/chat_to_responses.go`, `relay/channel/openai/relay_responses.go` and mode-specific runtime probes. The source checkout includes uncommitted changes and is not a deployment identity. + +- [OpenAI Responses API](https://developers.openai.com/api/reference/resources/responses/methods/create) +- [OpenAI Chat Completions API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create) +- [OpenAI function calling](https://developers.openai.com/api/docs/guides/function-calling) +- [OpenAI streaming](https://developers.openai.com/api/docs/guides/streaming-responses) diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md new file mode 100644 index 00000000..41d18cc6 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md @@ -0,0 +1,93 @@ +# Typed Decisions + +Use Decisions for probability judgments, categorical decisions and ordinal scores. It has a separate request/response contract from Chat and Responses; it does not return an assistant message or execute the chosen business action. + +## Gateway and model selection + +POST `/v1/alpha/decisions` or `/api/alpha/decisions` with Bearer authentication. Use `model`, a non-null `state`, and a nonempty `questions` object. Omit `stream` or set it to `false`; this gateway rejects `stream:true`. The inspected New-API implementation routes to an OpenRouter channel's `/alpha/decisions`, preserving the request body except root model mapping. It does not automatically translate Chat/Responses requests into Decisions. + +| Model alias | Live coverage on 2026-09-30 | Input boundary | +|---|---|---| +| `typesafe/jev-1.13` | `noul`, `choice`, `score` together | Ticket-shaped object tested | +| `upstage/solar-decide` | `noul`, `choice`, `score` together | Same object tested; score can be fractional | +| `respan/span-01` | `noul` | Conversation object below tested | +| `respan/span-01-lite` | `noul`; `choice` rejected | Conversation object below tested | + +The public protocol permits broader state shapes than an individual model may accept. A successful Jev request is not a portable Respan template. Respan's observed validation restricts questions to `noul`, and accepts a state string or an object containing only `input`/`output` messages. Only the conversation object was live tested here. See [verification](verification.md) for exact coverage. + +## Classify and score in one request + +This synthetic request passed with Jev and, replacing `model`, Solar: + +```json +{ + "model": "typesafe/jev-1.13", + "state": {"ticket": "Checkout fails after payment. Customers cannot complete orders."}, + "questions": { + "is_bug": { + "type": "noul", + "instructions": "Does the ticket describe a software defect?", + "criteria": {"true": "Broken or unexpected behavior", "false": "A question without a defect"} + }, + "team": { + "type": "choice", + "instructions": "Which team should own this ticket?", + "criteria": {"payments": "Payment and checkout issues", "account": "Login and profile issues"} + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the issue?", + "criteria": ["Can wait", "Fix this week", "Blocking payment now"] + } + } +} +``` + +Interpret `answers` by the original question keys and returned `type`: + +- `noul`: `noul` is the probability in `[0,1]` that the condition holds, not a Boolean. Choose a task-specific threshold after evaluating relevant data. +- `choice`: `choice` identifies a criterion key; `probabilities` maps those keys to probabilities. Preserve string labels. `confidence` is a separate field, not necessarily the largest probability. +- `score`: `criteria` is an ordered list; zero-based positions define the scale. `probabilities` uses string index keys, and `legend`, when present, identifies the descriptions. `score` may be fractional; do not cast it to an integer merely because the criteria have integer positions. + +Validate matching question/answer keys, types, finite numbers, ranges, probability labels and sums. The bundled probe checks the object/list criteria shapes above; additional protocol forms require corresponding handling. Preserve `usage` fields when available; monetary `cost` and token counts are different measures. One arithmetic or ticket test does not prove calibration or classification quality. + +## Respan conversation evaluation + +Tested with both Respan aliases: + +```json +{ + "model": "respan/span-01-lite", + "state": { + "input": [{"role": "user", "content": "What is 2 plus 2?"}], + "output": {"role": "assistant", "content": "The answer is 4."} + }, + "questions": { + "correct": { + "type": "noul", + "instructions": "The assistant gives the correct arithmetic answer.", + "criteria": {"true": "Correct answer", "false": "Incorrect answer"} + } + } +} +``` + +The observed state validator requires string `content`, roles `system`, `user`, `assistant` or `tool`, and an assistant role for `output`. `state:{"messages":[...]}` failed. Do not put Chat's multimodal message parts or arbitrary extra state fields into this Respan object. + +A `choice` request returned a generic error mentioning plain-string instructions/criteria. The `noul` request above with a true/false criteria object nevertheless succeeded, so that error does not establish that all criteria objects are rejected. Keep the tested shape or verify the alternative explicitly; do not extrapolate a `choice` failure into a different schema requirement. + +## Probe + +Save the selected synthetic request as UTF-8 `request.json`: + +```powershell +python '/scripts/smoke_api.py' --mode decisions --payload-file request.json --dry-run +python '/scripts/smoke_api.py' --mode decisions --payload-file request.json +python '/scripts/smoke_api.py' --mode decisions --payload-file request.json --decisions-alias +``` + +In this repository prefix external commands with `rtk proxy`. The summary includes decision labels/answers, so use synthetic questions for shared diagnostics. The helper performs no chosen action and makes no automatic retry. HTTP 400 should lead to checking model-specific state/question support, not silently substituting a Chat call. + +## Sources + +Reviewed 2026-09-30: [OpenRouter Decisions protocol](https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request), model pages for [Jev](https://openrouter.ai/typesafe/jev-1.13), [Solar](https://openrouter.ai/upstage/solar-decide) and [Respan lite](https://openrouter.ai/respan/span-01-lite). Local New-API evidence: `router/relay-router.go`, `dto/openrouter_decisions.go`, `relay/helper/valid_request.go`, `relay/openrouter_decisions_handler.go`. Deployment observations are recorded separately in [verification](verification.md). diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md new file mode 100644 index 00000000..bee8be37 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md @@ -0,0 +1,62 @@ +# Embedding and Rerank + +These are retrieval operations, not conversations. Use a model enabled for the actual endpoint. Model names and scores do not establish dimensionality, compatibility or calibrated probabilities. + +## Embedding + +POST `/v1/embeddings`: + +```json +{ + "model": "text-embedding-3-small", + "input": ["blue", "red"], + "encoding_format": "float" +} +``` + +Read each `data[*].embedding` and associate it with the input using its `index`. Confirm item count, finite values, actual vector length and consistent dimensions. Preserve the embedding model/version and preprocessing used to build the index; do not mix vectors from incompatible spaces. Base64 encodings and token-array inputs need their own provider-compatible handling; the bundled probe checks float vectors from text input. + +The live gateway returned **1024 dimensions** for this alias. Adding `dimensions:128` still returned 1024, so the dimension option was not honored in this tested route. Do not size a vector database solely from a public model name or assume every accepted parameter affects the result. Treat a required dimension mismatch as a failed contract. Other deployments/channels may behave differently. + +`dimensions` is an optional upstream feature of certain models. This gateway's source forwards it, but code forwarding alone cannot prove implementation. Batch input order must be recovered by indices even if response rows arrive in a different order. Context/input and batch limits also depend on the provider. + +## Rerank + +POST `/v1/rerank`: + +```json +{ + "model": "bge-reranker-v2-m3-Q8_0.gguf", + "query": "How do I reset my password?", + "documents": [ + "Fresh apples for sale.", + "Open account settings and choose reset password.", + "Rain is expected tomorrow." + ], + "top_n": 2, + "return_documents": true +} +``` + +Read `results[*].index` and `relevance_score`; the index identifies the original candidate, not its new rank. Preserve candidate IDs separately and map them by index. This deployment returned `[1,0]` for the example and scores about `1.77` and `-11.02`; these are ranking scores, not probabilities bounded by 0–1. Do not invent a universal confidence threshold across models or queries. + +Although `return_documents:true` was supplied, this route returned only indices/scores. Recover source documents from your original list. Object-shaped documents, chunk limits and additional options depend on the selected adapter; string documents are the tested baseline. The source preserves explicit `top_n` and `return_documents` through pointer fields, including zero/false values, but their accepted semantics remain provider-specific. + +Embedding retrieval finds candidates; reranking reorders a candidate set against the current query. Choose how many candidates to retrieve/rerank for the application's quality and cost requirements rather than inheriting a fixed number from an example. + +## Probes + +Put the matching request into UTF-8 `request.json`: + +```powershell +python '/scripts/smoke_api.py' --mode embedding --payload-file request.json +python '/scripts/smoke_api.py' --mode rerank --payload-file request.json +``` + +The helper does not dump vectors or documents. It validates indices, counts and finite numeric data; it reports actual dimensions and rejects a requested dimension mismatch. A valid vector or a sorted result is not proof of application retrieval quality. + +## Sources + +Reviewed 2026-09-30: local New-API `dto/embedding.go`, `dto/rerank.go`, `relay/embedding_handler.go`, `relay/rerank_handler.go`, `relay/common_handler/rerank.go` and the dated live [verification](verification.md). + +[OpenAI embedding guide](https://developers.openai.com/api/docs/guides/embeddings) describes upstream embedding behavior; the alias-specific observations above come from this gateway's tests. diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md new file mode 100644 index 00000000..a89a0ba9 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md @@ -0,0 +1,59 @@ +# Structured JSON output + +Use for a machine-readable result, not as the default for all LLM tasks. Select a model/channel that actually supports the needed schema. The live example below passed through Responses; the same model's Chat strict-schema probe returned an empty answer. See [verification](verification.md). + +## Distinct field shapes + +Responses puts the format under `text.format`: + +```json +{ + "model": "gpt-6-luna", + "input": "Return ok=true and label=smoke.", + "text": {"format": { + "type": "json_schema", "name": "gateway_smoke", "strict": true, + "schema": { + "type": "object", "additionalProperties": false, + "properties": {"ok": {"type": "boolean"}, "label": {"type": "string", "enum": ["smoke"]}}, + "required": ["ok", "label"] + } + }}, + "reasoning": {"effort": "low"}, + "max_output_tokens": 256, + "store": false +} +``` + +The analogous Chat field is: + +```json +{"response_format":{"type":"json_schema","json_schema":{"name":"gateway_smoke","strict":true,"schema":{"type":"object","additionalProperties":false,"properties":{"ok":{"type":"boolean"},"label":{"type":"string","enum":["smoke"]}},"required":["ok","label"]}}}} +``` + +Combine it with a normal Chat request's `model` and `messages`; do not copy Responses `text.format` there. This describes the protocol, not a claim that every model accepts it. A supported function tool uses its tool parameter schema instead of the final-answer format. + +## Schema and result checks + +Use only the JSON Schema subset supported by the selected model. Strict OpenAI examples require object fields to be listed as required and disallow additional properties; optional concepts can be represented by a nullable type where supported. Do not universally add `maxLength`, numeric bounds or array limits from an old task: supported constraints vary, and application validation still matters. + +JSON mode (`json_object`) aims for valid JSON but does not enforce a particular schema. A prompt saying “only JSON” alone establishes neither valid syntax nor adherence. If strict enforcement is required, do not silently downgrade to prose or JSON mode after an error. + +Extract actual output text, check completion/refusal, parse JSON, validate against the application schema with a real schema validator, and check task-specific values. Parsing any dictionary is insufficient: missing/extra keys, wrong field types, invalid enum values and truncated output must fail. Do not repair invalid business data by inventing values. JSON numbers and booleans are distinct even when a programming language treats `True == 1`. + +The generic probe's `--expect-json` checks parsing only; it is not a general JSON Schema validator. The convenience probe validates its own small synthetic task's exact keys/types/values, not arbitrary schemas: + +```powershell +python '/scripts/smoke_structured_output.py' --model gpt-6-luna --endpoint responses --reasoning-effort low +``` + +`--image` can repeat for a vision-shaped smoke request, but its synthetic answer does not measure image understanding. For a visual task, verify predictions against known image content. `--dry-run` needs no key. Model selection is now explicit and reasoning defaults to omission; old CLI flags remain, but the old fixed `gpt-5.5`/`none` defaults have been removed. + +## Diagnostics + +Record endpoint/model/settings, success category, visible text length, elapsed time and available usage fields. A small visible answer can coexist with substantial reasoning tokens. Inspect the model's effort support, the gateway's channel overrides or conversion, and the actual usage before attributing cost to the prompt. Missing usage does not equal zero tokens; encrypted reasoning is not itself an error or proof of forced XHIGH. Keep benchmark inputs/settings equivalent and exclude failed outputs from success comparisons. + +For HTTP 400, check the endpoint field shape, supported schema subset and model-specific options. For HTTP 200 with an empty/incomplete answer, do not mark the test as passed. Choose another verified model or endpoint deliberately when the application permits it. + +## Sources + +Reviewed 2026-09-30: [OpenAI structured outputs](https://developers.openai.com/api/docs/guides/structured-outputs), local conversion source and live probes described in [verification](verification.md). diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md new file mode 100644 index 00000000..93759170 --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md @@ -0,0 +1,51 @@ +# Verification record + +Date: **2026-09-30**. Live target: `https://api-n-cd.playones.com`. Requests used existing environment credentials and tiny synthetic inputs. No production inputs or credentials are stored here. These observations describe the tested alias/channel/request combination; they are not promises for every deployment or model. + +## Live requests + +| Case | Model / endpoint | Observed result | +|---|---|---| +| Model discovery | GET `/v1/models` | Passed; 121 IDs returned. Listing does not establish feature support. | +| Ordinary text | `gpt-6-luna`, Chat and Responses | Both returned exact `PONG`; normal completion. Low reasoning, 256-token budget; Responses `store:false`. | +| Text SSE | Same model, both endpoints | Both passed exact `PONG` and terminal completion. Responses final snapshot had empty `output`; earlier deltas/items contained the answer. Parser repaired and retested successfully. | +| Strict JSON schema | Same model, Chat | Two probes returned HTTP 200, `stop`, empty content and zero completion usage. **Failed usable-output check**; cause not established. | +| Strict JSON schema | Same model, Responses | Passed expected keys/types/values, including the convenience script's `ok=true`, `label="smoke"`, `score=1`. | +| Client function tools | Same model, both endpoints, nonstream | Each returned one `echo` call with parsed arguments `{"text":"ping"}`. Tool execution and continuation were not tested. | +| Vision + strict JSON | Same model, Responses | Supplied synthetic 32×32 red PNG; returned exactly `{"color":"red"}` against red/blue enum. Chat vision not tested. | +| Float embeddings | `text-embedding-3-small`, `/v1/embeddings` | Two text inputs returned indices 0/1 and two finite 1024-dimension vectors. | +| Requested embedding dimension | Same alias, `dimensions:128` | HTTP 200, still 1024 dimensions. **Requested dimension not honored**; helper rejects this mismatch. | +| Rerank | `bge-reranker-v2-m3-Q8_0.gguf`, `/v1/rerank` | Password query over three documents, `top_n:2`: indices `[1,0]`; ranking scores approximately `1.77`, `-11.02`. No documents returned despite `return_documents:true`. | +| Three Decisions types | `typesafe/jev-1.13`, `/v1/alpha/decisions` | `noul`, `choice`, `score` together passed. Bug probability .97, team payments, urgency score 2. | +| Three Decisions types | `upstage/solar-decide`, same endpoint | All three passed. Bug probability .984022; team payments probability .993307, confidence .942033; urgency score **1.967132**. Confidence differs from maximum probability. | +| Respan `noul` | `respan/span-01`, `/v1/alpha/decisions`; lite, `/api/alpha/decisions` | Both passed using conversation `input`/assistant `output`; arithmetic correctness probability .95715207. Both gateway paths were exercised, not every model on both paths. | +| Respan invalid state | lite, `state.messages` | HTTP 400; requires string state or the constrained `input`/`output` shape. | +| Respan categorical question | lite, `choice` | HTTP 400; error states Respan accepts only `noul`. Successful `noul` used true/false criteria object. | +| Decisions streaming | `stream:true` | HTTP 400: streaming not supported. | + +Examples matching the inputs above are in [Chat/Responses](chat-responses.md), [structured output](structured-output.md), [retrieval](retrieval.md) and [Decisions](decisions.md). No broad model benchmark, quality evaluation, latency comparison or automatic retry was performed. + +## Source inspection + +Read-only source: WSL Debian `/home/code/new-api`, HEAD `8651bc59`, with existing uncommitted Decisions changes. This checkout is **not a verified identity of deployed service code**. Source was not modified. + +- Router exposes Chat, Responses, Embedding, Rerank, `/v1/alpha/decisions` and its `/api` alias. +- Decisions validation requires model, non-null state, nonempty questions and nonstream mode. The handler uses an OpenRouter channel, forwards the raw body with root model mapping, and checks answers/usage/cost. No Chat/Responses-to-Decisions conversion exists in the inspected code. +- General Chat/Responses conversion exists, but channel settings and model support determine which features survive it. Source presence does not prove a deployed route works. +- Embedding dimensions/encoding and optional Rerank values are represented and forwarded in source. Live probes demonstrate that accepted options may still be ignored downstream. + +Responses compact/WebSocket, realtime, hosted tools, search/memory routes, stored continuation, streamed function tools, base64/token-array embeddings and other aliases were **not live verified**. Image and audio workflows remain covered by separate skills. + +## Local regression checks + +Run the network-mocked tests from this repository: + +```powershell +rtk proxy python -m unittest discover -s plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests -v +``` + +All **15 tests passed**. Coverage includes wire shapes and explicit zero/false/Unicode values, endpoint joining, empty/truncated/refused outputs, UTF-8 SSE and terminal detection, tool-call identity/argument accumulation, empty final Responses snapshots, vector indices/dimensions, Rerank logits, fractional Decisions scores and concrete synthetic JSON validation. These tests validate helper behavior, independently of live service availability. + +Skill frontmatter validation, root AGENTS.md mechanical validation, all five plugin manifests/version/assets and 27 local documentation links also passed. Plugin/skill IDs remain `eapil-llmapi-structured`; the display name is now a general LLM API guide, and the repository baseline remains 1.0.0. + +`--expect-json` only checks JSON parsing. The convenience script checks its own fixed schema and expected values. Neither replaces application-specific schema validation or quality evaluation. diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py new file mode 100644 index 00000000..46289e5f --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Small, explicit EAPIL API probes. No retries or automatic tool execution.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import sys +import time +from pathlib import Path +from typing import Any +from urllib import error, parse, request + + +PATHS = { + "models": "models", "chat": "chat/completions", "responses": "responses", + "embedding": "embeddings", "rerank": "rerank", "decisions": "alpha/decisions", +} + + +def endpoint_url(base_url: str, mode: str, decisions_alias: bool = False) -> str: + parts = parse.urlsplit(base_url) + if parts.scheme not in {"http", "https"} or not parts.netloc: + raise ValueError("base URL must be HTTP or HTTPS") + if parts.username or parts.password or parts.query or parts.fragment: + raise ValueError("base URL must not contain credentials, query or fragment") + base = base_url.rstrip("/") + if decisions_alias: + if mode != "decisions": + raise ValueError("the /api alias applies only to decisions") + if base.endswith(("/v1", "/api")): + base = base.rsplit("/", 1)[0] + return base + "/api/alpha/decisions" + if base.endswith("/api"): + if mode != "decisions": + raise ValueError("use the service root or /v1 for this mode") + elif not base.endswith("/v1"): + base += "/v1" + return base + "/" + PATHS[mode] + + +def read_payload(path: str) -> dict[str, Any]: + payload = json.loads(Path(path).read_text(encoding="utf-8-sig")) + if not isinstance(payload, dict): + raise ValueError("payload must be a JSON object") + return payload + + +def extract_text(mode: str, response: dict[str, Any]) -> str: + if mode == "chat": + choices = response.get("choices") or [] + if choices and isinstance(choices[0], dict): + message = choices[0].get("message") or {} + text = message.get("content") if isinstance(message, dict) else None + return text if isinstance(text, str) else "" + return "" + texts = [] + for item in response.get("output") or []: + if isinstance(item, dict) and item.get("type") == "message": + for part in item.get("content") or []: + if isinstance(part, dict) and part.get("type") == "output_text": + if isinstance(part.get("text"), str): + texts.append(part["text"]) + return "\n".join(texts) + + +def usage_summary(response: dict[str, Any]) -> dict[str, Any]: + usage = response.get("usage") or {} + if not isinstance(usage, dict): + return {} + keys = ("input_tokens", "output_tokens", "prompt_tokens", "completion_tokens", + "total_tokens", "cost") + result = {key: usage[key] for key in keys if key in usage} + for key in ("input_tokens_details", "output_tokens_details", + "prompt_tokens_details", "completion_tokens_details"): + if isinstance(usage.get(key), dict): + result[key] = usage[key] + return result + + +def finite_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def validate_response(mode: str, body: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict) or body.get("error"): + raise ValueError("response is not a successful JSON object") + result: dict[str, Any] = {"usage": usage_summary(body)} + if mode == "models": + rows = body.get("data") + if not isinstance(rows, list): + raise ValueError("models response has no data array") + result["models"] = [row["id"] for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str)] + elif mode == "chat": + choices = body.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("chat response has no choices") + reasons = [c.get("finish_reason") for c in choices if isinstance(c, dict)] + if len(reasons) != len(choices) or any(r not in {"stop", "tool_calls", "function_call"} for r in reasons): + raise ValueError("chat response did not finish normally") + messages = [c.get("message") for c in choices] + if any(not isinstance(m, dict) for m in messages): + raise ValueError("chat response has no assistant message") + result.update(finish_reasons=reasons, text_chars=len(extract_text(mode, body)), + tool_calls=sum(len(m.get("tool_calls") or []) for m in messages), + refusal=any(bool(m.get("refusal")) for m in messages)) + if result["refusal"]: + raise ValueError("chat response refused the request") + if not result["text_chars"] and not result["tool_calls"] and not any(m.get("function_call") for m in messages): + raise ValueError("chat finished with no text or tool call") + elif mode == "responses": + if body.get("status") != "completed" or not isinstance(body.get("output"), list) or not body["output"]: + raise ValueError("Responses result is not completed") + result.update(status=body["status"], text_chars=len(extract_text(mode, body)), + output_types=[i.get("type") for i in body["output"] if isinstance(i, dict)]) + if any(isinstance(c, dict) and c.get("type") == "refusal" + for i in body["output"] if isinstance(i, dict) for c in i.get("content") or []): + raise ValueError("Responses result refused the request") + calls = [i for i in body["output"] if isinstance(i, dict) + and isinstance(i.get("type"), str) and i["type"].endswith("_call")] + if not result["text_chars"] and not calls: + raise ValueError("Responses completed with no text or tool call") + if any(i.get("status") in {"failed", "incomplete", "in_progress"} for i in calls): + raise ValueError("Responses tool call did not complete") + elif mode == "embedding": + rows = body.get("data") + inputs = payload.get("input") + count = len(inputs) if isinstance(inputs, list) else 1 + if not isinstance(rows, list) or len(rows) != count or not rows: + raise ValueError("embedding item count does not match input") + indices = [r.get("index") for r in rows if isinstance(r, dict)] + if len(indices) != count or any(type(i) is not int for i in indices) or sorted(indices) != list(range(count)): + raise ValueError("embedding indices do not match input") + vectors = [r.get("embedding") for r in rows] + if not all(isinstance(v, list) and v and all(finite_number(x) for x in v) for v in vectors): + raise ValueError("probe expects finite float embedding vectors; use encoding_format=float") + dimensions = [len(v) for v in vectors] + if len(set(dimensions)) != 1: + raise ValueError("embedding dimensions are inconsistent") + result.update(items=count, dimensions=dimensions, indices=indices) + if payload.get("dimensions") is not None: + result["requested_dimensions_honored"] = all(d == payload["dimensions"] for d in dimensions) + if not result["requested_dimensions_honored"]: + raise ValueError("requested embedding dimensions were not honored") + elif mode == "rerank": + rows = body.get("results") + documents = payload.get("documents") or [] + if not isinstance(rows, list) or not rows: + raise ValueError("rerank response has no results") + indices = [r.get("index") for r in rows if isinstance(r, dict)] + if len(indices) != len(rows) or any(type(i) is not int or not 0 <= i < len(documents) for i in indices) or len(set(indices)) != len(indices): + raise ValueError("rerank indices do not identify the input documents") + scores = [r.get("relevance_score") for r in rows] + if not all(finite_number(s) for s in scores): + raise ValueError("rerank scores are not finite numbers") + result.update(indices=indices, scores=scores, + documents_returned=all("document" in r for r in rows)) + elif mode == "decisions": + questions, answers = payload.get("questions"), body.get("answers") + if not isinstance(questions, dict) or not isinstance(answers, dict) or set(answers) != set(questions): + raise ValueError("decision answer keys do not match question keys") + for name, question in questions.items(): + answer = answers[name] + if not isinstance(question, dict) or not isinstance(answer, dict) or answer.get("type") != question.get("type"): + raise ValueError("decision answer type does not match its question") + kind, criteria = question["type"], question.get("criteria") + if kind == "noul": + value = answer.get("noul") + if not finite_number(value) or not 0 <= value <= 1: + raise ValueError("noul is not a probability") + elif kind in {"choice", "score"}: + labels = set(criteria) if kind == "choice" and isinstance(criteria, dict) else {str(i) for i in range(len(criteria))} if isinstance(criteria, list) else set() + probabilities = answer.get("probabilities") + confidence = answer.get("confidence") + if not labels or not isinstance(probabilities, dict) or set(probabilities) != labels: + raise ValueError("decision probability labels do not match criteria") + if not all(finite_number(p) and 0 <= p <= 1 for p in probabilities.values()) or not math.isclose(sum(probabilities.values()), 1, abs_tol=1e-3): + raise ValueError("decision probabilities are invalid") + if not finite_number(confidence) or not 0 <= confidence <= 1: + raise ValueError("decision confidence is invalid") + if kind == "choice" and answer.get("choice") not in labels: + raise ValueError("decision choice is not a criterion") + if kind == "score" and (not finite_number(answer.get("score")) or not 0 <= answer["score"] <= len(criteria) - 1): + raise ValueError("decision score is outside the ordinal range") + else: + raise ValueError("unknown decision type") + result["answers"] = answers + return result + + +def sse_events(response): + data = [] + for raw_line in response: + line = raw_line.decode("utf-8").rstrip("\r\n") + if not line: + if data: + yield "\n".join(data) + data = [] + elif line.startswith("data:"): + data.append(line[5:].lstrip(" ")) + if data: + yield "\n".join(data) + + +def read_stream(response, mode: str) -> dict[str, Any]: + if mode not in {"chat", "responses"}: + raise ValueError("stream probe supports Chat and Responses only") + choices, usage, done = {}, None, False + terminal = None + items = {} + text_parts = {} + for data in sse_events(response): + if data == "[DONE]": + done = True + continue + event = json.loads(data) + if not isinstance(event, dict) or event.get("error") or event.get("type") in {"error", "response.error", "response.failed", "response.incomplete"}: + raise ValueError("stream reported an error or incomplete result") + if mode == "responses": + kind = event.get("type") + index = event.get("output_index", 0) + if kind in {"response.output_item.added", "response.output_item.done"} and isinstance(event.get("item"), dict): + items[index] = event["item"] + elif kind in {"response.output_text.delta", "response.output_text.done"}: + part_key = (index, event.get("content_index", 0)) + if kind.endswith(".delta") and isinstance(event.get("delta"), str): + text_parts[part_key] = text_parts.get(part_key, "") + event["delta"] + elif isinstance(event.get("text"), str): + text_parts[part_key] = event["text"] + if event.get("type") == "response.completed": + terminal = event.get("response") + else: + if event.get("usage"): + usage = event["usage"] + for chunk in event.get("choices") or []: + index = chunk.get("index", 0) + choice = choices.setdefault(index, {"index": index, "message": {"content": ""}, "finish_reason": None}) + delta = chunk.get("delta") or {} + if isinstance(delta.get("content"), str): + choice["message"]["content"] += delta["content"] + if delta.get("refusal"): + choice["message"]["refusal"] = True + if delta.get("tool_calls"): + calls = choice["message"].setdefault("tool_calls", {}) + for call in delta["tool_calls"]: + target = calls.setdefault(call.get("index", 0), {"function": {"arguments": ""}}) + for field in ("id", "type"): + if field in call: + target[field] = call[field] + function = call.get("function") or {} + if "name" in function: + target["function"]["name"] = function["name"] + if isinstance(function.get("arguments"), str): + target["function"]["arguments"] += function["arguments"] + if chunk.get("finish_reason") is not None: + choice["finish_reason"] = chunk["finish_reason"] + if mode == "responses": + if not isinstance(terminal, dict): + raise ValueError("stream ended without response.completed") + # Some gateways omit the accumulated output from the terminal snapshot. + if not terminal.get("output"): + for (index, content_index), text in sorted(text_parts.items()): + item = items.setdefault(index, {"type": "message", "role": "assistant", "content": []}) + if item.get("type") == "message": + content = item.setdefault("content", []) + while len(content) <= content_index: + content.append({"type": "output_text", "text": ""}) + content[content_index] = {**content[content_index], "type": "output_text", "text": text} + terminal["output"] = [items[i] for i in sorted(items)] + return terminal + if not done: + raise ValueError("chat stream ended without [DONE]") + for choice in choices.values(): + if isinstance(choice["message"].get("tool_calls"), dict): + choice["message"]["tool_calls"] = list(choice["message"]["tool_calls"].values()) + return {"choices": [choices[i] for i in sorted(choices)], "usage": usage} + + +def fetch_response(url: str, payload: dict[str, Any] | None, key: str, timeout: float, mode: str) -> dict[str, Any]: + stream = bool(payload and payload.get("stream")) + body = None if payload is None else json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8") + req = request.Request(url, data=body, headers={"Authorization": "Bearer " + key, + "Content-Type": "application/json", "Accept": "text/event-stream" if stream else "application/json"}, + method="GET" if payload is None else "POST") + with request.urlopen(req, timeout=timeout) as response: + if stream: + if "text/event-stream" not in response.headers.get("Content-Type", ""): + raise ValueError("requested stream did not return text/event-stream") + return read_stream(response, mode) + return json.load(response) + + +def resolve_key(env_name: str | None) -> str: + names = [env_name] if env_name else ["EAPIL_API_KEY", "OPENAI_API_KEY"] + for name in names: + value = os.getenv(name, "").strip() + if value: + return value + raise ValueError("missing API key environment variable") + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=PATHS, required=True) + parser.add_argument("--base-url", default="https://api-n-cd.playones.com/v1") + parser.add_argument("--payload-file") + parser.add_argument("--api-key-env") + parser.add_argument("--timeout-s", type=float, default=60) + parser.add_argument("--decisions-alias", action="store_true") + parser.add_argument("--expect-text") + parser.add_argument("--expect-json", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + key = "" + try: + if not math.isfinite(args.timeout_s) or args.timeout_s <= 0: + raise ValueError("timeout must be positive and finite") + if args.mode != "models" and not args.payload_file: + raise ValueError("this mode requires --payload-file") + if args.mode == "models" and args.payload_file: + raise ValueError("models is a GET request without a payload") + if (args.expect_json or args.expect_text is not None) and args.mode not in {"chat", "responses"}: + raise ValueError("text expectations apply only to Chat and Responses") + payload = read_payload(args.payload_file) if args.payload_file else None + url = endpoint_url(args.base_url, args.mode, args.decisions_alias) + if args.dry_run: + print(json.dumps({"url": url, "method": "GET" if payload is None else "POST", "payload": payload}, ensure_ascii=False, indent=2)) + return 0 + key = resolve_key(args.api_key_env) + started = time.monotonic() + body = fetch_response(url, payload, key, args.timeout_s, args.mode) + summary = validate_response(args.mode, body, payload or {}) + if args.expect_text is not None and extract_text(args.mode, body) != args.expect_text: + raise ValueError("output text did not match the expectation") + if args.expect_json: + value = json.loads(extract_text(args.mode, body)) + summary["json_kind"] = type(value).__name__ + summary.update(mode=args.mode, model=payload.get("model") if payload else None, + stream=bool(payload and payload.get("stream")), elapsed_s=round(time.monotonic() - started, 3), ok=True) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + except error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + try: + parsed_error = json.loads(raw).get("error", {}) + code = parsed_error.get("code") if isinstance(parsed_error, dict) else None + except (ValueError, AttributeError): + code = None + print(json.dumps({"ok": False, "http_status": exc.code, "error_code": code, + "note": "inspect the provider error locally; raw body omitted"}), file=sys.stderr) + return 1 + except (error.URLError, TimeoutError, OSError, ValueError, TypeError, KeyError, IndexError, AttributeError, OverflowError) as exc: + message = str(exc) + if key: + message = message.replace(key, "[redacted]") + message = re.sub(r"Bearer\s+\S+", "Bearer [redacted]", message) + print(json.dumps({"ok": False, "error_type": type(exc).__name__, "message": message[:240]}, ensure_ascii=False), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py index 278d7e83..d8a27754 100644 --- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py @@ -1,220 +1,106 @@ #!/usr/bin/env python3 -"""Smoke-test EAPIL/OpenAI-compatible structured output behavior.""" - +"""Convenience structured/vision probe; smoke_api.py covers other API modes.""" from __future__ import annotations import argparse import base64 import json +import math import mimetypes -import os import sys import time from pathlib import Path -from typing import Any -from urllib import request +from smoke_api import endpoint_url, extract_text, fetch_response, resolve_key, validate_response -SCHEMA: dict[str, Any] = { - "type": "object", - "additionalProperties": False, - "properties": { - "ok": {"type": "boolean"}, - "label": {"type": "string", "maxLength": 64}, - "score": {"type": "number", "minimum": 0, "maximum": 1}, - }, +SCHEMA = { + "type": "object", "additionalProperties": False, + "properties": {"ok": {"type": "boolean"}, "label": {"type": "string"}, "score": {"type": "number"}}, "required": ["ok", "label", "score"], } def data_url(path: Path) -> str: - mime = mimetypes.guess_type(path.name)[0] or "image/jpeg" + mime = mimetypes.guess_type(path.name)[0] + if mime not in {"image/png", "image/jpeg", "image/webp", "image/gif"}: + raise ValueError("image must be PNG, JPEG, WebP or GIF") return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}" -def endpoint_url(base_url: str, endpoint: str) -> str: - stripped = base_url.rstrip("/") - if stripped.endswith("/v1"): - return f"{stripped}/{endpoint}" - return f"{stripped}/v1/{endpoint}" +def build_payload(args) -> dict: + prompt = 'Return one object with ok=true, label="smoke", and score=1. No markdown.' + if args.endpoint == "responses": + content = [{"type": "input_text", "text": prompt}] + content.extend({"type": "input_image", "image_url": data_url(Path(p)), "detail": args.image_detail} for p in args.image) + body = {"model": args.model, "input": [{"role": "user", "content": content}], + "text": {"format": {"type": "json_schema", "name": "gateway_smoke", "strict": True, "schema": SCHEMA}}, + "max_output_tokens": args.max_output_tokens, "store": False} + if args.reasoning_effort not in {"omit", "auto", "default"}: + body["reasoning"] = {"effort": args.reasoning_effort} + else: + content = [{"type": "text", "text": prompt}] + content.extend({"type": "image_url", "image_url": {"url": data_url(Path(p)), "detail": args.image_detail}} for p in args.image) + body = {"model": args.model, "messages": [{"role": "user", "content": content}], + "response_format": {"type": "json_schema", "json_schema": {"name": "gateway_smoke", "strict": True, "schema": SCHEMA}}, + "max_completion_tokens": args.max_output_tokens} + if args.reasoning_effort not in {"omit", "auto", "default"}: + body["reasoning_effort"] = args.reasoning_effort + return body -def responses_payload(args: argparse.Namespace) -> dict[str, Any]: - content: list[dict[str, Any]] = [ - { - "type": "input_text", - "text": ( - "只输出一个 JSON object,不要 markdown。" - "判断输入是否可用于结构化输出 smoke test。" - ), - } - ] - for image in args.image: - content.append( - { - "type": "input_image", - "image_url": data_url(Path(image)), - "detail": args.image_detail, - } - ) - payload: dict[str, Any] = { - "model": args.model, - "input": [{"role": "user", "content": content}], - "text": { - "format": { - "type": "json_schema", - "name": "eapil_llmapi_structured_smoke", - "strict": True, - "schema": SCHEMA, - } - }, - "truncation": "disabled", - "max_output_tokens": args.max_output_tokens, - } - if args.reasoning_effort not in {"auto", "default", "omit"}: - payload["reasoning"] = {"effort": args.reasoning_effort} - return payload +def validate_smoke_object(value) -> None: + if not isinstance(value, dict) or set(value) != {"ok", "label", "score"}: + raise ValueError("structured result has wrong keys") + if type(value["ok"]) is not bool or not isinstance(value["label"], str): + raise ValueError("structured result has wrong field types") + score = value["score"] + if isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score): + raise ValueError("structured score must be finite") + if value != {"ok": True, "label": "smoke", "score": 1}: + raise ValueError("structured result did not match the synthetic task") -def chat_payload(args: argparse.Namespace) -> dict[str, Any]: - content: list[dict[str, Any]] = [ - { - "type": "text", - "text": ( - "只输出一个 JSON object,不要 markdown。" - "判断输入是否可用于结构化输出 smoke test。" - ), - } - ] - for image in args.image: - content.append( - { - "type": "image_url", - "image_url": {"url": data_url(Path(image)), "detail": args.image_detail}, - } - ) - payload: dict[str, Any] = { - "model": args.model, - "messages": [{"role": "user", "content": content}], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "eapil_llmapi_structured_smoke", - "strict": True, - "schema": SCHEMA, - }, - }, - "temperature": 0, - "max_tokens": args.max_output_tokens, - } - if args.reasoning_effort not in {"auto", "default", "omit"}: - payload["reasoning_effort"] = args.reasoning_effort - return payload - - -def extract_responses_text(response: dict[str, Any]) -> str: - if isinstance(response.get("output_text"), str): - return str(response["output_text"]) - texts: list[str] = [] - for item in response.get("output") or []: - if not isinstance(item, dict): - continue - for content in item.get("content") or []: - if isinstance(content, dict) and isinstance(content.get("text"), str): - texts.append(str(content["text"])) - return "\n".join(texts) if texts else json.dumps(response, ensure_ascii=False) - - -def extract_chat_text(response: dict[str, Any]) -> str: - choices = response.get("choices") - if isinstance(choices, list) and choices: - message = choices[0].get("message") if isinstance(choices[0], dict) else None - content = message.get("content") if isinstance(message, dict) else None - if isinstance(content, str): - return content - return json.dumps(response, ensure_ascii=False) - - -def post_json(url: str, payload: dict[str, Any], api_key: str, timeout_s: float) -> dict[str, Any]: - body = json.dumps(payload).encode("utf-8") - req = request.Request( - url, - data=body, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - with request.urlopen(req, timeout=timeout_s) as response: # noqa: S310 - return json.loads(response.read().decode("utf-8")) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base-url", default="https://api-n-cd.playones.com/v1") - parser.add_argument("--model", default="gpt-5.5") + parser.add_argument("--model", required=True) parser.add_argument("--endpoint", choices=["responses", "chat"], default="responses") - parser.add_argument("--reasoning-effort", default="none") - parser.add_argument("--max-output-tokens", type=int, default=300) + parser.add_argument("--reasoning-effort", default="omit") + parser.add_argument("--max-output-tokens", type=int, default=256) parser.add_argument("--image", action="append", default=[]) parser.add_argument("--image-detail", default="low") - parser.add_argument("--api-key-env", default="OPENAI_API_KEY") - parser.add_argument("--timeout-s", type=float, default=240.0) + parser.add_argument("--api-key-env") + parser.add_argument("--timeout-s", type=float, default=60) parser.add_argument("--dry-run", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - payload = responses_payload(args) if args.endpoint == "responses" else chat_payload(args) - path = "responses" if args.endpoint == "responses" else "chat/completions" - url = endpoint_url(args.base_url, path) - if args.dry_run: - print(json.dumps({"url": url, "payload": payload}, ensure_ascii=False, indent=2)) - return 0 - api_key = os.getenv(args.api_key_env) - if not api_key: - print(f"Missing API key env: {args.api_key_env}", file=sys.stderr) - return 2 - started = time.perf_counter() - response = post_json(url, payload, api_key, args.timeout_s) - elapsed = time.perf_counter() - started - raw_text = ( - extract_responses_text(response) - if args.endpoint == "responses" - else extract_chat_text(response) - ) + args = parser.parse_args(argv) + key = "" try: - parsed = json.loads(raw_text) - parse_ok = isinstance(parsed, dict) - except json.JSONDecodeError: - parse_ok = False - output_types = [ - item.get("type") - for item in response.get("output", []) - if isinstance(item, dict) - ] - print( - json.dumps( - { - "endpoint": args.endpoint, - "elapsed_s": round(elapsed, 3), - "usage": response.get("usage"), - "raw_text_chars": len(raw_text), - "raw_text_preview": raw_text[:240], - "parse_ok": parse_ok, - "has_encrypted_reasoning": "encrypted_content" in json.dumps( - response, - ensure_ascii=False, - ), - "output_item_types": output_types, - }, - ensure_ascii=False, - indent=2, - ) - ) - return 0 if parse_ok else 1 + if args.max_output_tokens <= 0 or not math.isfinite(args.timeout_s) or args.timeout_s <= 0: + raise ValueError("token limit and timeout must be positive") + body = build_payload(args) + url = endpoint_url(args.base_url, args.endpoint) + if args.dry_run: + print(json.dumps({"url": url, "payload": body}, ensure_ascii=False, indent=2)) + return 0 + key = resolve_key(args.api_key_env) + started = time.monotonic() + response = fetch_response(url, body, key, args.timeout_s, args.endpoint) + summary = validate_response(args.endpoint, response, body) + validate_smoke_object(json.loads(extract_text(args.endpoint, response))) + summary.update(ok=True, schema_checked=True, endpoint=args.endpoint, + model=args.model, elapsed_s=round(time.monotonic()-started, 3)) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + except Exception as exc: + # Deliberately avoid raw provider bodies, prompts, images and credentials. + message = str(exc).replace(key, "[redacted]") if key else str(exc) + report = {"ok": False, "error_type": type(exc).__name__} + if hasattr(exc, "code"): + report["http_status"] = exc.code + else: + report["message"] = message[:240] + print(json.dumps(report, ensure_ascii=False), file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py new file mode 100644 index 00000000..52bd41ae --- /dev/null +++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import smoke_api as api +import smoke_structured_output as structured + + +def stream(*events): + lines = [] + for event in events: + value = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False) + lines.append(("data: " + value + "\r\n\r\n").encode("utf-8")) + return io.BytesIO(b"".join(lines)) + + +class APIProbeTests(unittest.TestCase): + def setUp(self): + self.network = mock.patch.object(api.request, "urlopen", side_effect=AssertionError("unexpected network")) + self.urlopen = self.network.start() + self.addCleanup(self.network.stop) + + def test_endpoint_joining_preserves_prefix_and_supports_decisions_alias(self): + for base in ("https://example.test", "https://example.test/v1", "https://example.test/v1/"): + self.assertEqual(api.endpoint_url(base, "chat"), "https://example.test/v1/chat/completions") + self.assertEqual(api.endpoint_url("https://example.test/relay/v1", "responses"), "https://example.test/relay/v1/responses") + self.assertEqual(api.endpoint_url("https://example.test/v1", "decisions", True), "https://example.test/api/alpha/decisions") + self.assertEqual(api.endpoint_url("https://example.test/api", "decisions"), "https://example.test/api/alpha/decisions") + for base in ("https://key@example.test", "https://example.test?key=secret", "file:///tmp"): + with self.subTest(base=base), self.assertRaises(ValueError): + api.endpoint_url(base, "chat") + + def test_wire_request_preserves_zero_false_unicode_and_exact_mode_shape(self): + payload = {"model": "alias", "messages": [{"role": "user", "content": '中文 "quoted" $HOME'}], "temperature": 0, "stream": False} + response = io.BytesIO(b'{"choices":[]}') + response.headers = {"Content-Type": "application/json"} + self.urlopen.side_effect = None + self.urlopen.return_value = response + api.fetch_response("https://example.test/v1/chat/completions", payload, "test-key", 12, "chat") + req = self.urlopen.call_args.args[0] + self.assertEqual(req.get_method(), "POST") + self.assertEqual(req.get_header("Authorization"), "Bearer test-key") + self.assertEqual(json.loads(req.data), payload) + self.assertEqual(self.urlopen.call_args.kwargs["timeout"], 12) + + def test_models_is_get_and_has_no_body(self): + response = io.BytesIO(b'{"data":[]}') + response.headers = {} + self.urlopen.side_effect = None + self.urlopen.return_value = response + api.fetch_response("https://example.test/v1/models", None, "test-key", 12, "models") + req = self.urlopen.call_args.args[0] + self.assertEqual(req.get_method(), "GET") + self.assertIsNone(req.data) + + def test_responses_text_does_not_fall_back_to_serializing_raw_response(self): + self.assertEqual(api.extract_text("responses", {"output": [{"type": "reasoning", "content": [{"text": "private reasoning"}]}]}), "") + self.assertEqual(api.extract_text("responses", {"output": [{"type": "message", "content": [{"type": "output_text", "text": "答案"}]}]}), "答案") + + def test_empty_chat_truncation_refusal_and_incomplete_responses_fail(self): + bad = [ + ("chat", {"choices": [{"message": {"content": ""}, "finish_reason": "stop"}]}), + ("chat", {"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]}), + ("chat", {"choices": [{"message": {"refusal": "refused"}, "finish_reason": "stop"}]}), + ("responses", {"status": "incomplete", "output": []}), + ("responses", {"status": "completed", "output": []}), + ("responses", {"status": "completed", "output": [{"type": "reasoning"}]}), + ("responses", {"status": "completed", "output": [{"type": "message", "content": []}]}), + ("responses", {"status": "completed", "output": [{"type": "web_search_call", "status": "failed"}]}), + ("responses", {"status": "completed", "output": [{"type": "message", "content": [{"type": "refusal", "refusal": "no"}]}]}), + ] + for mode, body in bad: + with self.subTest(mode=mode, body=body), self.assertRaises(ValueError): + api.validate_response(mode, body, {}) + + def test_tool_only_chat_and_responses_are_valid_without_text(self): + chat = {"choices": [{"message": {"content": None, "tool_calls": [{"function": {"name": "echo", "arguments": "{}"}}]}, "finish_reason": "tool_calls"}]} + self.assertEqual(api.validate_response("chat", chat, {})["tool_calls"], 1) + response = {"status": "completed", "output": [{"type": "function_call", "name": "echo", "arguments": "{}"}]} + self.assertEqual(api.validate_response("responses", response, {})["output_types"], ["function_call"]) + + def test_chat_sse_assembles_utf8_and_usage_and_requires_done(self): + events = [ + {"choices": [{"index": 0, "delta": {"content": "中"}, "finish_reason": None}]}, + {"choices": [{"index": 0, "delta": {"content": "文"}, "finish_reason": "stop"}]}, + {"choices": [], "usage": {"total_tokens": 5}}, + ] + result = api.read_stream(stream(*events, "[DONE]"), "chat") + self.assertEqual(api.extract_text("chat", result), "中文") + self.assertEqual(api.validate_response("chat", result, {})["usage"]["total_tokens"], 5) + with self.assertRaises(ValueError): + api.read_stream(stream(*events), "chat") + + def test_chat_tool_sse_preserves_call_identity_and_joins_arguments(self): + result = api.read_stream(stream( + {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "echo", "arguments": '{"text":'}}]}}]}, + {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": '"ping"}'}}]}, "finish_reason": "tool_calls"}]}, + "[DONE]", + ), "chat") + call = result["choices"][0]["message"]["tool_calls"][0] + self.assertEqual(call["id"], "call_1") + self.assertEqual(call["type"], "function") + self.assertEqual(call["function"]["name"], "echo") + self.assertEqual(json.loads(call["function"]["arguments"]), {"text": "ping"}) + self.assertEqual(api.validate_response("chat", result, {})["tool_calls"], 1) + + def test_responses_sse_recovers_items_when_terminal_output_is_empty(self): + item = {"type": "message", "content": [{"type": "output_text", "text": "PONG"}]} + result = api.read_stream(stream( + {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "P"}, + {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "ONG"}, + {"type": "response.output_text.done", "output_index": 0, "content_index": 0, "text": "PONG"}, + {"type": "response.output_item.done", "output_index": 0, "item": item}, + {"type": "response.completed", "response": {"status": "completed", "output": [], "usage": {"output_tokens": 6}}}, + ), "responses") + self.assertEqual(api.extract_text("responses", result), "PONG") + self.assertEqual(api.validate_response("responses", result, {})["usage"]["output_tokens"], 6) + + def test_responses_sse_deltas_without_item_snapshot_and_failure_terminals(self): + result = api.read_stream(stream( + {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "answer"}, + {"type": "response.completed", "response": {"status": "completed", "output": []}}, + ), "responses") + self.assertEqual(api.extract_text("responses", result), "answer") + for kind in ("error", "response.failed", "response.incomplete"): + with self.subTest(kind=kind), self.assertRaises(ValueError): + api.read_stream(stream({"type": kind}), "responses") + with self.assertRaises(ValueError): + api.read_stream(stream({"type": "response.output_text.delta", "delta": "partial"}), "responses") + + def test_embedding_indices_dimensions_and_finite_vectors(self): + payload = {"input": ["one", "two"]} + body = {"data": [{"index": 1, "embedding": [0.1, 0.2]}, {"index": 0, "embedding": [0.3, 0.4]}]} + self.assertEqual(api.validate_response("embedding", body, payload)["dimensions"], [2, 2]) + with self.assertRaises(ValueError): + api.validate_response("embedding", body, dict(payload, dimensions=128)) + for vector in ([], [float("nan")], [True], "base64"): + with self.subTest(vector=vector), self.assertRaises(ValueError): + api.validate_response("embedding", {"data": [{"index": 0, "embedding": vector}]}, {"input": "one"}) + with self.assertRaises(ValueError): + api.validate_response("embedding", {"data": [{"index": False, "embedding": [1]}]}, {"input": "one"}) + + def test_rerank_accepts_logits_and_maps_original_indices(self): + result = api.validate_response("rerank", {"results": [{"index": 1, "relevance_score": 1.77}, {"index": 0, "relevance_score": -11.02}]}, {"documents": ["apple", "password"]}) + self.assertEqual(result["indices"], [1, 0]) + self.assertFalse(result["documents_returned"]) + for index in (2, -1, True): + with self.subTest(index=index), self.assertRaises(ValueError): + api.validate_response("rerank", {"results": [{"index": index, "relevance_score": 1}]}, {"documents": ["one"]}) + + def test_decision_fractional_score_confidence_and_question_correspondence(self): + payload = {"questions": { + "team": {"type": "choice", "criteria": {"a": "one", "b": "two"}}, + "urgency": {"type": "score", "criteria": ["wait", "soon", "now"]}, + "correct": {"type": "noul", "criteria": "correct answer"}, + }} + answers = {"team": {"type": "choice", "choice": "a", "probabilities": {"a": .99, "b": .01}, "confidence": .94}, + "urgency": {"type": "score", "score": 1.97, "probabilities": {"0": .01, "1": .01, "2": .98}, "confidence": .87}, + "correct": {"type": "noul", "noul": .96}} + self.assertEqual(api.validate_response("decisions", {"answers": answers}, payload)["answers"], answers) + with self.assertRaises(ValueError): + api.validate_response("decisions", {"answers": {"correct": answers["correct"]}}, payload) + bad = {**answers, "correct": {"type": "noul", "noul": 2}} + with self.assertRaises(ValueError): + api.validate_response("decisions", {"answers": bad}, payload) + + def test_structured_validation_does_not_confuse_boolean_with_numeric_or_accept_extra_keys(self): + structured.validate_smoke_object({"ok": True, "label": "smoke", "score": 1}) + for value in ({"ok": 1, "label": "smoke", "score": 1}, + {"ok": True, "label": "smoke", "score": True}, + {"ok": True, "label": "smoke", "score": 1, "extra": 0}): + with self.subTest(value=value), self.assertRaises(ValueError): + structured.validate_smoke_object(value) + + def test_dry_run_needs_no_key_makes_no_network_call_and_reads_bom(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "payload.json" + path.write_text(json.dumps({"model": "alias", "input": "中文"}), encoding="utf-8-sig") + output = io.StringIO() + with contextlib.redirect_stdout(output), mock.patch.dict(api.os.environ, {}, clear=True): + self.assertEqual(api.main(["--mode", "responses", "--payload-file", str(path), "--dry-run"]), 0) + self.assertEqual(json.loads(output.getvalue())["payload"]["input"], "中文") + self.urlopen.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json index 5ec5cd7a..a8b92dd0 100644 --- a/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "eapil-voice", - "version": "0.1.0", - "description": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", + "version": "1.0.0", + "description": "易瞳音频服务调用指南:语音设计、朗读、克隆、ASR、MossFormer2/RE-USE 降噪、Stable Audio 音乐与音效、Yue2 歌曲及批量导出。包含请求校验、错误处理和实测记录。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-voice", + "repository": "https://git.playones.com/chenmingxuan/ManySkills", "license": "Proprietary", "keywords": [ "eapil", @@ -16,9 +16,9 @@ ], "skills": "./skills/", "interface": { - "displayName": "易瞳语音转录|设计|克隆|降噪", - "shortDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", - "longDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。", + "displayName": "易瞳音频:语音 / 转录 / 降噪 / 音乐 / 音效", + "shortDescription": "语音、ASR、降噪、音乐、音效与歌曲的调用及验证指南。", + "longDescription": "按需选择 Qwen/VoxCPM2 语音、转录、两类降噪、Stable Audio 和 Yue2 接口。配套单请求客户端与顺序批处理,校验 WAV/JSON 响应、使用准确参考转写,并明确超时不可盲目重发及部署限制。保留 eapil-voice 插件 ID。", "developerName": "EAPIL", "category": "Creativity", "capabilities": [ @@ -26,7 +26,7 @@ "Write" ], "defaultPrompt": [ - "使用 易瞳语音转录|设计|克隆|降噪 帮我完成这个任务。" + "使用 易瞳音频 帮我选择接口并完成音频任务。" ], "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", diff --git a/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..9378c334 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "eapil-voice", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "adapter": "codex-plugin", + "sourcePath": "plugins/eapil-voice", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md index 816b0049..8310731e 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md @@ -1,120 +1,47 @@ --- name: eapil-voice -description: Guide agents to call the hosted Eapil-Voice/Qwen3 Voice Design FastAPI service by domain for audio generation workflows. Use when a task asks to synthesize speech, design a voice from an instruction, clone a voice from reference audio, use VoxCPM2 or Qwen providers, enhance or denoise audio, transcribe audio through this service, batch-ASR/clone alert sounds, export generated speech as WAV/MP3, check model health, or produce curl/Python calls for the Eapil-Voice API. +description: Build, test or use EAPIL audio API calls for speech design and cloning, transcription, denoising, music, sound effects and lyric-conditioned songs; includes sequential batch speech export. --- -# Eapil-Voice +# EAPIL audio service -## Purpose +Use the hosted service at `https://ades.playones.com`, or the deployment chosen by the user. Keep the historical skill/plugin ID for compatibility; this guide covers the whole audio service. -Use this skill to call the hosted Eapil-Voice API by domain. Prefer the existing API surface over inventing client contracts. +| Need | Route | Read when relevant | +|---|---|---| +| Describe a voice and speak text | `/v1/voice/design` | [Speech](references/speech.md) | +| Ordinary Qwen text reading | `/v1/voice/synthesize` | [Speech](references/speech.md) | +| Clone from reference speech | `/v1/voice/clone` | [Speech](references/speech.md) | +| Transcribe speech | `/v1/asr/transcribe` | [ASR and enhancement](references/recognition-enhancement.md) | +| MossFormer2 enhancement / alias | `/v1/audio/enhance`, `/v1/audio/denoise` | [ASR and enhancement](references/recognition-enhancement.md) | +| RE-USE denoising | `/v1/audio/reuse` | [ASR and enhancement](references/recognition-enhancement.md) | +| Instrumental music or sound effects | `/v1/audio/music`, `/v1/audio/sfx` | [Stable Audio](references/music.md) | +| Songs from lyrics or ABC arrangement | `/v1/audio/music/yue2` | [Yue2](references/yue2.md) | +| Directory-scale ASR/clone/export | Sequential calls | [Batch audio](references/batch-audio.md) | -Default base URL is `https://ades.playones.com`. Override it only when the user gives another domain or `QWEN3_VOICE_API_BASE_URL`. +Read [transport and errors](references/api.md) for client integration or troubleshooting. Consult `/openapi.json` and `/models` when deployment facts matter; a registered route or healthy service does not prove a model can infer. Dated evidence is in [verification](references/verification.md). Read only the mode needed. -Use `OPENAI_API_KEY` as the API key environment variable. If it is not set and the user did not provide a key directly, tell the user to get their key from `https://kinfo.playones.com`. Direct key use is allowed with `--token` or an `Authorization: Bearer ` header. +## Call the actual contract -## Call Workflow +Use `AUDIO_API_BASE` and `AUDIO_API_TOKEN`; old `QWEN3_VOICE_API_BASE_URL` and `OPENAI_API_KEY` remain fallback aliases. Explicit flags take precedence. An available legacy key is not proof of authorization on this deployment. Inference requires Bearer authentication; health/models are public. Obtain missing credentials from the provider without putting them in chat or tracked files. -1. Resolve the base URL first. Use `https://ades.playones.com` unless the user gives another domain. -2. Check `GET /health` before expensive generation. -3. Add `Authorization: Bearer ` from an explicit user-provided key or `OPENAI_API_KEY`. -4. Choose the endpoint by desired output: - - Voice from style instruction: `POST /v1/voice/design`. - - Base TTS by speaker name: `POST /v1/voice/synthesize`. - - Voice clone from reference audio: `POST /v1/voice/clone`. - - Speech enhancement or denoise: `POST /v1/audio/enhance` or `POST /v1/audio/denoise`. - - Transcription: `POST /v1/asr/transcribe` returns JSON, not audio. -5. Save audio-output responses as `.wav`; these endpoints return `audio/wav`. -6. Read `/openapi.json` from the active base URL when exact current form fields matter. -7. Read `references/api.md` before writing detailed client code, exposing all services, troubleshooting errors, or selecting Qwen vs VoxCPM2 parameters. -8. Read `references/batch-audio.md` before batch ASR/clone/export work, especially game filter alert sounds. +Speech design/synthesis, music/SFX and Yue2 take JSON. Clone, ASR and enhancement use multipart with file field `file`; let the client create the boundary. Audio success is complete WAV bytes, not JSON/Base64/a URL or inference streaming. ASR returns JSON. Preserve explicit false/0; keep provider and endpoint controls separate. -## Provider Rules +[call_voice_api.py](scripts/call_voice_api.py) makes one validated request. Existing command names remain; new commands are reuse, music, sfx, yue2. Dry run prints the payload without a key/network call, so share only synthetic inputs. Resolve the actual skill directory: -Use `provider=qwen` by default. Only `/v1/voice/design` and `/v1/voice/clone` accept `provider=voxcpm2`. +~~~powershell +python '/scripts/call_voice_api.py' design --text '你好。' --instruct '温暖自然、清晰的女声。' --language Chinese --max-tokens 512 --out voice.wav +python '/scripts/call_voice_api.py' asr --file speech.wav --language zh +~~~ -For VoxCPM2: -- Pass `provider=voxcpm2`. -- Use `cfg_value`, `inference_timesteps`, `normalize`, `denoise`, and `retry_badcase` for generation controls. -- Set `denoise=true` only when the server is configured with `VOXCPM2_LOAD_DENOISER=true`. -- For clone with `ref_text`, expect Hi-Fi transcript-guided cloning. -- For clone without `ref_text`, use `instruct` for controllable cloning. +Prefix external commands with `rtk proxy` in this repository. -For Qwen clone, always provide both `text` and `ref_text`. +## Verify and deliver -## Batch Audio Rules +Check status/media type and complete WAV structure, then verify what matters: spoken content, accurate reference transcript, intelligibility/noise or musical content/duration. A valid WAV alone does not prove voice similarity or listening quality. Read actual rate/channels from the header; MP3 requires local transcoding. -- For many short alert sounds, ask only if the output mode is genuinely ambiguous: generated-only files, full replacement files, or overlay/mix. -- Do not assume ASR segment timestamps are source-separation masks. ASR can locate/transcribe speech; it cannot remove an old voice from mixed music/SFX by itself. -- If the user wants clean replacement assets for another tool, export generated speech only, named after the source files. Use `scripts/batch_clone_generated_audio.py`. -- If the user wants to preserve music/SFX while replacing embedded speech, state that source separation or stems are required for clean removal. Do not mix the original voice under the generated voice unless the user explicitly asks for overlay. -- Normalize generated text before clone: add expressive punctuation when useful; if the final character is not `。!?.!?~~`, append `。` for Chinese alert text. -- For final MP3 delivery, convert the generated WAVs with `ffmpeg` and explicit gain such as `volume=4dB` only when requested. +Do not invent transcripts from filenames, manufacture speech after empty ASR, silently rewrite requested text or treat ASR timestamps as source separation. Punctuation normalization, gain and overlays apply only when requested. -## Quick Examples +Requests wait for the final result. There is no job ID, polling/cancel endpoint or idempotency key. Timeout/504 may leave inference running: do not automatically resubmit. Keep calls sequential initially; only an explicit busy rejection is eligible for bounded backoff. -Use JSON for voice design and synthesis: - -```bash -BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}" - -curl -X POST "$BASE_URL/v1/voice/design" \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -H 'Content-Type: application/json' \ - -d '{"provider":"qwen","text":"Hello world","instruct":"Warm, calm, natural narration","language":"auto","temperature":0.9,"max_tokens":4096,"top_k":50,"top_p":1.0,"repetition_penalty":1.05}' \ - --output voice_design.wav -``` - -Use multipart form data for clone, enhancement, denoise, and ASR: - -```bash -BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}" - -curl -X POST "$BASE_URL/v1/voice/clone" \ - -H "Authorization: Bearer $OPENAI_API_KEY" \ - -F 'file=@reference.wav' \ - -F 'provider=qwen' \ - -F 'text=This is the cloned sentence.' \ - -F 'ref_text=Transcript of the reference audio.' \ - -F 'language=auto' \ - -F 'temperature=0.9' \ - -F 'max_tokens=4096' \ - -F 'top_k=50' \ - -F 'top_p=1.0' \ - -F 'repetition_penalty=1.05' \ - --output voice_clone.wav -``` - -Use the bundled dependency-free client when convenient: - -```bash -python ~/.codex/skills/eapil-voice/scripts/call_voice_api.py design \ - --text "Hello world" \ - --instruct "Warm, calm, natural narration" \ - --out voice_design.wav -``` - -Batch clone generated speech only, preserving source names: - -```bash -python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \ - --base-url https://ades.playones.com \ - --token "$OPENAI_API_KEY" \ - --input-dir "/path/to/filter-sounds" \ - --reference-audio reference.wav \ - --output-dir "/path/to/generated-mp3" \ - --format mp3 \ - --gain-db 4 -``` - -On Windows, prefer direct command execution or a single quoted `Start-Process -ArgumentList` string for paths containing spaces or CJK characters. - -## Source Anchors - -When a local source checkout is available and the task depends on implementation details, use it only as source reference. Do not prefer local service startup over the hosted domain: - -- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/voice.py` -- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/audio.py` -- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/asr.py` -- `/Users/eapil/git/QWEN3-Voice-Design/app/schemas/voice.py` -- `/Users/eapil/git/QWEN3-Voice-Design/README.md` +Return usable local audio, endpoint/provider and limitations. In Codex, preview delivered audio with an absolute path. Record actual coverage; keep credentials, probe outputs and private transcripts outside the repository. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml index 73e1eafa..35188b44 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Eapil-Voice" - short_description: "Call hosted Eapil voice APIs" - default_prompt: "Use $eapil-voice to transcribe, clone, or batch-export speech through the hosted Eapil-Voice API." + display_name: "EAPIL Audio" + short_description: "EAPIL speech, ASR, denoising, music and song APIs" + default_prompt: "Use $eapil-voice to choose and call the EAPIL audio endpoint for speech, transcription, denoising, music or songs." diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md index 404b0220..2fddb284 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md @@ -1,359 +1,37 @@ -# Eapil-Voice API Reference +# Transport and deployed contract -## Defaults +Source: maintainer's API usage guide dated 2026-10-06 and live [OpenAPI](https://ades.playones.com/openapi.json). Schema limits, deployment caps and model recommendations differ; deployment settings can change. -- Base URL: `https://ades.playones.com` -- Override env: `QWEN3_VOICE_API_BASE_URL` -- API key env: `OPENAI_API_KEY` -- Auth header: `Authorization: Bearer ` -- If no key is available, get one from `https://kinfo.playones.com`. -- Audio-output media type: `audio/wav` -- Local source checkouts are source references only unless the user explicitly asks to run a local service. +## Requests and credentials -## Endpoint Summary +Base URL is the service root without /v1. Client precedence: explicit flags; AUDIO_API_BASE/AUDIO_API_TOKEN; legacy QWEN3_VOICE_API_BASE_URL/OPENAI_API_KEY; default hosted URL. --api-key-env NAME selects exactly that variable. --token remains for old scripts; environment credentials avoid process-argument exposure. GET health/models require no token. -| Task | Method | Path | Input | Output | -| --- | --- | --- | --- | --- | -| Voice design TTS | `POST` | `/v1/voice/design` | JSON | WAV | -| Base TTS synthesis | `POST` | `/v1/voice/synthesize` | JSON | WAV | -| Voice clone | `POST` | `/v1/voice/clone` | multipart form | WAV | -| Speech enhancement | `POST` | `/v1/audio/enhance` | multipart form | WAV | -| Denoise alias | `POST` | `/v1/audio/denoise` | multipart form | WAV | -| ASR transcription | `POST` | `/v1/asr/transcribe` | multipart form | JSON | -| Health | `GET` | `/health` | none | JSON | -| Models | `GET` | `/models` | none | JSON | +Inference uses Bearer auth. Serialize UTF-8 JSON, including multiline lyrics. File workflows prefer multipart with field file, automatically generated boundary and form-valued numbers/booleans. Raw binary with query fields is also supported by the API; the bundled client uses multipart to keep long text out of URLs. -## Parameter Limits +Size limit: 26,214,400 bytes (25 MiB). Qwen clone, ASR, MossFormer2 and RE-USE enforce <=60 decoded seconds. VoxCPM2 shares the size limit but not that decoded-duration check; a clear 5–15 second reference is usually sufficient. A .wav extension is not conversion. WAV inputs can be preflight checked; other decoders depend on the server. -- `provider`: `qwen` or `voxcpm2` -- `text`: 1 to 4096 characters -- `instruct`: 1 to 1024 characters; optional for clone -- `temperature`: 0 to 2 -- `max_tokens`: 1 to 8192 -- `top_k`: 0 to 1000 -- `top_p`: 0 to 1 -- `repetition_penalty`: 0.1 to 2 -- `cfg_value`: 0 to 10 -- `inference_timesteps`: 1 to 200 -- Uploaded audio defaults to a 25 MB and 60 second server-side limit, depending on deployment environment variables. +## Results -## Voice Design +Audio success is complete binary audio/wav. The helper checks media type and complete RIFF format/data chunks before saving. It handles PCM/IEEE float WAV; RF64/compressed WAV needs another validated decoder. ASR must have string text; an empty transcription can represent silence, but cannot drive a batch clone. -`POST /v1/voice/design` +Read actual rate/channels. Music/SFX currently use 44.1 kHz stereo PCM16; Yue2 48 kHz stereo PCM16. Provider speech rates vary. RE-USE encodes the input rate; enhancement/RE-USE mix channels to mono and are not stereo mastering tools. -Use this for text-to-speech driven by a style instruction. Body is JSON. +Server-side JavaScript reads response.arrayBuffer() for audio and response.json() for ASR, once per response body. Current deployment lacks general browser CORS; use a backend proxy or configured CORS. Keep service tokens on that backend. -Required: -- `text`: text to speak -- `instruct`: style, emotion, voice, or delivery instruction +## Timeouts and concurrency -Common optional fields: -- `provider`: `qwen` default, or `voxcpm2` -- `language`: `auto` default, used by Qwen -- `temperature`: `0.9` default, used by Qwen -- `max_tokens`: `4096` default, used by Qwen -- `top_k`: `50` default, used by Qwen -- `top_p`: `1.0` default, used by Qwen -- `repetition_penalty`: `1.05` default, used by Qwen -- `cfg_value`: `2.0` default, used by VoxCPM2 -- `inference_timesteps`: `10` default, used by VoxCPM2 -- `normalize`: `false` default, used by VoxCPM2 -- `denoise`: `false` default, used by VoxCPM2 -- `retry_badcase`: `true` default, used by VoxCPM2 +Speech/ASR/enhancement timeout starts at 600 seconds; music/SFX/Yue2 at 1800. Client timeouts cannot extend a gateway timeout. HTTP chunked transport is not progressive generation. -Qwen example: +There is no async job, task query, cancellation endpoint or idempotency key. Timeout/504 does not establish that inference stopped; investigate before repeating. Do not invent a status endpoint. -```bash -curl -X POST https://ades.playones.com/v1/voice/design \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -H 'Content-Type: application/json' \ - -d '{ - "provider": "qwen", - "text": "Hello world", - "instruct": "Warm, calm, natural narration", - "language": "auto", - "temperature": 0.9, - "max_tokens": 4096, - "top_k": 50, - "top_p": 1.0, - "repetition_penalty": 1.05 - }' \ - --output voice_design.wav -``` +Qwen design can reject busy with 503 and Retry-After. Stable Audio music/SFX share a queue. Qwen synthesis/clone share serialized execution; ASR, VoxCPM2 and Yue2 are separately serialized; MossFormer2/RE-USE share execution. Loading/connection failures can also be 503: status alone is insufficient. -VoxCPM2 example: +The single-call helper never retries. Batch --retries defaults to 1 total attempt, capped at 5. Additional attempts only follow explicit busy/running 503 detail without load/error/timeout indicators. It honors numeric Retry-After up to 60 seconds; longer backoff is left to the application, and fallback delays are bounded. Upload bodies are rebuilt in full. Ambiguous errors are failures, not retry signals. -```bash -curl -X POST https://ades.playones.com/v1/voice/design \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -H 'Content-Type: application/json' \ - -d '{ - "text": "Hello world", - "instruct": "Warm, calm, natural narration", - "provider": "voxcpm2", - "cfg_value": 2.0, - "inference_timesteps": 10, - "normalize": false, - "denoise": false, - "retry_badcase": true - }' \ - --output voxcpm2_voice_design.wav -``` +## Errors and state -## Base TTS Synthesis +Failure detail can be a string/array or gateway HTML. Check status/media before parsing. 400/422 usually require request correction; 401 credential correction; 413 smaller/shorter audio. 404 includes removed MiniMax. 500/501/502/503 need detail to distinguish input errors, unsupported backend, loading and busy. Yue2 upstream 4xx may appear as 502. -`POST /v1/voice/synthesize` +Library callers can inspect APIError.status/detail/busy. CLI logs omit raw provider detail because it can echo private inputs; inspect locally and redact before sharing. -Use this for base Qwen TTS with an optional named speaker. Body is JSON. This endpoint does not accept `provider`. - -Required: -- `text` - -Optional: -- `speaker`: e.g. `Chelsie`; omit for model default -- `language`: `auto` default -- `temperature`: `0.9` default -- `max_tokens`: `4096` default -- `top_k`: `50` default -- `top_p`: `1.0` default -- `repetition_penalty`: `1.05` default - -Example: - -```bash -curl -X POST https://ades.playones.com/v1/voice/synthesize \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -H 'Content-Type: application/json' \ - -d '{ - "text": "Hello world", - "speaker": "Chelsie", - "language": "auto", - "temperature": 0.9, - "max_tokens": 4096, - "top_k": 50, - "top_p": 1.0, - "repetition_penalty": 1.05 - }' \ - --output synthesize.wav -``` - -## Voice Clone - -`POST /v1/voice/clone` - -Use this for cloning from reference audio. Send multipart form upload with `file=@reference.wav`. - -Audio input: -- `file`: reference audio upload - -Shared fields: -- `text`: required output text -- `provider`: `qwen` default, or `voxcpm2` -- `language`: `auto` default, used by Qwen - -Qwen required fields: -- `text` -- `ref_text`: transcript of the reference audio -- `file` - -Qwen optional fields: -- `temperature`: default `0.9` -- `max_tokens`: default `4096` -- `top_k`: default `50` -- `top_p`: default `1.0` -- `repetition_penalty`: default `1.05` - -VoxCPM2 required fields: -- `text` -- `file` -- `provider=voxcpm2` - -VoxCPM2 optional fields: -- `ref_text`: when present, enables transcript-guided Hi-Fi clone -- `instruct`: used only when `ref_text` is absent -- `cfg_value`: default `2.0` -- `inference_timesteps`: default `10` -- `normalize`: default `false` -- `denoise`: default `false` -- `retry_badcase`: default `true` - -Qwen clone example: - -```bash -curl -X POST https://ades.playones.com/v1/voice/clone \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@reference.wav' \ - -F 'provider=qwen' \ - -F 'text=This is the cloned sentence.' \ - -F 'ref_text=This is the transcript of the reference audio.' \ - -F 'language=auto' \ - -F 'temperature=0.9' \ - -F 'max_tokens=4096' \ - -F 'top_k=50' \ - -F 'top_p=1.0' \ - -F 'repetition_penalty=1.05' \ - --output voice_clone.wav -``` - -VoxCPM2 controllable clone without transcript: - -```bash -curl -X POST https://ades.playones.com/v1/voice/clone \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@reference.wav' \ - -F 'text=This is the cloned sentence.' \ - -F 'instruct=Keep the same speaker identity with a calm delivery.' \ - -F 'provider=voxcpm2' \ - -F 'cfg_value=2.0' \ - -F 'inference_timesteps=10' \ - -F 'normalize=false' \ - -F 'denoise=false' \ - -F 'retry_badcase=true' \ - --output voxcpm2_controllable_clone.wav -``` - -VoxCPM2 Hi-Fi clone with transcript: - -```bash -curl -X POST https://ades.playones.com/v1/voice/clone \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@reference.wav' \ - -F 'text=This is the cloned sentence.' \ - -F 'ref_text=This is the transcript of the reference audio.' \ - -F 'provider=voxcpm2' \ - -F 'cfg_value=2.0' \ - -F 'inference_timesteps=10' \ - -F 'normalize=false' \ - -F 'denoise=false' \ - -F 'retry_badcase=true' \ - --output voxcpm2_hifi_clone.wav -``` - -## Speech Enhancement And Denoise - -`POST /v1/audio/enhance` - -`POST /v1/audio/denoise` - -These two paths call the same service and return `enhanced.wav`. Send multipart form upload with `file=@noisy.wav`. - -Optional: -- `chunked`: form or query bool. Accepted truthy values include `1`, `true`, `yes`, `on`; falsy values include `0`, `false`, `no`, `off`. - -Example: - -```bash -curl -X POST https://ades.playones.com/v1/audio/enhance \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@noisy.wav' \ - -F 'chunked=false' \ - --output enhanced.wav -``` - -Denoise alias: - -```bash -curl -X POST https://ades.playones.com/v1/audio/denoise \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@noisy.wav' \ - -F 'chunked=false' \ - --output denoise.wav -``` - -## ASR Transcription - -`POST /v1/asr/transcribe` - -This is not audio generation. It returns JSON: - -```json -{ - "text": "...", - "segments": [], - "language": "English", - "prompt_tokens": 0, - "generation_tokens": 0, - "total_tokens": 0, - "prompt_tps": 0, - "generation_tps": 0, - "total_time": 0 -} -``` - -Input is multipart form upload with `file=@speech.wav`. - -Optional fields can be form or query params: -- `language`: normalized aliases include `zh`, `zh-cn`, `en`, `ja`, `ko`; default is `English` -- `temperature`: default `0.0` -- `max_tokens`: default `8192` -- `top_k`: default `0` -- `top_p`: default `1.0` -- `repetition_penalty`: default omitted - -Example: - -```bash -curl -X POST https://ades.playones.com/v1/asr/transcribe \ - -H 'Authorization: Bearer $OPENAI_API_KEY' \ - -F 'file=@speech.wav' \ - -F 'language=auto' \ - -F 'temperature=0.0' \ - -F 'max_tokens=8192' \ - -F 'top_k=0' \ - -F 'top_p=1.0' -``` - -## Health And Models - -```bash -curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/health -curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/models -``` - -Model status keys: -- `voice_design_tts` -- `base_tts` -- `asr` -- `speech_enhancement` -- `voxcpm2` - -Models are lazy-loaded on first request, so `loaded: false` can be normal before use. - -## Errors And Troubleshooting - -- `401 unauthorized`: the request lacks a valid `Authorization: Bearer `. Use `OPENAI_API_KEY`, pass `--token`, or get a key from `https://kinfo.playones.com`. -- `400 provider must be one of: qwen, voxcpm2`: invalid or empty provider. -- `400 audio file is required`: missing audio input for enhancement or ASR. -- `400 reference audio file is required`: missing clone reference audio. -- `400 text and ref_text are required`: Qwen clone requires both. -- `400 text is required`: VoxCPM2 clone requires `text`. -- `400 denoise=true requires VOXCPM2_LOAD_DENOISER=true`: retry with `denoise=false` or reconfigure server. -- `501 VOXCPM2_BACKEND=vllm_omni is not implemented in this build`: local ARM Mac path supports `official`. -- `503 failed to load ... model`: model package, model id, local weights, or optional `voxcpm` dependency is unavailable. -- `504 Gateway Time-out`: the request passed HTTP validation but generation exceeded the gateway timeout. Retry after model warmup, reduce `max_tokens` for short test requests, or inspect server logs. -- `500 Internal Server Error`: the request reached the application but failed internally. Re-run with `curl -i` and no `--output` to capture the response body; if the body is only `Internal Server Error`, inspect server logs. - -## Environment Variables - -Server-side: - -```env -OPENAI_API_KEY=your_key_here -TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-bf16 -BASE_TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-0.6B-Base-8bit -ASR_MODEL_ID=mlx-community/Qwen3-ASR-1.7B-8bit -SE_MODEL_ID=starkdmi/MossFormer2_SE_48K_MLX -SE_MODEL_PRECISION=int8 -VOXCPM2_MODEL_ID=openbmb/VoxCPM2 -VOXCPM2_BACKEND=official -VOXCPM2_LOAD_DENOISER=false -VOXCPM2_DEVICE=auto -VOXCPM2_OPTIMIZE=true -``` - -Client-side convenience: - -```env -QWEN3_VOICE_API_BASE_URL=https://ades.playones.com -OPENAI_API_KEY=your_key_here -``` +Health ok means API responsiveness. Models keys include voice_design_tts, base_tts, asr, speech_enhancement, reuse, stable_audio_3, yue2, voxcpm2. Loaded=false or Yue2 stopped can be normal lazy loading. Hosted API use does not require local model startup. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md index 55dce960..ff62658b 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md @@ -1,60 +1,25 @@ -# Batch Audio Workflows +# Sequential batch speech export -Use this reference for directory-scale ASR, clone, and export jobs such as game filter alert sounds. +ASR each source, then clone speech under the source stem. Output is generated speech only, without overlay or embedded music/SFX preservation. Clean voice replacement inside mixed audio requires stems/source separation; ASR timestamps are not removal masks. -## Pick The Output Mode First +~~~powershell +python '/scripts/batch_clone_generated_audio.py' --input-dir source-audio --reference-audio reference.wav --reference-text-file reference.txt --output-dir generated --provider qwen --asr-language zh --clone-language Chinese --format wav +~~~ -Clarify or infer the output mode before generating many files: +## Transcripts and content -- **Generated-only assets**: create cloned speech files named after the source audio. This is the safest default when the user wants files they can manually replace elsewhere. -- **Overlay/mix**: mix generated speech with the source audio only when the user explicitly asks for overlay. -- **Speech replacement inside mixed audio**: do not promise clean removal from mixed music/SFX using ASR timestamps alone. Clean replacement requires source separation, stems, or a dedicated vocal-removal model. +Supply reference-text/reference-text-file containing the recording's actual words. Otherwise transcribe the reference once with reference-language (default zh). Review ASR for important work; automatic recognition does not establish accuracy. -If the user says the old and new voices are both audible, stop overlay work and export generated-only assets instead unless they ask for source separation. +Each source uses asr-language (default zh). Empty/invalid ASR fails that file, never substitutes its name. Recognized text is preserved apart from surrounding whitespace. Optional normalize-text collapses whitespace/adds terminal punctuation without invented interjections or rewriting semantics. -## Generated-Only Batch Export +Both batch providers use transcript-guided clone. Use the single-call client for VoxCPM2 instruct-only clone; Qwen clone-language is not sent to VoxCPM2. -Prefer the bundled script: +## Files and retries -```bash -python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \ - --base-url https://ades.playones.com \ - --token "$OPENAI_API_KEY" \ - --input-dir "/path/to/source-audio" \ - --reference-audio reference.wav \ - --output-dir "/path/to/generated-output" \ - --format mp3 \ - --gain-db 4 \ - --overwrite -``` +Requests are sequential. Retries defaults to **1 total attempt**, only confirmed busy 503 permits extra attempts (max 5). Timeout, 500/502/504 and ambiguous load failures are not replayed. [Transport](api.md) explains classification. Multipart is rebuilt from the entire file. -The script: +Preflight checks file presence/size, WAV limits where possible, output-name collisions, overlap with input/reference paths and required ffmpeg. Compressed decoding is server-dependent. Rename colliding stems before generation. Existing outputs skip unless overwrite is requested. -- ASRs each source file. -- Normalizes alert text punctuation. -- Uses the reference audio stem as `ref_text`. -- Calls `/v1/voice/clone` with `provider=qwen` by default. -- Saves `_raw_wav/` originals plus final WAV/MP3 named after the source files. -- Writes `_manifests/` and `conversion_summary.json`. +Raw WAVs go to _raw_wav; final outputs keep source stems. Per-file _manifests and conversion_summary.json track progress, saved after every file. Failed files do not become successes. Rerun skips completed files; review unknown outcomes before resubmitting failed files. This is local progress tracking, not server task resume. Keep private transcripts/manifests outside the repository. -If `OPENAI_API_KEY` is not set, ask the user to get their key from `https://kinfo.playones.com` or pass the key directly with `--token`. - -## Text And Punctuation - -For Chinese alert text, normalize whitespace and punctuation before clone. Add expressive punctuation when it matches the phrase. If the final character is not one of `。!?.!?~~`, append `。`. - -## Gain And Format - -Service clone responses are WAV. For MP3 delivery, use ffmpeg after generation: - -```bash -ffmpeg -y -i generated.wav -filter:a "volume=4dB" -codec:a libmp3lame -q:a 2 generated.mp3 -``` - -Use the user's requested gain literally. Do not normalize or limit unless requested. - -## Windows Notes - -Paths with spaces or CJK characters are common. Prefer direct shell invocation. If launching a background job with PowerShell `Start-Process`, pass a single fully quoted `-ArgumentList` string; arrays can be split incorrectly. - -Use `subprocess.run(..., encoding="utf-8", errors="replace")` in Python when capturing ffmpeg output on Windows to avoid GBK decode crashes. +WAV/no gain is copied; MP3 or requested gain uses ffmpeg. Export writes a temporary file and replaces the final output only after success; failure clears the temporary file and preserves any old completed output. Defaults are WAV and 0 dB. Set format mp3 or gain-db 4 only when wanted. No automatic normalization, limiter or overlay; subprocess capture uses UTF-8 for Windows CJK paths. For redirected Python output on Windows, use python -X utf8 so CJK diagnostics are encoded as UTF-8 too. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md new file mode 100644 index 00000000..6d908b8e --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md @@ -0,0 +1,24 @@ +# Stable Audio music and sound effects + +JSON music uses Medium + SAME-L; sfx uses Small-SFX + SAME-S. Text-to-audio only: no dedicated lyrics, reference audio, inpainting or continuation. Use Yue2 for explicit lyrics. MiniMax was removed. + +| Field | Default | Schema / effective meaning | +|---|---|---| +| prompt | required | Nonblank 1–2000 characters; model uses <=256 tokenizer tokens | +| negative_prompt | null | <=2000 characters; active only when cfg != 1 | +| seconds | music 30, SFX 5 | >0 and <=380; current music cap 380 | +| steps | 8 | Schema 1–64; current deployment cap **16** | +| seed | null/random | 0–2147483647 | +| cfg | 1 | 0–10; 1 is single-branch lower latency | +| apg | 1 | 0–1; active only when cfg != 1 | + +Effective limits are min(380, configured seconds) and min(64, configured steps). SFX shares the 380-second schema but its recommended window remains <=120 seconds. Schema acceptance is not a model recommendation. + +Use one or two concrete sentences: source/genre, instruments, BPM, mood, development, space. Defaults or 4-step short previews are starting points; more steps/CFG do not guarantee quality. Fix seed to compare. Instrumental/no vocals is guidance, not a guarantee. Activate negative_prompt with cfg !=1 (e.g. 2.5), increasing compute. APG does nothing at cfg=1. + +~~~powershell +python '/scripts/call_voice_api.py' music --prompt 'Instrumental piano and warm strings, 90 BPM, no vocals' --seconds 5 --steps 4 --seed 7 --out music.wav +python '/scripts/call_voice_api.py' sfx --prompt 'Three footsteps on wet gravel, realistic transients, no music' --seconds 5 --steps 8 --seed 7 --out sfx.wav +~~~ + +Current output is 44.1 kHz stereo PCM16. Check duration, clipping, content and unwanted vocals by listening. Longer output increases wait/memory; music/SFX share a queue, so do not concurrently benchmark them merely to verify integration. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md new file mode 100644 index 00000000..39889c19 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md @@ -0,0 +1,24 @@ +# ASR and speech enhancement + +## ASR + +Multipart /v1/asr/transcribe: file plus optional form fields. Default language is **English**, not automatic detection. Chinese recordings should explicitly use zh. Aliases: zh/zh-cn/zh-tw → Chinese, en → English, ja/jp → Japanese, ko/kr → Korean. Other values go to the model directly; auto is not an auto-detection guarantee. + +Controls: temperature=0 (0–2), max_tokens=8192 (1–8192), top_k=0 (0–1000), top_p=1 (0–1), optional repetition_penalty (.1–2). Do not inherit TTS sampling settings without reason. + +Text is the primary result; segments may be empty and does not guarantee word/sentence timestamps or speaker separation. Token/TPS and total_time are model statistics, not audio duration or total HTTP latency. Review uncertain transcription before cloning. + +~~~powershell +python '/scripts/call_voice_api.py' asr --file speech.wav --language zh +~~~ + +## Enhancement + +MossFormer2 enhance/denoise are aliases. Optional chunked controls model chunking, not HTTP streaming; use form true/false. RE-USE requires only file and has no public strength/chunk/provider control. + +~~~powershell +python '/scripts/call_voice_api.py' enhance --file noisy.wav --chunked true --out enhanced.wav +python '/scripts/call_voice_api.py' reuse --file noisy.wav --out reuse.wav +~~~ + +Both return complete WAV and mix channels to mono; input is limited to 25 MiB/60 decoded seconds. Evaluate intelligibility, residual noise and speech damage with known speech/noise. Headers are not a quality score. These endpoints are not source separation, clean old-voice replacement inside mixed music, or stereo spatial preservation. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md new file mode 100644 index 00000000..dbb30efa --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md @@ -0,0 +1,37 @@ +# Speech design, reading and cloning + +Design takes JSON text (spoken content) and instruct (voice/delivery), provider qwen by default or voxcpm2. Reading takes JSON text, Qwen only; optional speaker is meaningful only with an actual supported list. Omit it otherwise; do not invent speaker names or use instruct/provider here. + +Clone takes multipart file (reference speech), text (new utterance) and ref_text (accurate reference transcript), with provider rules below. Text/ref_text limit 4096 characters, instruct 1024. Reject blank speech; split long content at complete sentences and verify continuity rather than assuming identical delivery. + +## Qwen + +TTS language names such as Chinese/English or auto go to the model; ASR alias normalization differs. + +| Control | Default | Request range | +|---|---|---| +| temperature | .9 | 0–2 | +| max_tokens | 4096 | 1–8192 | +| top_k | 50 | 0–1000 | +| top_p | 1 | 0–1 | +| repetition_penalty | 1.05 | .1–2 | + +Effective current caps: **design 512**, reading/clone **4096** tokens. Budgets are not characters/seconds; a higher accepted request cannot bypass the cap. Small budgets can truncate. Defaults are a starting point, not a quality guarantee. + +Qwen clone requires nonblank accurate ref_text; instruct is ignored. Use the recording's actual text, a UTF-8 transcript file, or reviewed ASR, never a filename/stem. + +~~~powershell +python '/scripts/call_voice_api.py' clone --provider qwen --file reference.wav --text '这是新的朗读内容。' --ref-text-file reference.txt --language Chinese --out clone.wav +~~~ + +## VoxCPM2 + +Only design/clone support it. Controls: cfg_value=2 (0–10), inference_timesteps=10 (1–200), normalize=false, denoise=false, retry_badcase=true. More steps/CFG can increase time without better sound. + +Current deployment has no built-in denoiser; denoise=true returns 400. Qwen language/sampling/token controls do not reach this provider. The helper rejects wrong-provider controls. Retry_badcase is a model option, separate from HTTP retries. + +Clone with ref_text is transcript-guided Hi-Fi; instruct does not apply. Without ref_text, instruct guides delivery and audio supplies the reference. Design instruct is combined with text by this provider; verify the actual spoken utterance. + +~~~powershell +python '/scripts/call_voice_api.py' clone --provider voxcpm2 --file reference.wav --text '今天的故事从这里开始。' --instruct '保持音色,轻松平静地朗读。' --denoise false --out clone.wav +~~~ diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md new file mode 100644 index 00000000..66abbe78 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md @@ -0,0 +1,50 @@ +# Verification record + +Date: **2026-10-06**. Target: `https://ades.playones.com`. Basis: maintainer's API usage document dated the same day, public health/models/OpenAPI inspection, actual synthetic requests and offline behavior tests. No service source or deployment configuration was modified. + +## Live coverage + +Existing environment credentials authenticated successfully via the legacy OPENAI_API_KEY alias. Inputs were a short Chinese utterance, its generated reference WAV, deterministic low-amplitude added noise, short music/SFX prompts and synthetic lyrics. Requests ran sequentially, without retries. All generated media and detailed diagnostics remain outside the repository. + +| Operation | Probe and observed result | +|---|---| +| Qwen ordinary reading | Chinese text, 256-token budget; valid mono PCM16 24 kHz WAV, 2.64 s. This became the reference. | +| Chinese ASR | Reference with language=zh; nonblank JSON text, known words matched. | +| Qwen design | Short text/style, Chinese, 256-token budget; mono 24 kHz, 2.08 s. | +| VoxCPM2 design | Short text/style, denoise=false; mono 48 kHz, 3.36 s. Generated utterance checked by ASR. | +| Qwen clone | Accurate reference text and new utterance; mono 24 kHz, 1.60 s. Utterance checked by ASR. | +| VoxCPM2 Hi-Fi clone | Same reference with ref_text; mono 48 kHz, 2.08 s. Utterance checked by ASR. | +| VoxCPM2 controlled clone | No ref_text, delivery instruct, denoise=false; mono 48 kHz, 1.92 s. Utterance checked by ASR. | +| MossFormer2 enhance | Noisy synthetic reference, chunked=true; mono 48 kHz, 2.64 s. | +| RE-USE | Same noisy reference; mono 24 kHz, 2.64 s. | +| Stable Audio music | 5 s, 4 steps, seed 7, cfg=1; stereo 44.1 kHz PCM16, exactly 5 s. | +| Stable Audio SFX | Same timing/settings, footsteps prompt; stereo 44.1 kHz PCM16, exactly 5 s. | +| Yue2 | Synthetic tagged Chinese lyrics, direct, 4 steps, seed 7, min 8/max 12 s; stereo 48 kHz PCM16, 11.999 s. | +| Real single-file batch | Explicit accurate reference text; source ASR followed by Qwen clone succeeded, CJK output names/raw WAV/manifest created. Rerun skipped the completed output. | +| Local MP3 | Generated batch WAV converted with explicit +4 dB, produced a usable nonempty MP3. Export was rechecked locally after the atomic-write fix. | + +There were 18 small inference calls including four ASR content rechecks and the two batch calls. After punctuation removal, those four transcripts matched the intended utterances. All initial generated WAVs had nonzero PCM samples and no samples at PCM16 clipping extremes. These checks establish playable-format/signal/content evidence; they do **not** establish perceptual quality, speaker similarity, genre correctness, lyric completeness or denoising improvement. + +Public GETs confirmed service responsiveness and current route/schema availability. Models not loaded or Yue2 stopped before inference were treated as lazy loading, not a failed model test. Stable Audio's OpenAPI step cap was 64 while the supplied deployment guide states effective cap 16; schema presence alone does not override runtime constraints. + +## Local and independent validation + +Run from the repository root: + +```powershell +rtk proxy python -X utf8 -m unittest discover -s plugins/eapil-voice/skills/eapil-voice/tests -v +``` + +**22 regression tests passed**, with network mocked. Coverage includes public GET without credentials, environment precedence, Unicode/multiline/zero/false wire values, real multipart boundary/file content, provider controls, Yue2 mode/ABC constraints, WAV media/header/truncation, decoded duration, ASR shape/empty text, string/array/HTML errors, explicit busy-only retries/Retry-After, whole-file reupload, name collisions, overlap, per-file continuation, existing-output skip and failed conversion cleanup/preservation/manual rerun. + +An independent offline use pass constructed VoxCPM2 clone, 5-second music and UTF-8 lyrics/ABC melody requests through the actual CLI. Mock HTTP drove actual multipart and batch processing; local ffmpeg exported two MP3s, WAVs and manifests. Empty ASR did not clone, a timeout was not replayed, and mixed successes/failures produced the correct summary/exit status. It discovered the partial-MP3 skip problem; atomic export and targeted failure/rerun tests fixed it. This pass did not call live models. + +Skill frontmatter/UI metadata, local documentation links, root AGENTS.md and all five plugin manifests/assets/version were checked. IDs remain eapil-voice and version remains 1.0.0. Changed scripts also passed Ruff checks. + +## Limits of this run + +- No blind listening test, human voice-similarity evaluation, objective denoising score or long-audio benchmark was performed. +- Yue2 direct was live tested; melody/full/external ABC were checked offline only. No claim of live coverage for those modes. +- Denoise alias was checked through the client/offline routing; live enhancement used the primary enhance endpoint. +- Deployment caps, maximum-duration behavior, denied denoise=true, invalid-token behavior, real busy/503 handling, raw-binary uploads and browser CORS were not load-tested. Their guidance comes from the supplied contract/public schema; retry/error behavior was simulated offline. +- No broad concurrency tests, timeout resubmission, API/service reconfiguration, commit, push, tag or market publication occurred. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md new file mode 100644 index 00000000..768afef9 --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md @@ -0,0 +1,26 @@ +# Yue2 songs and ABC arrangements + +JSON /v1/audio/music/yue2, current Yue2 3B Q8. Required nonblank style (1–4000 characters) and lyrics (1–30000). Unknown fields are rejected: Stable Audio prompt/seconds/cfg and legacy duration_seconds do not belong here. + +| Field | Default | Range / meaning | +|---|---|---| +| mode | full | direct, melody, full | +| abc | null | Nonblank if supplied, <=100000 characters; melody/full only | +| steps | 8 | 1–64 acoustic denoising steps | +| seed | 831001 | 0–9223372036854775807 | +| cfg_scale | 1 | 0–20 | +| min_duration_seconds | 8 | Integer 8–360 | +| max_duration_seconds | 120 | Integer 8–360, >=minimum | + +Direct skips ABC planning. Melody plans melody or uses supplied ABC; full plans melody/harmony or uses supplied ABC. No WAV/MP3 reference is accepted; score arrangement is not recording-to-recording singing conversion. + +Duration bounds map to 25 semantic tokens/second; they guide stopping, not exact length or natural endings. Large minimum can force repetition; short maximum can cut lyrics. Start at minimum 8, maximum 60–120 for real songs. Short probes do not establish full-song quality; fixed-length products may need requested trimming/fades. + +Use [Verse]/[Chorus]/[Bridge] and UTF-8 lyric/ABC files. JS Number cannot represent all allowed 64-bit seeds; keep interoperable seeds <=2147483647 or handle exact integers. + +~~~powershell +python '/scripts/call_voice_api.py' yue2 --style 'Mandopop ballad, female vocal, piano and strings' --lyrics-file lyrics.txt --mode full --steps 8 --seed 7 --min-duration-seconds 8 --max-duration-seconds 60 --out song.wav +python '/scripts/call_voice_api.py' yue2 --style 'Acoustic folk, intimate vocal' --lyrics-file lyrics.txt --mode melody --abc-file melody.abc --max-duration-seconds 30 --out arrangement.wav +~~~ + +Lyrics must not be blank/absent. [Instrumental] is experimental, not a guarantee; prefer Stable Audio for pure instrumentals. Current output is 48 kHz stereo PCM16. Lazy startup is handled by the service. Verify lyrics/music by listening; one direct probe does not establish melody/full/ABC. diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py index fd36a4a2..e92b2c70 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py @@ -1,249 +1,326 @@ #!/usr/bin/env python3 -"""Batch ASR + Eapil-Voice clone and export generated speech by source name.""" +"""Sequential ASR/clone export. Generated speech only; no timeout retries.""" from __future__ import annotations - import argparse import json -import mimetypes -import os +import math import re +import shutil import subprocess import sys import time +import tempfile from pathlib import Path -from typing import Any +from types import SimpleNamespace +import call_voice_api as api -import requests - - -DEFAULT_BASE_URL = "https://ades.playones.com" -KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com" AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"} -END_PUNCTUATION = "。!?.!?~~" SAFE_NAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base-url", default=os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL)) - parser.add_argument("--token", default=os.getenv("OPENAI_API_KEY"), required=False) - parser.add_argument("--input-dir", required=True, help="Directory of source alert audio files") - parser.add_argument("--reference-audio", required=True, help="Voice clone reference audio") - parser.add_argument("--output-dir", required=True, help="Directory for generated files") - parser.add_argument("--format", choices=["wav", "mp3"], default="wav") - parser.add_argument("--gain-db", type=float, default=0.0, help="Output gain applied during export") - parser.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen") - parser.add_argument("--asr-language", default="zh") - parser.add_argument("--clone-language", default="auto") - parser.add_argument("--timeout", type=int, default=900) - parser.add_argument("--retries", type=int, default=3) - parser.add_argument("--overwrite", action="store_true") - return parser.parse_args() +def parse_args(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--base-url", default=api.env_base_url()) + p.add_argument("--token", help="legacy direct token; prefer environment") + p.add_argument("--api-key-env") + p.add_argument("--input-dir", required=True) + p.add_argument("--reference-audio", required=True) + ref = p.add_mutually_exclusive_group() + ref.add_argument("--reference-text") + ref.add_argument("--reference-text-file") + p.add_argument("--reference-language", default="zh") + p.add_argument("--output-dir", required=True) + p.add_argument("--format", choices=["wav", "mp3"], default="wav") + p.add_argument("--gain-db", type=float, default=0) + p.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen") + p.add_argument("--asr-language", default="zh") + p.add_argument("--clone-language", default="auto") + p.add_argument("--timeout", type=float, default=600) + p.add_argument( + "--retries", + type=int, + default=1, + help="maximum attempts for explicit busy 503 only; default 1", + ) + p.add_argument( + "--normalize-text", + action="store_true", + help="opt in to whitespace normalization and terminal punctuation", + ) + p.add_argument("--overwrite", action="store_true") + return p.parse_args(argv) -def log(message: str) -> None: - print(message, flush=True) - - -def bearer_token(token: str | None) -> str: - if not token: - raise SystemExit(KEY_HELP) - token = token.strip() - if token.lower().startswith("bearer "): - token = token[7:].strip() - if not token: - raise SystemExit(KEY_HELP) - return token - - -def request_headers(token: str | None) -> dict[str, str]: - return {"Authorization": f"Bearer {bearer_token(token)}"} - - -def audio_files(input_dir: Path) -> list[Path]: +def audio_files(input_dir): return sorted( - [path for path in input_dir.iterdir() if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS], - key=lambda path: path.name.casefold(), + ( + p + for p in input_dir.iterdir() + if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS + ), + key=lambda p: p.name.casefold(), ) -def safe_stem(text: str) -> str: - cleaned = SAFE_NAME_RE.sub("_", text).strip().strip(".") - return cleaned or "untitled" +def safe_stem(text): + name = SAFE_NAME_RE.sub("_", text).strip().strip(".") + if not name: + name = "untitled" + if re.fullmatch(r"(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])", name, re.I): + name = "_" + name + return name -def normalize_text(raw: str, fallback: str) -> str: - text = " ".join((raw or "").replace("\u3000", " ").split()).strip() or fallback.strip() - text = text.translate(str.maketrans({",": ",", ":": ":", ";": ";", "(": "(", ")": ")"})) - if text.startswith("哇") and len(text) > 1 and text[1] not in ",,。!?!?": - text = "哇," + text[1:] - if text.startswith("拜托") and "," not in text[:4] and len(text) > 2: - text = "拜托," + text[2:] - if text and text[-1] not in END_PUNCTUATION: +def normalize_text(raw): + if not isinstance(raw, str) or not raw.strip(): + raise ValueError( + "ASR returned no usable transcript; file names are not transcripts" + ) + text = " ".join(raw.split()) + if text[-1] not in "。!?.!?~~": text += "。" return text -def request_with_retries( - method: str, - url: str, - *, - retries: int, - timeout: int, - **kwargs: Any, -) -> requests.Response: - last_error: Exception | None = None - for attempt in range(1, retries + 1): +def call_with_busy_retry(operation, attempts): + for index in range(attempts): try: - response = requests.request(method, url, timeout=timeout, **kwargs) - if response.status_code < 500: - return response - last_error = RuntimeError(f"HTTP {response.status_code}: {response.text[:500]}") - except Exception as exc: # noqa: BLE001 - last_error = exc - wait = min(30, 2**attempt) - log(f"retry attempt={attempt}/{retries} wait={wait}s error={last_error}") - time.sleep(wait) - if last_error: - raise last_error - raise RuntimeError("request failed") + return operation() + except api.APIError as exc: + if not exc.busy or index + 1 >= attempts: + raise + delay = ( + exc.retry_after + if exc.retry_after is not None + else min(30, 2 ** (index + 1)) + ) + if delay > 60: + raise # Leave unusually long backoff to the calling application. + time.sleep(delay) + raise ValueError("attempt count must be at least 1") -def transcribe(args: argparse.Namespace, source: Path, headers: dict[str, str]) -> dict[str, Any]: - mime = mimetypes.guess_type(source.name)[0] or "application/octet-stream" - with source.open("rb") as handle: - response = request_with_retries( - "POST", - f"{args.base_url.rstrip('/')}/v1/asr/transcribe", - retries=args.retries, - timeout=args.timeout, - headers=headers, - files={"file": (source.name, handle, mime)}, - data={"language": args.asr_language, "temperature": "0"}, - ) - if response.status_code != 200: - raise RuntimeError(f"ASR HTTP {response.status_code}: {response.text[:1000]}") - return response.json() +def transcribe(args, source): + # Each optional busy retry rebuilds multipart from the complete file. + data = call_with_busy_retry( + lambda: api.post_multipart( + args, + api.PATHS["asr"], + {"language": args.asr_language, "temperature": 0}, + str(source), + ), + args.retries, + ) + result = json.loads(data) + if not isinstance(result.get("text"), str) or not result["text"].strip(): + raise ValueError("ASR returned an empty transcript") + return result -def clone(args: argparse.Namespace, reference_audio: Path, ref_text: str, text: str, headers: dict[str, str]) -> bytes: - mime = mimetypes.guess_type(reference_audio.name)[0] or "audio/wav" - with reference_audio.open("rb") as handle: - response = request_with_retries( - "POST", - f"{args.base_url.rstrip('/')}/v1/voice/clone", - retries=args.retries, - timeout=args.timeout, - headers=headers, - files={"file": (reference_audio.name, handle, mime)}, - data={ - "text": text, - "ref_text": ref_text, - "language": args.clone_language, - "provider": args.provider, - }, - ) - if response.status_code != 200: - raise RuntimeError(f"CLONE HTTP {response.status_code}: {response.text[:1000]}") - return response.content - - -def export_audio(raw_wav: Path, output: Path, gain_db: float, output_format: str) -> None: - output.parent.mkdir(parents=True, exist_ok=True) - if output_format == "wav": - command = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - str(raw_wav), - "-filter:a", - f"volume={gain_db:.6f}dB", - str(output), - ] +def reference_transcript(args): + if args.reference_text_file: + text = Path(args.reference_text_file).read_text(encoding="utf-8-sig") + elif args.reference_text is not None: + text = args.reference_text else: - command = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - str(raw_wav), - "-filter:a", - f"volume={gain_db:.6f}dB", - "-codec:a", - "libmp3lame", - "-q:a", - "2", - str(output), + ref_args = SimpleNamespace(**vars(args)) + ref_args.asr_language = args.reference_language + text = transcribe(ref_args, Path(args.reference_audio))["text"] + if not isinstance(text, str) or not text.strip() or len(text) > 4096: + raise ValueError( + "reference transcript must be nonblank and at most 4096 characters" + ) + return text.strip() + + +def clone(args, ref_text, text): + fields = {"text": text, "ref_text": ref_text, "provider": args.provider} + if args.provider == "qwen": + fields["language"] = args.clone_language + api.validate_payload("clone", fields) + return call_with_busy_retry( + lambda: api.post_multipart( + args, api.PATHS["clone"], fields, args.reference_audio + ), + args.retries, + ) + + +def export_audio(raw_wav, output, gain_db, output_format): + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=output.parent, prefix=".audio-export-", suffix=output.suffix, delete=False + ) as handle: + temporary = Path(handle.name) + try: + if output_format == "wav" and gain_db == 0: + api.wav_info(raw_wav.read_bytes()) + temporary.write_bytes(raw_wav.read_bytes()) + else: + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(raw_wav), + ] + if gain_db != 0: + command += ["-filter:a", f"volume={gain_db:.6f}dB"] + if output_format == "mp3": + command += ["-codec:a", "libmp3lame", "-q:a", "2"] + command.append(str(temporary)) + if shutil.which("rtk"): + command = ["rtk", "proxy", *command] + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode or not temporary.stat().st_size: + raise RuntimeError("ffmpeg export failed; inspect conversion locally") + if output_format == "wav": + api.wav_info(temporary.read_bytes()) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + +def prepare(args): + if ( + not math.isfinite(args.gain_db) + or not math.isfinite(args.timeout) + or args.timeout <= 0 + or args.retries < 1 + or args.retries > 5 + ): + raise ValueError("invalid gain/timeout; retries must be 1–5") + api.build_url(args.base_url, api.PATHS["clone"]) + source = Path(args.input_dir) + if not source.is_dir(): + raise ValueError("input directory does not exist") + api.preflight_audio( + args.reference_audio, + args.provider == "qwen" + or not (args.reference_text or args.reference_text_file), + ) + if (args.format == "mp3" or args.gain_db != 0) and not shutil.which("ffmpeg"): + raise ValueError("ffmpeg is required for MP3 or gain conversion") + files = audio_files(source) + names = [safe_stem(p.stem).casefold() for p in files] + if len(names) != len(set(names)): + raise ValueError( + "source names collide after output normalization; rename before generating" + ) + output = Path(args.output_dir) + inputs = {p.resolve() for p in files} | {Path(args.reference_audio).resolve()} + for p in files: + for target in ( + output / f"{safe_stem(p.stem)}.{args.format}", + output / "_raw_wav" / f"{safe_stem(p.stem)}.wav", + ): + if target.resolve() in inputs: + raise ValueError("output must not overwrite input/reference audio") + api.preflight_audio(str(p)) + return files + + +def main(argv=None): + args = parse_args(argv) + try: + files = prepare(args) + import os + + args.token = args.token or ( + os.getenv(args.api_key_env) if args.api_key_env else api.env_token() + ) + api.bearer_token(args.token) + output_dir = Path(args.output_dir) + raw_dir, manifest_dir = output_dir / "_raw_wav", output_dir / "_manifests" + for path in (output_dir, raw_dir, manifest_dir): + path.mkdir(parents=True, exist_ok=True) + pending = [ + p + for p in files + if args.overwrite + or not (output_dir / f"{safe_stem(p.stem)}.{args.format}").exists() ] - result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace") - if result.returncode != 0: - raise RuntimeError(result.stderr[-2000:] or result.stdout[-2000:] or "ffmpeg failed") - - -def main() -> int: - args = parse_args() - if not args.token: - log(KEY_HELP) - return 2 - - input_dir = Path(args.input_dir) - reference_audio = Path(args.reference_audio) - output_dir = Path(args.output_dir) - raw_dir = output_dir / "_raw_wav" - manifest_dir = output_dir / "_manifests" - output_dir.mkdir(parents=True, exist_ok=True) - raw_dir.mkdir(parents=True, exist_ok=True) - manifest_dir.mkdir(parents=True, exist_ok=True) - - headers = request_headers(args.token) - files = audio_files(input_dir) - ref_text = reference_audio.stem - summary: list[dict[str, Any]] = [] - - log(f"base_url={args.base_url.rstrip('/')}") - log(f"input_files={len(files)} output_dir={output_dir} format={args.format} gain_db={args.gain_db}") - - for index, source in enumerate(files, 1): - output = output_dir / f"{safe_stem(source.stem)}.{args.format}" - raw_output = raw_dir / f"{safe_stem(source.stem)}.wav" - try: + ref_text = reference_transcript(args) if pending else "" + summary = [] + for source in files: + output = output_dir / f"{safe_stem(source.stem)}.{args.format}" + raw = raw_dir / f"{safe_stem(source.stem)}.wav" if output.exists() and not args.overwrite: - record = {"source": str(source), "output": str(output), "status": "skipped", "reason": "exists"} - else: - asr = transcribe(args, source, headers) - text = normalize_text(str(asr.get("text") or ""), source.stem) - raw_output.write_bytes(clone(args, reference_audio, ref_text, text, headers)) - export_audio(raw_output, output, args.gain_db, args.format) record = { "source": str(source), "output": str(output), - "raw_wav": str(raw_output), - "status": "ok", - "text": text, - "asr": asr, + "status": "skipped", + "reason": "exists", } - (manifest_dir / f"{safe_stem(source.stem)}.json").write_text( - json.dumps(record, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - log(f"{index}/{len(files)} {record['status'].upper()} {source.name}") - except Exception as exc: # noqa: BLE001 - record = {"source": str(source), "status": "error", "error": str(exc)} - log(f"{index}/{len(files)} ERROR {source.name}: {exc}") - summary.append(record) - - summary_path = output_dir / "conversion_summary.json" - summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") - errors = sum(1 for record in summary if record.get("status") == "error") - log(f"done files={len(files)} errors={errors} summary={summary_path}") - return 1 if errors else 0 + else: + try: + asr = transcribe(args, source) + text = ( + normalize_text(asr["text"]) + if args.normalize_text + else asr["text"].strip() + ) + audio = clone(args, ref_text, text) + api.write_output(str(raw), audio) + export_audio(raw, output, args.gain_db, args.format) + record = { + "source": str(source), + "output": str(output), + "raw_wav": str(raw), + "status": "ok", + "text": text, + "reference_text": ref_text, + "asr": asr, + "audio": api.wav_info(audio), + } + (manifest_dir / f"{safe_stem(source.stem)}.json").write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except (api.APIError, OSError, ValueError, RuntimeError) as exc: + record = { + "source": str(source), + "status": "error", + "error_type": type(exc).__name__, + } + if isinstance(exc, api.APIError): + record["http_status"] = exc.status + summary.append(record) + # Persist progress after each file; existing successful outputs are skipped on rerun. + (output_dir / "conversion_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print( + json.dumps( + {"file": source.name, "status": record["status"]}, + ensure_ascii=False, + ), + flush=True, + ) + if not files: + (output_dir / "conversion_summary.json").write_text("[]", encoding="utf-8") + return int(any(r["status"] == "error" for r in summary)) + except (api.APIError, OSError, ValueError, RuntimeError) as exc: + report = {"ok": False, "error_type": type(exc).__name__} + if isinstance(exc, api.APIError): + report["http_status"] = exc.status + else: + message = str(exc) + if args.token: + message = message.replace(args.token, "[redacted]") + report["message"] = message[:240] + print(json.dumps(report), file=sys.stderr) + return 1 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py index 2372131c..3bf11673 100644 --- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py @@ -1,42 +1,107 @@ #!/usr/bin/env python3 -"""Dependency-free client for the hosted Eapil-Voice API.""" +"""Dependency-free EAPIL audio client. One request, no inference retries.""" from __future__ import annotations - import argparse import json +import math import mimetypes import os +import struct import sys import uuid +from http.client import HTTPException from pathlib import Path from urllib.error import HTTPError, URLError -from urllib.parse import urljoin +from urllib.parse import urlsplit from urllib.request import Request, urlopen - DEFAULT_BASE_URL = "https://ades.playones.com" -KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com" +MAX_UPLOAD_BYTES = 26_214_400 +PATHS = { + "health": "/health", + "models": "/models", + "design": "/v1/voice/design", + "synthesize": "/v1/voice/synthesize", + "clone": "/v1/voice/clone", + "asr": "/v1/asr/transcribe", + "enhance": "/v1/audio/enhance", + "denoise": "/v1/audio/denoise", + "reuse": "/v1/audio/reuse", + "music": "/v1/audio/music", + "sfx": "/v1/audio/sfx", + "yue2": "/v1/audio/music/yue2", +} +QWEN = ("language", "temperature", "max_tokens", "top_k", "top_p", "repetition_penalty") +VOX = ("cfg_value", "inference_timesteps", "normalize", "denoise", "retry_badcase") -def env_base_url() -> str: - return os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL).rstrip("/") +class APIError(RuntimeError): + def __init__(self, status, detail, retry_after=None): + self.status, self.detail = status, detail + try: + delay = float(retry_after) + self.retry_after = delay if math.isfinite(delay) and delay >= 0 else None + except (TypeError, ValueError): + self.retry_after = None + text = detail.lower() if isinstance(detail, str) else "" + self.busy = ( + status == 503 + and ("busy" in text or "already running" in text) + and not any( + word in text + for word in ("failed", "error", "load", "connect", "timeout") + ) + ) + super().__init__(f"HTTP {status}; provider detail omitted, no automatic retry") -def env_token() -> str | None: - return os.getenv("OPENAI_API_KEY") +def env_base_url(): + return ( + os.getenv("AUDIO_API_BASE") + or os.getenv("QWEN3_VOICE_API_BASE_URL") + or DEFAULT_BASE_URL + ) -def add_common_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--base-url", default=env_base_url(), help="API base URL") - parser.add_argument("--token", default=env_token(), help="Bearer API key; defaults to OPENAI_API_KEY") +def env_token(): + return os.getenv("AUDIO_API_TOKEN") or os.getenv("OPENAI_API_KEY") -def clean_payload(values: dict[str, object | None]) -> dict[str, object]: - return {key: value for key, value in values.items() if value is not None} +def bearer_token(token): + value = (token or "").strip() + if value.lower().startswith("bearer "): + value = value[7:].strip() + if not value or "\r" in value or "\n" in value: + raise ValueError( + "set AUDIO_API_TOKEN (legacy OPENAI_API_KEY supported), or --api-key-env" + ) + return value -def bool_value(raw: str | bool | None) -> bool | None: +def build_url(base_url, path): + parts = urlsplit(base_url) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username + or parts.password + or parts.query + or parts.fragment + ): + raise ValueError( + "base URL must be an HTTP(S) service root without credentials/query/fragment" + ) + if parts.path.rstrip("/").endswith("/v1"): + raise ValueError("audio base URL is the service root, without /v1") + return base_url.rstrip("/") + path + + +def clean_payload(values): + return {k: v for k, v in values.items() if v is not None} + + +def bool_value(raw): if raw is None or isinstance(raw, bool): return raw value = raw.strip().lower() @@ -44,326 +109,458 @@ def bool_value(raw: str | bool | None) -> bool | None: return True if value in {"0", "false", "no", "off"}: return False - raise argparse.ArgumentTypeError(f"invalid boolean value: {raw}") + raise argparse.ArgumentTypeError("boolean must be true or false") -def bearer_token(token: str | None) -> str: - if not token: - raise SystemExit(KEY_HELP) - token = token.strip() - if token.lower().startswith("bearer "): - token = token[7:].strip() - if not token: - raise SystemExit(KEY_HELP) - return token +def upload_bytes(file_path): + path = Path(file_path) + if not path.is_file() or not 0 < path.stat().st_size <= MAX_UPLOAD_BYTES: + raise ValueError("upload must be a nonempty file of at most 25 MiB") + return path.read_bytes() -def request_headers(token: str | None, extra: dict[str, str] | None = None) -> dict[str, str]: - headers = dict(extra or {}) - headers["Authorization"] = f"Bearer {bearer_token(token)}" - return headers +def preflight_audio(file_path, limit_seconds=True): + data = upload_bytes(file_path) + if Path(file_path).suffix.lower() == ".wav": + info = wav_info(data) + if limit_seconds and info["frames"] / info["sample_rate"] > 60: + raise ValueError("decoded WAV exceeds the current 60-second input limit") -def build_url(base_url: str, path: str) -> str: - return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) +def multipart_body(*, fields, file_path, file_field="file"): + audio = upload_bytes(file_path) + boundary = "----eapil-audio-" + uuid.uuid4().hex + parts = [] + for name, value in clean_payload(fields).items(): + if any(c in name for c in '\r\n"'): + raise ValueError("invalid multipart field name") + value = str(value).lower() if isinstance(value, bool) else str(value) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode( + "utf-8" + ) + ) + filename = ( + Path(file_path).name.replace('"', "_").replace("\r", "_").replace("\n", "_") + ) + mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode( + "utf-8" + ) + ) + parts.extend((audio, f"\r\n--{boundary}--\r\n".encode("ascii"))) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def wav_info(data): + """Check complete little-endian PCM/float WAV, including extensible headers.""" + if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WAVE": + raise ValueError("response is not RIFF/WAVE") + end = struct.unpack_from(" end: + raise ValueError("truncated WAV chunk") + if kind == b"fmt ": + if size < 16: + raise ValueError("invalid WAV format") + fmt = struct.unpack_from(" bytes: - request = Request( - build_url(base_url, path), - data=body, - method=method, - headers=request_headers(token, headers), - ) + method, + base_url, + path, + token=None, + body=None, + headers=None, + timeout=600, + expected="json", +): + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be positive and finite") + hdr = dict(headers or {}) + if path.startswith("/v1/"): + hdr["Authorization"] = "Bearer " + bearer_token(token) + hdr["Accept"] = "audio/wav" if expected == "wav" else "application/json" + req = Request(build_url(base_url, path), data=body, method=method, headers=hdr) try: - with urlopen(request) as response: - return response.read() + with urlopen(req, timeout=timeout) as response: + media = ( + response.headers.get("Content-Type", "") + .split(";", 1)[0] + .lower() + .strip() + ) + data = response.read() except HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - raise SystemExit(f"HTTP {exc.code} {exc.reason}: {detail}") from exc - except URLError as exc: - raise SystemExit(f"request failed: {exc.reason}") from exc + raw = exc.read().decode("utf-8", "replace") + try: + parsed = json.loads(raw) + detail = ( + parsed.get("detail", parsed) if isinstance(parsed, dict) else parsed + ) + except ValueError: + detail = "non-JSON error response" + raise APIError( + exc.code, detail, exc.headers.get("Retry-After") if exc.headers else None + ) from exc + except HTTPException as exc: + raise RuntimeError( + "incomplete HTTP response; outcome unknown, no automatic retry" + ) from exc + if expected == "wav": + if media not in {"audio/wav", "audio/x-wav", "audio/wave"}: + raise ValueError("successful response is not WAV media") + wav_info(data) + else: + if media != "application/json": + raise ValueError("successful response is not JSON media") + value = json.loads(data) + if not isinstance(value, dict): + raise ValueError("expected a JSON object") + if path == PATHS["asr"] and not isinstance(value.get("text"), str): + raise ValueError("ASR response must contain string text") + return data -def post_json(args: argparse.Namespace, path: str, payload: dict[str, object]) -> bytes: - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") +def post_json(args, path, payload): return http_request( method="POST", base_url=args.base_url, path=path, token=args.token, - body=body, + body=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"), headers={"Content-Type": "application/json"}, + timeout=args.timeout, + expected="wav", ) -def multipart_body( - *, - fields: dict[str, object | None], - file_field: str | None = None, - file_path: str | None = None, -) -> tuple[bytes, str]: - boundary = f"----eapil-voice-{uuid.uuid4().hex}" - chunks: list[bytes] = [] - - def add_line(value: str) -> None: - chunks.append(value.encode("utf-8")) - - def field_value(value: object) -> str: - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - for name, value in clean_payload(fields).items(): - add_line(f"--{boundary}\r\n") - add_line(f'Content-Disposition: form-data; name="{name}"\r\n\r\n') - add_line(f"{field_value(value)}\r\n") - - if file_field and file_path: - path = Path(file_path) - content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" - add_line(f"--{boundary}\r\n") - add_line( - f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n' - ) - add_line(f"Content-Type: {content_type}\r\n\r\n") - chunks.append(path.read_bytes()) - add_line("\r\n") - - add_line(f"--{boundary}--\r\n") - return b"".join(chunks), f"multipart/form-data; boundary={boundary}" - - -def post_multipart( - args: argparse.Namespace, - path: str, - fields: dict[str, object | None], - file_path: str | None, -) -> bytes: - body, content_type = multipart_body(fields=fields, file_field="file", file_path=file_path) +def post_multipart(args, path, fields, file_path): + body, media = multipart_body(fields=fields, file_path=file_path) return http_request( method="POST", base_url=args.base_url, path=path, token=args.token, body=body, - headers={"Content-Type": content_type}, + headers={"Content-Type": media}, + timeout=args.timeout, + expected="json" if path == PATHS["asr"] else "wav", ) -def write_output(path: str, data: bytes) -> None: - Path(path).write_bytes(data) - print(path) +def write_output(path, data): + wav_info(data) + target = Path(path) + if target.suffix.lower() != ".wav": + raise ValueError("API output is WAV; transcode separately for MP3") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) -def cmd_health(args: argparse.Namespace) -> None: - data = http_request( - method="GET", - base_url=args.base_url, - path="/health", - token=args.token, - ) - print_json(data) +def validate_payload(command, payload): + required = { + "design": ("text", "instruct"), + "synthesize": ("text",), + "clone": ("text",), + "music": ("prompt",), + "sfx": ("prompt",), + "yue2": ("style", "lyrics"), + }.get(command, ()) + limits = { + "text": 4096, + "ref_text": 4096, + "instruct": 1024, + "prompt": 2000, + "negative_prompt": 2000, + "style": 4000, + "lyrics": 30000, + "abc": 100000, + } + for key in required: + if not isinstance(payload.get(key), str) or not payload[key].strip(): + raise ValueError(f"{key} must be nonblank text") + for key, limit in limits.items(): + if key in payload and ( + not isinstance(payload[key], str) + or len(payload[key]) > limit + or key in {"ref_text", "abc"} + and not payload[key].strip() + ): + raise ValueError(f"invalid {key} or exceeds {limit} characters") + ranges = { + "temperature": (0, 2), + "max_tokens": (1, 8192), + "top_k": (0, 1000), + "top_p": (0, 1), + "repetition_penalty": (0.1, 2), + "cfg_value": (0, 10), + "inference_timesteps": (1, 200), + "cfg": (0, 10), + "apg": (0, 1), + "steps": (1, 64), + "cfg_scale": (0, 20), + "min_duration_seconds": (8, 360), + "max_duration_seconds": (8, 360), + "seed": (0, 9223372036854775807 if command == "yue2" else 2147483647), + } + for key, (low, high) in ranges.items(): + if key in payload and ( + isinstance(payload[key], bool) + or not isinstance(payload[key], (int, float)) + or not math.isfinite(payload[key]) + or not low <= payload[key] <= high + ): + raise ValueError(f"{key} must be between {low} and {high}") + if "seconds" in payload and not ( + math.isfinite(payload["seconds"]) and 0 < payload["seconds"] <= 380 + ): + raise ValueError("seconds must be greater than 0 and at most 380") + if command in {"design", "clone"}: + provider = payload.get("provider", "qwen") + if provider not in {"qwen", "voxcpm2"}: + raise ValueError("provider must be qwen or voxcpm2") + ignored = VOX if provider == "qwen" else QWEN + if any(k in payload for k in ignored): + raise ValueError("control fields must match the selected provider") + if command == "clone": + if provider == "qwen" and not payload.get("ref_text", "").strip(): + raise ValueError( + "Qwen clone requires the accurate reference transcript" + ) + if "instruct" in payload and (provider == "qwen" or "ref_text" in payload): + raise ValueError( + "clone instruct only applies to VoxCPM2 without ref_text" + ) + if command == "yue2": + if payload.get("mode", "full") == "direct" and "abc" in payload: + raise ValueError("ABC is allowed only in melody/full mode") + if payload.get("max_duration_seconds", 120) < payload.get( + "min_duration_seconds", 8 + ): + raise ValueError("maximum duration must not be less than minimum") -def cmd_models(args: argparse.Namespace) -> None: - data = http_request( - method="GET", - base_url=args.base_url, - path="/models", - token=args.token, - ) - print_json(data) - - -def print_json(data: bytes) -> None: - try: - parsed = json.loads(data.decode("utf-8")) - except json.JSONDecodeError: - sys.stdout.buffer.write(data) - return - print(json.dumps(parsed, ensure_ascii=False, indent=2)) - - -def cmd_design(args: argparse.Namespace) -> None: - payload = clean_payload( - { - "text": args.text, - "instruct": args.instruct, - "provider": args.provider, - "language": args.language, - "temperature": args.temperature, - "max_tokens": args.max_tokens, - "top_k": args.top_k, - "top_p": args.top_p, - "repetition_penalty": args.repetition_penalty, - "cfg_value": args.cfg_value, - "inference_timesteps": args.inference_timesteps, - "normalize": args.normalize, - "denoise": args.denoise, - "retry_badcase": args.retry_badcase, - } - ) - write_output(args.out, post_json(args, "/v1/voice/design", payload)) - - -def cmd_synthesize(args: argparse.Namespace) -> None: - payload = clean_payload( - { - "text": args.text, - "speaker": args.speaker, - "language": args.language, - "temperature": args.temperature, - "max_tokens": args.max_tokens, - "top_k": args.top_k, - "top_p": args.top_p, - "repetition_penalty": args.repetition_penalty, - } - ) - write_output(args.out, post_json(args, "/v1/voice/synthesize", payload)) - - -def cmd_clone(args: argparse.Namespace) -> None: - fields = clean_payload( - { - "text": args.text, - "ref_text": args.ref_text, - "language": args.language, - "temperature": args.temperature, - "max_tokens": args.max_tokens, - "top_k": args.top_k, - "top_p": args.top_p, - "repetition_penalty": args.repetition_penalty, - "provider": args.provider, - "instruct": args.instruct, - "cfg_value": args.cfg_value, - "inference_timesteps": args.inference_timesteps, - "normalize": args.normalize, - "denoise": args.denoise, - "retry_badcase": args.retry_badcase, - } - ) - write_output(args.out, post_multipart(args, "/v1/voice/clone", fields, args.file)) - - -def cmd_enhance(args: argparse.Namespace) -> None: - fields = {"chunked": args.chunked} - write_output(args.out, post_multipart(args, "/v1/audio/enhance", fields, args.file)) - - -def cmd_denoise(args: argparse.Namespace) -> None: - fields = {"chunked": args.chunked} - write_output(args.out, post_multipart(args, "/v1/audio/denoise", fields, args.file)) - - -def cmd_asr(args: argparse.Namespace) -> None: - fields = clean_payload( - { - "language": args.language, - "temperature": args.temperature, - "max_tokens": args.max_tokens, - "top_k": args.top_k, - "top_p": args.top_p, - "repetition_penalty": args.repetition_penalty, - } - ) - print_json(post_multipart(args, "/v1/asr/transcribe", fields, args.file)) - - -def add_generation_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--language", default=None) - parser.add_argument("--temperature", type=float, default=None) - parser.add_argument("--max-tokens", type=int, default=None) - parser.add_argument("--top-k", type=int, default=None) - parser.add_argument("--top-p", type=float, default=None) - parser.add_argument("--repetition-penalty", type=float, default=None) - - -def add_voxcpm2_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--cfg-value", type=float, default=None) - parser.add_argument("--inference-timesteps", type=int, default=None) - parser.add_argument("--normalize", type=bool_value, default=None) - parser.add_argument("--denoise", type=bool_value, default=None) - parser.add_argument("--retry-badcase", type=bool_value, default=None) - - -def build_parser() -> argparse.ArgumentParser: +def build_parser(): parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - health = subparsers.add_parser("health", help="Check service health") - add_common_args(health) - health.set_defaults(func=cmd_health) - - models = subparsers.add_parser("models", help="Show configured model status") - add_common_args(models) - models.set_defaults(func=cmd_models) - - design = subparsers.add_parser("design", help="Generate voice from text and instruction") - add_common_args(design) - design.add_argument("--text", required=True) - design.add_argument("--instruct", required=True) - design.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None) - design.add_argument("--out", default="voice_design.wav") - add_generation_args(design) - add_voxcpm2_args(design) - design.set_defaults(func=cmd_design) - - synthesize = subparsers.add_parser("synthesize", help="Base TTS synthesis") - add_common_args(synthesize) - synthesize.add_argument("--text", required=True) - synthesize.add_argument("--speaker", default=None) - synthesize.add_argument("--out", default="synthesize.wav") - add_generation_args(synthesize) - synthesize.set_defaults(func=cmd_synthesize) - - clone = subparsers.add_parser("clone", help="Clone a voice from reference audio") - add_common_args(clone) - clone.add_argument("--file", required=True, help="Reference audio path") - clone.add_argument("--text", required=True) - clone.add_argument("--ref-text", default=None) - clone.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None) - clone.add_argument("--instruct", default=None) - clone.add_argument("--out", default="voice_clone.wav") - add_generation_args(clone) - add_voxcpm2_args(clone) - clone.set_defaults(func=cmd_clone) - - enhance = subparsers.add_parser("enhance", help="Enhance speech audio") - add_common_args(enhance) - enhance.add_argument("--file", required=True) - enhance.add_argument("--chunked", type=bool_value, default=None) - enhance.add_argument("--out", default="enhanced.wav") - enhance.set_defaults(func=cmd_enhance) - - denoise = subparsers.add_parser("denoise", help="Denoise speech audio") - add_common_args(denoise) - denoise.add_argument("--file", required=True) - denoise.add_argument("--chunked", type=bool_value, default=None) - denoise.add_argument("--out", default="enhanced.wav") - denoise.set_defaults(func=cmd_denoise) - - asr = subparsers.add_parser("asr", help="Transcribe audio to JSON") - add_common_args(asr) - asr.add_argument("--file", required=True) - add_generation_args(asr) - asr.set_defaults(func=cmd_asr) - + subs = parser.add_subparsers(dest="command", required=True) + for command in PATHS: + sub = subs.add_parser(command) + sub.add_argument("--base-url", default=env_base_url()) + sub.add_argument("--token", help="legacy direct token; prefer environment") + sub.add_argument("--api-key-env", help="explicit token environment variable") + sub.add_argument( + "--timeout", + type=float, + default=1800 if command in {"music", "sfx", "yue2"} else 600, + ) + sub.add_argument("--dry-run", action="store_true") + if command in {"health", "models"}: + continue + if command != "asr": + sub.add_argument( + "--out", + default={ + "design": "voice_design.wav", + "clone": "voice_clone.wav", + "enhance": "enhanced.wav", + "denoise": "enhanced.wav", + }.get(command, f"{command}.wav"), + ) + if command in {"clone", "asr", "enhance", "denoise", "reuse"}: + sub.add_argument("--file", required=True) + if command in {"design", "synthesize", "clone"}: + sub.add_argument("--text", required=True) + if command in {"design", "clone"}: + sub.add_argument("--instruct", required=command == "design") + sub.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen") + for key in VOX: + kind = ( + bool_value + if key in {"normalize", "denoise", "retry_badcase"} + else int + if key == "inference_timesteps" + else float + ) + sub.add_argument("--" + key.replace("_", "-"), type=kind) + if command == "clone": + sub.add_argument("--ref-text") + sub.add_argument("--ref-text-file") + if command == "synthesize": + sub.add_argument("--speaker") + if command in {"design", "synthesize", "clone", "asr"}: + for key in QWEN: + kind = ( + str + if key == "language" + else int + if key in {"max_tokens", "top_k"} + else float + ) + sub.add_argument("--" + key.replace("_", "-"), type=kind) + if command in {"enhance", "denoise"}: + sub.add_argument("--chunked", type=bool_value) + if command in {"music", "sfx"}: + sub.add_argument("--prompt", required=True) + sub.add_argument("--negative-prompt") + sub.add_argument( + "--seconds", type=float, default=30 if command == "music" else 5 + ) + sub.add_argument("--steps", type=int, default=8) + sub.add_argument("--seed", type=int) + sub.add_argument("--cfg", type=float, default=1.0) + sub.add_argument("--apg", type=float, default=1.0) + if command == "yue2": + sub.add_argument("--style", required=True) + lyrics = sub.add_mutually_exclusive_group(required=True) + lyrics.add_argument("--lyrics") + lyrics.add_argument("--lyrics-file") + sub.add_argument( + "--mode", choices=["direct", "melody", "full"], default="full" + ) + sub.add_argument("--abc-file") + sub.add_argument("--steps", type=int, default=8) + sub.add_argument("--seed", type=int, default=831001) + sub.add_argument("--cfg-scale", type=float, default=1.0) + sub.add_argument("--min-duration-seconds", type=int, default=8) + sub.add_argument("--max-duration-seconds", type=int, default=120) return parser -def main() -> None: - args = build_parser().parse_args() - args.func(args) +def payload_from_args(args): + excluded = { + "command", + "base_url", + "token", + "api_key_env", + "timeout", + "dry_run", + "out", + "file", + "ref_text_file", + "lyrics_file", + "abc_file", + } + payload = clean_payload({k: v for k, v in vars(args).items() if k not in excluded}) + for key in ("ref_text", "lyrics", "abc"): + path = getattr(args, key + "_file", None) + if path: + if key in payload: + raise ValueError(f"supply {key} text or file, not both") + payload[key] = Path(path).read_text(encoding="utf-8-sig") + validate_payload(args.command, payload) + return payload + + +def main(argv=None): + args = build_parser().parse_args(argv) + try: + url = build_url(args.base_url, PATHS[args.command]) + if not math.isfinite(args.timeout) or args.timeout <= 0: + raise ValueError("timeout must be positive and finite") + payload = payload_from_args(args) + file = getattr(args, "file", None) + if file: + preflight_audio( + file, not (args.command == "clone" and args.provider == "voxcpm2") + ) + if hasattr(args, "out") and Path(args.out).suffix.lower() != ".wav": + raise ValueError("use a .wav output path") + if args.dry_run: + print( + json.dumps( + { + "url": url, + "payload": payload, + "file": file, + "timeout": args.timeout, + }, + ensure_ascii=False, + indent=2, + ) + ) + return 0 + args.token = args.token or ( + os.getenv(args.api_key_env) if args.api_key_env else env_token() + ) + if args.command in {"health", "models"}: + data = http_request( + method="GET", + base_url=args.base_url, + path=PATHS[args.command], + timeout=args.timeout, + ) + elif file: + data = post_multipart(args, PATHS[args.command], payload, file) + else: + data = post_json(args, PATHS[args.command], payload) + if args.command in {"asr", "health", "models"}: + print(json.dumps(json.loads(data), ensure_ascii=False, indent=2)) + else: + write_output(args.out, data) + print( + json.dumps( + { + "ok": True, + "output": str(Path(args.out).resolve()), + **wav_info(data), + }, + ensure_ascii=False, + ) + ) + return 0 + except (APIError, URLError, OSError, ValueError, RuntimeError) as exc: + report = {"ok": False, "error_type": type(exc).__name__} + if isinstance(exc, APIError): + report.update(http_status=exc.status, confirmed_busy=exc.busy) + elif isinstance(exc, (URLError, TimeoutError)): + report["message"] = ( + "connection/timeout; outcome unknown, no automatic retry" + ) + else: + message = str(exc) + if args.token: + message = message.replace(args.token, "[redacted]") + report["message"] = message[:240] + print(json.dumps(report, ensure_ascii=False), file=sys.stderr) + return 1 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py new file mode 100644 index 00000000..642e093d --- /dev/null +++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py @@ -0,0 +1,486 @@ +from __future__ import annotations +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +import wave +from email.parser import BytesParser +from email.policy import default +from pathlib import Path +from unittest import mock +from urllib.error import HTTPError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import call_voice_api as api +import batch_clone_generated_audio as batch + + +def wav(seconds=0.02): + out = io.BytesIO() + with wave.open(out, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(24000) + w.writeframes(b"\x01\x00" * int(24000 * seconds)) + return out.getvalue() + + +def response(data, media): + r = io.BytesIO(data) + r.headers = {"Content-Type": media} + return r + + +class ClientTests(unittest.TestCase): + def setUp(self): + p = mock.patch.object( + api, "urlopen", side_effect=AssertionError("unexpected network") + ) + self.net = p.start() + self.addCleanup(p.stop) + + def test_public_status_needs_no_auth(self): + self.net.side_effect = None + self.net.return_value = response(b'{"status":"ok"}', "application/json") + api.http_request(method="GET", base_url="https://example.test", path="/health") + req = self.net.call_args.args[0] + self.assertIsNone(req.get_header("Authorization")) + self.assertEqual(self.net.call_args.kwargs["timeout"], 600) + + def test_environment_priority_and_explicit_variable(self): + with mock.patch.dict( + os.environ, + { + "AUDIO_API_TOKEN": "new", + "OPENAI_API_KEY": "old", + "AUDIO_API_BASE": "https://new.test", + "QWEN3_VOICE_API_BASE_URL": "https://old.test", + }, + clear=True, + ): + self.assertEqual(api.env_token(), "new") + self.assertEqual(api.env_base_url(), "https://new.test") + for base in ( + "file:///tmp", + "https://token@example.test", + "https://example.test/v1", + "https://example.test?q=secret", + ): + with self.subTest(base=base), self.assertRaises(ValueError): + api.build_url(base, "/health") + + def test_json_wire_preserves_multiline_unicode_zero_and_false(self): + args = api.build_parser().parse_args( + [ + "yue2", + "--style", + "中文", + "--lyrics", + "[Verse]\n海风", + "--seed", + "0", + "--cfg-scale", + "0", + ] + ) + args.token = "test-key" + payload = api.payload_from_args(args) + self.net.side_effect = None + self.net.return_value = response(wav(), "audio/wav") + api.post_json(args, api.PATHS["yue2"], payload) + req = self.net.call_args.args[0] + self.assertEqual(json.loads(req.data), payload) + self.assertEqual(payload["seed"], 0) + self.assertEqual(payload["lyrics"], "[Verse]\n海风") + self.assertNotIn("seconds", payload) + vox = api.build_parser().parse_args( + [ + "design", + "--provider", + "voxcpm2", + "--text", + "你好", + "--instruct", + "平静", + "--denoise", + "false", + "--cfg-value", + "0", + ] + ) + self.assertIs(api.payload_from_args(vox)["denoise"], False) + + def test_multipart_has_real_boundary_complete_file_and_false_zero(self): + with tempfile.TemporaryDirectory() as d: + file = Path(d) / "参考.wav" + file.write_bytes(wav()) + body, media = api.multipart_body( + fields={"chunked": False, "temperature": 0, "ref_text": "你好"}, + file_path=str(file), + ) + msg = BytesParser(policy=default).parsebytes( + ("Content-Type: " + media + "\r\nMIME-Version: 1.0\r\n\r\n").encode() + + body + ) + parts = list(msg.iter_parts()) + values = { + p.get_param("name", header="content-disposition"): p.get_payload( + decode=True + ) + for p in parts + } + self.assertEqual(values["file"], file.read_bytes()) + self.assertEqual(values["chunked"], b"false") + self.assertEqual(values["temperature"], b"0") + self.assertEqual(values["ref_text"].decode(), "你好") + + def test_success_media_and_truncated_wav_fail_before_writing(self): + cases = [ + (b'{"error":"bad"}', "application/json"), + (b"error", "text/html"), + (b"garbage", "audio/wav"), + (wav()[:-2], "audio/wav"), + ] + for data, media in cases: + with self.subTest(media=media), self.assertRaises(ValueError): + self.net.side_effect = None + self.net.return_value = response(data, media) + api.http_request( + method="POST", + base_url="https://example.test", + path=api.PATHS["music"], + token="test", + expected="wav", + ) + + def test_wav_header_durations_and_preflight(self): + self.assertEqual(api.wav_info(wav())["sample_rate"], 24000) + with tempfile.TemporaryDirectory() as d: + file = Path(d) / "long.wav" + file.write_bytes(wav(60.1)) + with self.assertRaises(ValueError): + api.preflight_audio(str(file)) + api.preflight_audio(str(file), False) + file.write_bytes(wav(60 + 1 / 24000)) + with self.assertRaises(ValueError): + api.preflight_audio(str(file)) + file.write_bytes(b"not a WAV") + with self.assertRaises(ValueError): + api.preflight_audio(str(file)) + with tempfile.TemporaryDirectory() as d: + out = Path(d) / "out.wav" + with self.assertRaises(ValueError): + api.write_output(str(out), wav()[:-1]) + self.assertFalse(out.exists()) + + def test_asr_invalid_json_or_wrong_text_type_fails(self): + for data in (b"not json", b'{"text":7}', b"[]"): + self.net.side_effect = None + self.net.return_value = response(data, "application/json") + with self.assertRaises(ValueError): + api.http_request( + method="POST", + base_url="https://example.test", + path=api.PATHS["asr"], + token="test", + ) + + def test_error_detail_string_array_html_and_no_retry(self): + for raw in ( + b'{"detail":"busy"}', + b'{"detail":[{"msg":"invalid"}]}', + b"bad", + ): + self.net.reset_mock() + self.net.side_effect = HTTPError( + "https://example.test", 503, "error", {}, io.BytesIO(raw) + ) + with self.assertRaises(api.APIError) as caught: + api.http_request( + method="POST", + base_url="https://example.test", + path=api.PATHS["music"], + token="test", + ) + self.assertEqual(caught.exception.status, 503) + self.net.assert_called_once() + self.assertFalse(caught.exception.busy) + + def test_provider_constraints_and_yue2_combinations(self): + for command, payload in [ + ("clone", {"provider": "qwen", "text": "new"}), + ( + "design", + { + "provider": "voxcpm2", + "text": "new", + "instruct": "style", + "temperature": 0, + }, + ), + ( + "clone", + { + "provider": "voxcpm2", + "text": "new", + "ref_text": "reference", + "instruct": "ignored", + }, + ), + ( + "yue2", + {"style": "folk", "lyrics": "song", "mode": "direct", "abc": "X:1"}, + ), + ( + "yue2", + { + "style": "folk", + "lyrics": "song", + "min_duration_seconds": 30, + "max_duration_seconds": 8, + }, + ), + ("music", {"prompt": " "}), + ("synthesize", {"text": "new", "temperature": float("nan")}), + ]: + with self.subTest(command=command), self.assertRaises(ValueError): + api.validate_payload(command, payload) + + def test_new_routes_and_lyric_files_dry_run_require_no_key(self): + with tempfile.TemporaryDirectory() as d: + file = Path(d) / "speech.wav" + file.write_bytes(wav()) + lyrics = Path(d) / "歌词.txt" + lyrics.write_text("[Verse]\n你好", encoding="utf-8-sig") + commands = [ + ["reuse", "--file", str(file)], + ["music", "--prompt", "piano"], + ["sfx", "--prompt", "rain"], + ["yue2", "--style", "folk", "--lyrics-file", str(lyrics)], + ] + for argv in commands: + output = io.StringIO() + with ( + mock.patch.dict(os.environ, {}, clear=True), + contextlib.redirect_stdout(output), + ): + self.assertEqual(api.main(argv + ["--dry-run"]), 0) + value = json.loads(output.getvalue()) + self.assertTrue(value["url"].endswith(api.PATHS[argv[0]])) + self.assertNotIn("token", value) + self.net.assert_not_called() + + +class BatchTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + (self.root / "inputs").mkdir() + self.ref = self.root / "misleading-name.wav" + self.ref.write_bytes(wav()) + self.src = self.root / "inputs" / "source.wav" + self.src.write_bytes(wav()) + self.argv = [ + "--input-dir", + str(self.root / "inputs"), + "--reference-audio", + str(self.ref), + "--output-dir", + str(self.root / "out"), + "--token", + "test", + ] + p = mock.patch.object( + api, "urlopen", side_effect=AssertionError("unexpected network") + ) + self.net = p.start() + self.addCleanup(p.stop) + + def test_reference_uses_explicit_or_one_asr_never_stem(self): + args = batch.parse_args(self.argv) + with mock.patch.object( + batch, "transcribe", return_value={"text": "actual words"} + ) as transcribe: + self.assertEqual(batch.reference_transcript(args), "actual words") + transcribe.assert_called_once() + args.reference_text = "explicit actual words" + with mock.patch.object(batch, "transcribe") as transcribe: + self.assertEqual(batch.reference_transcript(args), "explicit actual words") + transcribe.assert_not_called() + + def test_empty_asr_never_clones_and_next_file_continues(self): + (self.root / "inputs" / "second.wav").write_bytes(wav()) + with ( + mock.patch.object( + batch, "transcribe", side_effect=[ValueError("empty"), {"text": "你好"}] + ), + mock.patch.object(batch, "clone", return_value=wav()) as clone, + contextlib.redirect_stdout(io.StringIO()), + ): + code = batch.main(self.argv + ["--reference-text", "known reference"]) + self.assertEqual(code, 1) + clone.assert_called_once() + summary = json.loads( + (self.root / "out" / "conversion_summary.json").read_text(encoding="utf-8") + ) + self.assertEqual([r["status"] for r in summary], ["error", "ok"]) + self.assertEqual(summary[1]["text"], "你好") + + def test_successful_outputs_skip_without_reference_asr_or_clone(self): + (self.root / "out").mkdir() + (self.root / "out" / "source.wav").write_bytes(wav()) + with ( + mock.patch.object(batch, "transcribe") as transcribe, + mock.patch.object(batch, "clone") as clone, + contextlib.redirect_stdout(io.StringIO()), + ): + self.assertEqual(batch.main(self.argv), 0) + transcribe.assert_not_called() + clone.assert_not_called() + + def test_empty_recognition_does_not_fall_back_to_name(self): + args = batch.parse_args(self.argv) + with ( + mock.patch.object(api, "post_multipart", return_value=b'{"text":""}'), + self.assertRaises(ValueError), + ): + batch.transcribe(args, self.src) + with self.assertRaises(ValueError): + batch.normalize_text("") + self.assertEqual(batch.normalize_text("你好"), "你好。") + + def test_only_confirmed_busy_retries_and_last_failure_never_sleeps(self): + op = mock.Mock(side_effect=[api.APIError(503, "model busy"), b"ok"]) + with mock.patch.object(batch.time, "sleep") as sleep: + self.assertEqual(batch.call_with_busy_retry(op, 2), b"ok") + sleep.assert_called_once() + for err in [ + api.APIError(500, "busy"), + api.APIError(502, "busy"), + api.APIError(504, "timeout"), + api.APIError(503, "failed to load model"), + TimeoutError("unknown outcome"), + ]: + op = mock.Mock(side_effect=err) + with ( + mock.patch.object(batch.time, "sleep") as sleep, + self.assertRaises(type(err)), + ): + batch.call_with_busy_retry(op, 3) + op.assert_called_once() + sleep.assert_not_called() + + def test_busy_retry_reuploads_full_file(self): + args = batch.parse_args(self.argv) + seen = [] + + def send(**kw): + seen.append(kw["body"]) + if len(seen) == 1: + raise api.APIError(503, "busy") + return b'{"text":"known"}' + + with ( + mock.patch.object(api, "http_request", side_effect=send), + mock.patch.object(batch.time, "sleep"), + ): + args.retries = 2 + batch.transcribe(args, self.src) + self.assertEqual(len(seen), 2) + self.assertTrue(all(self.src.read_bytes() in body for body in seen)) + + def test_busy_retry_honors_server_delay_and_stops_on_long_delay(self): + op = mock.Mock(side_effect=[api.APIError(503, "busy", "7"), b"ok"]) + with mock.patch.object(batch.time, "sleep") as sleep: + batch.call_with_busy_retry(op, 2) + sleep.assert_called_once_with(7) + op = mock.Mock(side_effect=api.APIError(503, "busy", "120")) + with ( + mock.patch.object(batch.time, "sleep") as sleep, + self.assertRaises(api.APIError), + ): + batch.call_with_busy_retry(op, 2) + op.assert_called_once() + sleep.assert_not_called() + + def test_collision_overlap_and_missing_file_fail_before_inference(self): + args = batch.parse_args(self.argv) + (self.root / "inputs" / "source.mp3").write_bytes(b"compressed") + with self.assertRaises(ValueError): + batch.prepare(args) + (self.root / "inputs" / "source.mp3").unlink() + args.output_dir = str(self.root / "inputs") + with self.assertRaises(ValueError): + batch.prepare(args) + args.output_dir = str(self.root / "out") + args.reference_audio = str(self.root / "missing.wav") + with self.assertRaises(ValueError): + batch.prepare(args) + self.net.assert_not_called() + + def test_vox_batch_does_not_send_qwen_language(self): + args = batch.parse_args(self.argv + ["--provider", "voxcpm2"]) + with mock.patch.object(api, "post_multipart", return_value=wav()) as send: + batch.clone(args, "reference words", "new text") + self.assertNotIn("language", send.call_args.args[2]) + self.assertEqual(send.call_args.args[2]["ref_text"], "reference words") + + def test_wav_no_gain_export_preserves_bytes_without_ffmpeg(self): + raw = self.root / "raw.wav" + out = self.root / "out.wav" + raw.write_bytes(wav()) + with mock.patch.object(batch.subprocess, "run") as run: + batch.export_audio(raw, out, 0, "wav") + run.assert_not_called() + self.assertEqual(out.read_bytes(), raw.read_bytes()) + + def test_failed_conversion_leaves_no_partial_final_and_preserves_old_output(self): + raw = self.root / "raw.wav" + raw.write_bytes(wav()) + output = self.root / "out.mp3" + + def failed(command, **kwargs): + Path(command[-1]).write_bytes(b"partial invalid MP3") + return mock.Mock(returncode=1) + + with ( + mock.patch.object(batch.subprocess, "run", side_effect=failed), + self.assertRaises(RuntimeError), + ): + batch.export_audio(raw, output, 0, "mp3") + self.assertFalse(output.exists()) + self.assertEqual(list(self.root.glob(".audio-export-*")), []) + output.write_bytes(b"existing completed output") + with ( + mock.patch.object(batch.subprocess, "run", side_effect=failed), + self.assertRaises(RuntimeError), + ): + batch.export_audio(raw, output, 0, "mp3") + self.assertEqual(output.read_bytes(), b"existing completed output") + + def test_failed_conversion_is_not_skipped_on_manual_rerun(self): + def failed(command, **kwargs): + Path(command[-1]).write_bytes(b"partial MP3") + return mock.Mock(returncode=1) + + argv = self.argv + ["--reference-text", "known", "--format", "mp3"] + with ( + mock.patch.object( + batch, "transcribe", return_value={"text": "known"} + ) as transcribe, + mock.patch.object(batch, "clone", return_value=wav()), + mock.patch.object(batch.subprocess, "run", side_effect=failed), + contextlib.redirect_stdout(io.StringIO()), + ): + self.assertEqual(batch.main(argv), 1) + self.assertFalse((self.root / "out" / "source.mp3").exists()) + self.assertEqual(batch.main(argv), 1) + self.assertEqual(transcribe.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json b/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json index 034d8b9e..50bf2d05 100644 --- a/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json @@ -1,39 +1,35 @@ { "name": "find-skills", "version": "0.1.0", - "description": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。", + "description": "从 Vercel Skills 生态发现和安装适合任务的 Agent Skills。", "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" + "name": "Vercel", + "url": "https://github.com/vercel-labs" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", - "license": "Proprietary", - "keywords": [ - "eapil", - "codex-skill", - "find-skills" - ], + "homepage": "https://github.com/vercel-labs/skills", + "repository": "https://github.com/vercel-labs/skills", + "license": "MIT", + "keywords": [], "skills": "./skills/", "interface": { "displayName": "find-skills", - "shortDescription": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。", - "longDescription": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。", - "developerName": "EAPIL", + "shortDescription": "查找并选择适合任务的 Agent Skills。", + "longDescription": "跟随 vercel-labs/skills 的 find-skills 原始技能,使用 Skills CLI 搜索并核对来源。需要 Node.js 和 npx。", + "developerName": "Vercel", "category": "Productivity", "capabilities": [ "Read", "Write" ], "defaultPrompt": [ - "使用 find-skills 帮我完成这个任务。" + "使用 find-skills 查找适合当前任务的技能。" ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "websiteURL": "https://github.com/vercel-labs/skills", + "privacyPolicyURL": "https://github.com/vercel-labs/skills", + "termsOfServiceURL": "https://github.com/vercel-labs/skills", "brandColor": "#2563EB", "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" } } diff --git a/plugins/codex/plugins/find-skills/LICENSE b/plugins/codex/plugins/find-skills/LICENSE new file mode 100644 index 00000000..eabe3008 --- /dev/null +++ b/plugins/codex/plugins/find-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..b968644e --- /dev/null +++ b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "find-skills", + "repo": "https://github.com/vercel-labs/skills.git", + "ref": "main", + "commit": "14cf84aa922cccc7e5b11fee5af9a01b3750fddf", + "adapter": "claude-skill", + "sourcePath": "skills/find-skills", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md b/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md index 114c6637..5caf394a 100644 --- a/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md +++ b/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md @@ -1,6 +1,6 @@ --- name: find-skills -description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill. +description: "当用户想查找、选择或安装现成 Agent Skill 时,搜索技能生态并核对来源与适用性。" --- # Find Skills @@ -24,9 +24,8 @@ The Skills CLI (`npx skills`) is the package manager for the open agent skills e **Key commands:** -- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills find [query] [--owner ]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner - `npx skills add ` - Install a skill from GitHub or other sources -- `npx skills check` - Check for skill updates - `npx skills update` - Update all installed skills **Browse skills at:** https://skills.sh/ @@ -54,7 +53,7 @@ For example, top skills for web development include: If the leaderboard doesn't cover the user's need, run the find command: ```bash -npx skills find [query] +npx skills find [query] [--owner ] ``` For example: diff --git a/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json b/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json index f2e2f223..675dec41 100644 --- a/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json @@ -1,13 +1,13 @@ { "name": "generate-agents-md", - "version": "0.1.0", - "description": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。", + "version": "1.0.0", + "description": "创建、更新或审查 AGENTS.md;基于仓库事实生成精炼、准确的 Agent 指南。", "author": { "name": "EAPIL", "url": "https://git.playones.com/arechen/EapilSkillMarket" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/generate-agents-md", + "repository": "https://git.playones.com/chenmingxuan/ManySkills", "license": "Proprietary", "keywords": [ "eapil", @@ -17,8 +17,8 @@ "skills": "./skills/", "interface": { "displayName": "AGENTS.md生成器", - "shortDescription": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。", - "longDescription": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。", + "shortDescription": "基于仓库事实创建、更新或审查精炼准确的 AGENTS.md。", + "longDescription": "为 Codex 创建或维护 AGENTS.md,保留项目特有约束、明确命令工作目录、按需引用资料,并提供机械校验。其他 Agent 的兼容规则按需核对。", "developerName": "EAPIL", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..2516eb95 --- /dev/null +++ b/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "generate-agents-md", + "repo": "https://git.playones.com/chenmingxuan/ManySkills.git", + "ref": "v1.0.0", + "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d", + "adapter": "codex-plugin", + "sourcePath": "plugins/generate-agents-md", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md index 08d851a3..51ffa075 100644 --- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md +++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md @@ -1,67 +1,28 @@ --- name: generate-agents-md -description: Use when Codex needs to create, refresh, standardize, audit, or migrate AGENTS.md files or repository-level AI agent instructions, including root or nested guides, project onboarding context, Claude/Copilot/Cursor compatibility, and context-bloat cleanup. +description: Create, revise, or audit AGENTS.md repository instructions when explicitly requested. --- # Generate AGENTS.md -## Overview +Produce a concise guide that changes the agent's decisions: verified commands with working directories, non-obvious constraints, and links to context needed for specific tasks. Use the project's language and structure; no required headings or line count. -Create lean, scoped, verifiable `AGENTS.md` files from repository facts. Treat root `AGENTS.md` as always-on context: a map and guardrail, not an encyclopedia. +## Approach -## Workflow +Inspect the existing guide and the repository evidence relevant to the request. A small edit needs a small investigation; a new guide may need manifests, CI, and contributor docs. Preserve useful user rules. Remove stale or redundant guidance when the evidence supports it; surface unresolved conflicts rather than inventing facts or permissions. -1. **Scan facts before drafting.** Inspect existing `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, tool rules, manifests, CI, key directories, and docs. Use `rg --files` first. Record unknowns instead of guessing. -2. **Extract only verifiable facts.** Commands must come from files such as `package.json`, `Makefile`, `pyproject.toml`, `Cargo.toml`, `go.mod`, CI workflows, or repo docs. Paths must exist. Omit any command, path, or section that cannot be verified; report the gap in the final response instead of adding placeholders. -3. **Choose the scope model.** - - Use one root file for small repos with shared commands and conventions. - - Use root plus child `AGENTS.md` files for monorepos or directories with distinct commands, risks, or conventions. - - Keep complex task workflows in skills or references, not in always-on `AGENTS.md`. -4. **Draft with the standard contract.** Read `references/agents-md-reference.md` for root and child templates, section guidance, and validation criteria. -5. **Update conservatively.** If `AGENTS.md` already exists, merge rather than replace. Preserve user-written sections unless they are clearly stale or conflict with verified facts. Show conflicts before changing them when possible. -6. **Validate before finishing.** Run the bundled validator when applicable. Use `--local` for child guides, and use `--warnings-as-errors` only when the user or repo explicitly wants strict gating: +Keep shared rules at the root. Add a child guide only when that area needs distinct instructions. Link long procedures with their read conditions. Do not copy a repository map, formatter rules, or generic engineering advice into always-loaded context. + +For a new guide, scope changes, command ambiguity, or a substantial audit, consult [the reference](references/agents-md-reference.md). It covers evidence, Codex loading, optional content, and validation limits. Ordinary wording fixes do not need the full reference. + +## Checks and delivery + +Review whether each retained instruction is accurate, scoped, useful, and consistent with the user's authorization. Check only what the change requires. For mechanical checks, run the bundled helper without executing the guide's commands: ```bash -python path/to/generate-agents-md/scripts/validate_agents_md.py --repo /path/to/repo --file /path/to/repo/AGENTS.md +python scripts/validate_agents_md.py --repo /path/to/repo --file /path/to/repo/AGENTS.md ``` -Treat validator warnings as review prompts. Do not add unverifiable sections just to silence a warning. Also run repo-native markdown/prose checks if they exist. +Resolve the script relative to this skill directory. Use `--local` for a child guide whose inline paths and commands are local; use `--cwd` when they refer to another directory. Markdown links are always relative to the guide. Warnings require judgment; use `--warnings-as-errors` only for an explicitly strict check. -## Content Placement - -| Content | Put it in | -|---|---| -| Global project purpose, key paths, commands, completion checks, risk boundaries | Root `AGENTS.md` | -| Directory-specific commands, naming, tests, generated files, local risks | Child `AGENTS.md` | -| Long release/migration/audit workflows, repeated procedures, tool recipes | Skill or reference file | -| Human contributor background, product explanation, broad docs | `README.md` or `docs/` | - -## Quality Bar - -- Keep root files concise, usually under 200 lines. -- Use exact commands, exact paths, and concrete rules. -- Keep recommended sections only when they contain verified content. -- Prefer `Always`, `Ask first`, and `Never` boundaries for risk. -- State user instructions override repo guidance; child files override parent files for their directory. -- Do not include secrets, internal credentials, or unverifiable environment assumptions. -- Do not invent commands, paths, tests, package managers, ownership, or architecture. - -## Final Response - -Report: - -- Files created or updated. -- Evidence used for commands and key paths. -- Validation commands run and results. -- Unknowns or items that need human confirmation. - -## Common Mistakes - -| Mistake | Fix | -|---|---| -| Root file repeats docs or long workflows | Move detail to child guides, skills, or references | -| Commands are tool names instead of runnable commands | Use exact invocations like `pnpm test` or `make check` | -| Child guide repeats parent rules | Keep only local differences | -| Existing custom guidance is overwritten | Merge and preserve non-conflicting user content | -| Placeholder text remains | Replace with verified facts or write `Unknown` in review notes, not in final guide | -| Validator warnings are treated as required content | Investigate warnings, but omit sections that lack verified facts | +Report changed files, relevant evidence, checks performed, and any remaining uncertainty. Passing the helper does not prove command execution, instruction loading, or improved agent performance. diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml index 3e82ca99..983ce2c8 100644 --- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml +++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Generate AGENTS.md" - short_description: "Create lean project agent guides" - default_prompt: "Use $generate-agents-md to create a lean, verified AGENTS.md for this repository." + short_description: "Create or audit concise repository instructions" + default_prompt: "Use $generate-agents-md to create or revise this repository's AGENTS.md using verified facts and task-specific context." diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md index 54a46878..452cb2da 100644 --- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md +++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md @@ -1,134 +1,80 @@ -# AGENTS.md Reference +# AGENTS.md reference -Use this reference when drafting, updating, or auditing `AGENTS.md`. +Use for creation, substantial audits, scope changes, or ambiguous commands. Small wording edits can stay in the entrypoint. -## Fact Sources +## Evidence and content selection -| Need | Prefer these sources | -|---|---| -| Package manager and scripts | `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`, `bun.lockb` | -| Python commands | `pyproject.toml`, `uv.lock`, `poetry.lock`, `requirements*.txt`, `tox.ini`, `noxfile.py` | -| Go/Rust/.NET/Java commands | `go.mod`, `Cargo.toml`, `*.csproj`, `pom.xml`, `build.gradle*` | -| CI expectations | `.github/workflows/*`, `.gitlab-ci.yml`, `azure-pipelines.yml`, `Jenkinsfile` | -| Architecture and boundaries | `README.md`, `CONTRIBUTING.md`, `docs/`, existing instruction files | -| Generated or dangerous paths | `.gitignore`, generator configs, migration dirs, infra dirs, lockfiles | +Keep facts that change decisions and are costly to rediscover. Verify commands against the relevant package manifest, build file, CI, or maintained contributor docs; state their working directory and required environment. A listed script exists is weaker evidence than a successful run. If running a command requires unavailable services or credentials, disclose that limit instead of claiming validation. -Never promote a guessed command or path into final `AGENTS.md`. If a fact cannot be confirmed, omit it or list it as a final-response unknown. +Useful content, when supported: -## Scope Decision +- A surprising service boundary, generated-file rule, data invariant, or environment trap. +- The shortest relevant setup or verification command and its working directory. +- A task-conditioned pointer to maintained architecture, schema, or release documentation. +- A project-specific completion condition or authorization boundary. -Choose **root only** when: +Omit obvious directory inventories, generic advice, redundant style rules enforced by tools, and facts that are readily read from code. Unverified facts belong in the handoff, not a fill-in template. AGENTS.md is ordinary Markdown: headings and length are choices, not correctness criteria. -- The repo has one main stack and one set of commands. -- Directory conventions are uniform. -- A concise root guide can cover common tasks without local exceptions. +## Scope and loading -Choose **root plus child guides** when: +Choose one root guide for shared instructions. Add a child guide when its directory has distinct commands or constraints; keep only the local differences. State whether commands run from the repository root or the child directory. Do not duplicate the parent's full text. -- The repo is a monorepo or has separate apps, services, docs, packages, or examples. -- Commands differ by directory. -- Generated files, migrations, security code, or public API contracts are localized. -- Parent guidance would become long because of local exceptions. +Codex discovers instructions at startup. Its documented chain includes the global Codex home and project directories from the repository root toward the current working directory. At each level it selects the first applicable file: AGENTS.override.md, AGENTS.md, then configured fallback names. Later, deeper instructions take precedence for conflicts. An override does not concatenate with AGENTS.md in the same directory. Non-empty files form a combined instruction chain subject to a configurable size limit. -Root files should orient. Child files should specialize. +This discovery rule does not guarantee that a newly created child guide will be loaded merely because a running agent edits a file there. Check the target working directory, active session, and host behavior when loading matters. After changing instructions, use a fresh session to verify which files it actually receives; use the target host's current documentation for configuration limits. -## Root Template +Do not claim an AGENTS.md can override system or developer instructions. Preserve explicit user requirements and established authorization. Host-specific rules belong in a compatibility note only when the user asks for that host. For Claude, Cursor, or Copilot migration, check that host's current primary documentation before adding loader files, imports, or symlinks; do not rename or duplicate guides by default. + +## Editing and boundaries + +Preserve the user's valid conventions and language. Remove a rule only when it is obsolete, redundant, contradicted by verified facts, or covered by the requested cleanup. Surface conflicts whose intended resolution cannot be established. + +Replace unconditional reading lists with conditions: read architecture notes for service-boundary changes, schema notes for schema work, and release notes for a release. Keep a fixed sequence only where deviation breaks correctness or a fragile operation. + +Describe concrete effects and authorization boundaries. Do not add an approval checklist for every dependency, API, CI, or schema edit. State safe autonomous actions only when their safety is verified; a fixture being disposable or a command having no production access must come from evidence. Keep genuinely necessary data, credential, and deployment constraints. + +## Small examples + +These illustrate selection, not a mandatory template: ```markdown -# Project Agent Guide +# Project instructions -## Project Overview -[1-3 factual sentences: project type, primary stack, most important working constraint.] - -## Scope -- This file applies to the whole repository. -- More specific `AGENTS.md` files in subdirectories override this file for their paths. -- User instructions in the current task override repository guidance. - -## Quick Commands -- Install: `[exact command]` -- Dev: `[exact command]` -- Test: `[exact command]` -- Lint: `[exact command]` -- Build: `[exact command]` -- Typecheck: `[exact command]` - -## Key Paths -- `path/` - purpose -- `path/file.ext` - purpose - -## Architecture Boundaries -- [Concrete boundary or dependency rule.] -- [High-risk area and why it matters.] - -## Conventions -- [Style, naming, formatting, import, or documentation rule that is specific to this repo.] -- [Testing expectation that is actually supported by the repo.] - -## Definition of Done -- Run `[exact command]` before finishing changes that touch [scope]. -- If a required command cannot run, report the reason and the expected follow-up. - -## Boundaries -- Always: [concrete safe behavior] -- Ask first: [dependency, schema, CI, public API, security, data migration, billing, destructive operation] -- Never: [secrets, generated/vendor edits, broad refactors without request] - -## Child Guides -- `subsystem/AGENTS.md` - local rules for [subsystem] - -## References -- `docs/file.md` - when to read it +Run frontend checks from apps/web with pnpm run check. +Database changes use the transaction rules in docs/database.md. +The schema client is generated; edit its source schema instead. ``` -Remove sections that have no verified content. Do not leave placeholder text. `Project Overview` and `Scope` usually belong in every root guide; the other template sections are recommended when the repository has facts to support them. +Only use commands and paths that exist in the target project. Link the real document using a path relative to the guide, and add a child guide only if its local rules justify one. -## Child Template +## Mechanical validation -```markdown -# Local Agent Guide +The dependency-free helper checks readable inputs, obvious unfinished prose markers, literal paths and Markdown links, and common package-script commands. It has no fixed heading requirements and never runs a documented command. -## Local Purpose -[What this directory owns. Keep it factual.] +- `--repo`: repository boundary. +- `--file`: guide path, resolved from the process working directory. +- `--local`: resolve inline paths and commands from the guide's directory instead of the repository root. +- `--cwd`: override that inline-path and command base; a relative value is resolved from the repository root. Markdown links still resolve from the guide's directory. +- `--warnings-as-errors`: optional strict exit status. Ordinary warnings are review prompts. -## Inherits -- Follow the nearest parent `AGENTS.md` unless this file gives a more specific local rule. +The command check reads only the nearest applicable package.json within the repository, not every package or dependency tree. It recognizes common npm/pnpm/yarn/bun script forms, rtk wrappers, explicit directory flags, and a simple leading cd. Workspace selectors, pipelines, shell expansion, and complex control flow are reported as unverified when recognized; they are not interpreted as a successful check. Run the appropriate project check separately when execution is authorized and useful. -## Local Commands -- Test: `[exact command]` -- Lint: `[exact command]` +It does not infer a command's directory from surrounding prose. Use the relevant `--cwd` or an explicit directory flag when checking that command. A root guide describing several package directories may need manual command checks; a warning is not proof that its instructions are wrong. Ambiguous slash-prefixed routes such as /billing are not treated as filesystem references. -## Local Conventions -- [Naming, code organization, generated files, docs, or test placement specific to this path.] +The helper cannot establish command safety, runtime behavior, instruction priority, conflicting prose, or whether the guide improves outcomes. Review those separately. A missing-path warning may refer to a generated artifact; confirm its purpose before changing the guide. -## Local Risks -- Ask first: [local high-risk changes] -- Never: [local forbidden edits] -``` +When changing the helper, run its regression suite from this skill directory: `python -B -m unittest discover -s tests -p 'test_*.py'`. -If a child directory has no distinct commands, omit `Local Commands` rather than inventing commands. Keep `Local Purpose` and `Inherits` in normal child guides; include `Local Conventions` and `Local Risks` only when there are local facts worth capturing. +## Behavioral acceptance -## Scoring Checklist +For a substantial rewrite, try a representative creation or audit task in an isolated repository. Check that it preserves user constraints, chooses the right scope, uses real commands, avoids invented sections, and gives a usable result. Compare old and new behavior only when both runs were actually captured. Text reduction alone is not evidence of faster or better work. -A strong guide passes these checks: +## Sources -- **Specific:** Every command and path is concrete. -- **Verified:** Commands and paths trace to repository files. -- **Scoped:** Root guidance stays global; child guidance contains only local differences. -- **No filler:** Missing recommended sections are acceptable when there is no verified content. -- **Short:** Root is usually under 200 lines and avoids long procedures. -- **Actionable:** A new agent can find the right files, run the right checks, and avoid high-risk edits. -- **Compatible:** It states parent/child precedence and user-instruction precedence. -- **Maintainable:** It avoids duplicated docs and points to references for deep background. +Reviewed 2026-09-30. These sources inform the guidance; their examples are not universal requirements. -## Audit Signals - -Flag these during updates: - -- `TODO`, `TBD`, `FIXME`, bracket placeholders, or template language. -- Commands that do not exist in manifests, Makefiles, CI, or docs. -- Paths that no longer exist. -- Duplicated or conflicting parent/child rules. -- Long release, migration, or audit procedures in the root guide. -- Tool-specific assumptions presented as cross-tool rules. -- Secrets, internal credentials, or sensitive environment details. +- [OpenAI: Rethinking skills and prompts for GPT-6 Astra](https://developers.openai.com/blog/rethinking-skills-and-prompts-for-gpt-6-astra), 2026-09-11: precise discovery, progressive disclosure, conditional reading, proportional boundaries. +- [OpenAI: Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md): Codex discovery and precedence; recheck for host-specific loading questions. +- [AGENTS.md](https://agents.md/): plain Markdown, optional sections, scoped instructions. +- [Anthropic: Claude Code best practices](https://code.claude.com/docs/en/best-practices): concise persistent context and observed behavior; not a Codex loader specification. +- [OpenAI: Testing Agent Skills Systematically with Evals](https://developers.openai.com/blog/eval-skills), 2026-01-22: observable outcomes and targeted evaluation. diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py index a1e2593d..13477c6c 100644 --- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py +++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py @@ -1,158 +1,213 @@ #!/usr/bin/env python3 -"""Lightweight AGENTS.md validator. - -Checks for common problems before an agent guide is treated as done: -missing recommended sections, placeholders, nonexistent referenced paths, and -package manager commands that do not map to package.json scripts. -""" +"""Conservative, dependency-free AGENTS.md checks; never execute guide commands.""" from __future__ import annotations import argparse import json import re +import shlex import sys from pathlib import Path +from urllib.parse import unquote - -PLACEHOLDER_RE = re.compile( - r"\b(TODO|TBD|FIXME|XXX|REVIEW_NEEDED)\b|" - r"\{\{[^}]+\}\}|" - r"<[^>\n]+>|" - r"\[[^\]\n]*(exact command|path|project|真实|项目类型|关键)[^\]\n]*\]", - re.IGNORECASE, -) - -CODE_SPAN_RE = re.compile(r"`([^`\n]+)`") -HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) - -ROOT_RECOMMENDED_HEADINGS = { - "Project Overview", - "Scope", - "Quick Commands", - "Key Paths", - "Definition of Done", - "Boundaries", -} - -LOCAL_RECOMMENDED_HEADINGS = { - "Local Purpose", - "Inherits", - "Local Conventions", - "Local Risks", -} - -PATH_HINT_RE = re.compile(r"(^\.{1,2}[\\/]|[\\/]|^[\w.-]+\.[A-Za-z0-9]+$)") -COMMAND_PREFIX_RE = re.compile( - r"^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)(?:\s|$)" +INLINE = re.compile(r"`([^`\n]+)`") +LINK = re.compile(r"!?\[[^\]\n]*\]\(\s*(<[^>\n]+>|[^\s)]+)(?:\s+[^)]*)?\)") +MARKER = re.compile( + r"^\s*(?:[-*+]\s+|#{1,6}\s+)?(?:TODO|TBD|FIXME|REVIEW_NEEDED)" + r"(?:\s*[::].*|\s*)$|\[TODO:[^\]\n]*\]", + re.IGNORECASE | re.MULTILINE, ) +SHELL_LANGS = {"bash", "sh", "shell", "zsh", "powershell", "pwsh", "ps1", "cmd", "bat", "console", ""} +MANAGERS = {"npm", "pnpm", "yarn", "bun"} +BUILTINS = {"install", "add", "remove", "exec", "dlx", "create", "init", "run", "publish", "version", "list", "audit", "help", "cache", "config"} -def load_package_scripts(repo: Path) -> dict[str, set[str]]: - scripts: dict[str, set[str]] = {} - for package_file in repo.rglob("package.json"): - if "node_modules" in package_file.parts: +def markdown_parts(text: str) -> tuple[str, list[str]]: + """Separate prose/inline snippets from fenced commands and HTML comments.""" + text = re.sub(r"", "", text, flags=re.DOTALL) + prose, commands = [], [] + marker, width, language = "", 0, "" + for line in text.splitlines(): + fence = re.match(r"^\s*(`{3,}|~{3,})(.*)$", line) + if fence: + run, info = fence.groups() + if not marker: + marker, width = run[0], len(run) + language = info.strip().lower() + elif run[0] == marker and len(run) >= width and not info.strip(): + marker = "" continue - try: - data = json.loads(package_file.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - package_scripts = data.get("scripts") - if isinstance(package_scripts, dict): - scripts[str(package_file.relative_to(repo))] = set(package_scripts) - return scripts + if marker: + if language in SHELL_LANGS and line.strip() and not line.lstrip().startswith("#"): + commands.append(line.strip()) + else: + prose.append(line) + plain = "\n".join(prose) + return plain, INLINE.findall(plain) + commands -def normalize_path_token(token: str) -> str | None: - if token.startswith(("http://", "https://", "$")): +def literal_path(token: str) -> str | None: + token = token.strip() + if re.match(r"^[a-z][a-z0-9+.-]*:", token, re.IGNORECASE) and not re.match(r"^[A-Za-z]:[\\/]", token): return None - if any(part in token for part in ("&&", "||", " --", " -")): + if any(char.isspace() for char in token) or any(char in token for char in "$~*?[]{}<>()|;&"): return None - if any(char in token for char in "*?[]"): - return None - if token in {"AGENTS.md", "CLAUDE.md", "README.md"}: - return token - if PATH_HINT_RE.search(token): - return token.rstrip("/") + token = re.sub(r"(?:#L\d+(?:-L\d+)?|:\d+(?::\d+)?(?:-\d+)?)$", "", token) + if token.startswith("/") and not re.search(r"\.[A-Za-z][A-Za-z0-9]*$", token): + return None # A bare /billing or /v1/users may be a route, not a file. + if "/" in token or "\\" in token or re.search(r"\.[A-Za-z][A-Za-z0-9]*$", token): + return token.replace("\\", "/") return None -def path_exists(repo: Path, token: str) -> bool: - candidate = (repo / token).resolve() +def path_warning(repo: Path, base: Path, token: str) -> str | None: + target = (base / token).resolve() + if not target.is_relative_to(repo): + return f"External path not verified: {token}" + if not target.exists(): + return f"Referenced path may not exist (base {base.relative_to(repo) or '.'}): {token}" + return None + + +def package_warning(command: str, repo: Path, base: Path) -> str | None: + """Check common script forms only. Unsupported shell/workspace semantics stay unverified.""" + command = command.removeprefix("$ ").strip() + cd = re.match(r"^cd\s+(\"[^\"]+\"|'[^']+'|\S+)\s*&&\s*(.+)$", command) + if cd: + base = (base / cd[1].strip("\"'")).resolve() + command = cd[2] try: - candidate.relative_to(repo.resolve()) + tokens = [word.strip("\"'") for word in shlex.split(command, posix=False)] except ValueError: - return False - return candidate.exists() + return f"Command quoting not verified: {command}" if re.search(r"\b(?:npm|pnpm|yarn|bun)\b", command) else None + if tokens[:1] == ["rtk"]: + tokens = tokens[1:] + if tokens[:1] == ["proxy"]: + tokens = tokens[1:] + if not tokens or tokens[0] not in MANAGERS: + return None + if re.search(r"[|;&$`]|\*|\?", command) or any(t.split("=")[0] in {"--workspace", "--workspaces", "-w", "--filter", "--recursive", "-r"} for t in tokens): + return f"Complex or workspace command not verified: {command}" + manager, args = tokens[0], tokens[1:] + cleaned, index = [], 0 + while index < len(args): + token = args[index] + flag = token.split("=", 1)[0] + if flag in {"--prefix", "--dir", "-C", "--cwd"}: + if "=" in token: + directory = token.split("=", 1)[1] + elif index + 1 < len(args): + index += 1 + directory = args[index] + else: + return f"Directory flag not verified: {command}" + base = (base / directory).resolve() + else: + cleaned.append(token) + index += 1 + words = [word for word in cleaned if not word.startswith("-")] + if not words: + return None + action = words[0] + if action in {"run", "run-script"}: + if len(words) < 2: + return None + script = words[1] + elif manager == "npm": + if action not in {"test", "start", "stop", "restart"}: + return None + script = action + elif manager == "bun" and action in {"build", "test"}: + return None # Bun has native build/test commands, not package-script aliases. + elif action not in BUILTINS: + script = action + else: + return None + if not base.is_relative_to(repo) or not base.is_dir(): + return f"Command working directory not verified: {base}" + package = None + for directory in (base, *base.parents): + candidate = directory / "package.json" + if candidate.is_file(): + package = candidate + break + if directory == repo: + break + if package is None: + return f"Package command not verified (no package.json from {base.relative_to(repo)}): {command}" + try: + data = json.loads(package.read_text(encoding="utf-8-sig")) + scripts = data.get("scripts", {}) + if not isinstance(scripts, dict): + scripts = {} + except (OSError, UnicodeError, ValueError, AttributeError) as exc: + return f"Cannot read {package.relative_to(repo)}: {exc}" + if script not in scripts: + if "--if-present" in cleaned: + return None + if manager == "npm" and action in {"start", "restart", "stop"}: + return f"npm lifecycle fallback not verified: {command}" + return f"Missing package script '{script}' in {package.relative_to(repo)}: {command}" + return None def validate(args: argparse.Namespace) -> tuple[list[str], list[str]]: - repo = Path(args.repo).resolve() - guide = Path(args.file).resolve() - errors: list[str] = [] - warnings: list[str] = [] - - if not repo.exists(): - return [f"Repository path does not exist: {repo}"], warnings - if not guide.exists(): - return [f"Guide file does not exist: {guide}"], warnings - - text = guide.read_text(encoding="utf-8") - headings = set(HEADING_RE.findall(text)) - - recommended = LOCAL_RECOMMENDED_HEADINGS if args.local else ROOT_RECOMMENDED_HEADINGS - missing = sorted(recommended - headings) - if missing: - warnings.append("Missing recommended headings: " + ", ".join(missing)) - - placeholders = sorted(set(match.group(0) for match in PLACEHOLDER_RE.finditer(text))) - if placeholders: - errors.append("Placeholder or review marker remains: " + ", ".join(placeholders[:10])) - - code_spans = CODE_SPAN_RE.findall(text) - for token in code_spans: - path_token = normalize_path_token(token.strip()) - if path_token and not path_exists(repo, path_token): - warnings.append(f"Referenced path may not exist: `{token}`") - - package_scripts = load_package_scripts(repo) - known_scripts = set().union(*package_scripts.values()) if package_scripts else set() - for token in code_spans: - match = COMMAND_PREFIX_RE.match(token.strip()) - if not match: + repo, guide = Path(args.repo).resolve(), Path(args.file).resolve() + if not repo.is_dir(): + return [f"Repository directory does not exist: {repo}"], [] + if not guide.is_file() or not guide.is_relative_to(repo): + return [f"Guide must be a file inside the repository: {guide}"], [] + base = guide.parent if args.local else repo + if getattr(args, "cwd", None): + base = (repo / args.cwd).resolve() + if not base.is_dir() or not base.is_relative_to(repo): + return [f"Working directory must exist inside the repository: {base}"], [] + try: + text = guide.read_text(encoding="utf-8-sig") + except (OSError, UnicodeError) as exc: + return [f"Cannot read guide: {exc}"], [] + prose, snippets = markdown_parts(text) + errors, warnings = [], [] + if not prose.strip() and not snippets: + errors.append("Guide has no instruction content") + for marker in MARKER.finditer(INLINE.sub("", prose)): + errors.append(f"Unfinished prose marker: {marker.group().strip()}") + for match in LINK.finditer(INLINE.sub("", prose)): + token = unquote(match[1].strip("<>").split("#", 1)[0]) + if not token or re.match(r"^[a-z][a-z0-9+.-]*:", token, re.IGNORECASE): continue - script_name = match.group(1) - if script_name in {"install", "add", "remove", "exec", "dlx", "create"}: - continue - if known_scripts and script_name not in known_scripts: - warnings.append( - f"Package command references missing package.json script: `{token}`" - ) - - return errors, warnings + warning = path_warning(repo, guide.parent, token) + if warning: + warnings.append(warning) + for snippet in snippets: + token = literal_path(snippet) + if token: + warning = path_warning(repo, base, token) + if warning: + warnings.append(warning) + warning = package_warning(snippet, repo, base) + if warning: + warnings.append(warning) + return list(dict.fromkeys(errors)), list(dict.fromkeys(warnings)) def main() -> int: - parser = argparse.ArgumentParser(description="Validate AGENTS.md guardrails.") - parser.add_argument("--repo", required=True, help="Repository root to validate against.") - parser.add_argument("--file", required=True, help="AGENTS.md file to validate.") - parser.add_argument("--local", action="store_true", help="Validate a child/local guide.") - parser.add_argument( - "--warnings-as-errors", - action="store_true", - help="Exit nonzero when warnings are present.", - ) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True, help="Repository boundary") + parser.add_argument("--file", required=True, help="Guide path, relative to process cwd") + parser.add_argument("--local", action="store_true", help="Inline paths/commands are relative to guide directory") + parser.add_argument("--cwd", help="Inline-path/command base, relative to repo or absolute") + parser.add_argument("--warnings-as-errors", action="store_true", help="Fail on heuristic warnings as well as errors") args = parser.parse_args() - errors, warnings = validate(args) for error in errors: print(f"ERROR: {error}", file=sys.stderr) for warning in warnings: print(f"WARNING: {warning}", file=sys.stderr) - if errors or (args.warnings_as_errors and warnings): return 1 - print("AGENTS.md validation passed") + print("Mechanical checks passed" + (" with warnings; review required" if warnings else "")) return 0 diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py new file mode 100644 index 00000000..96c1cbeb --- /dev/null +++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py @@ -0,0 +1,140 @@ +"""Behavioral regressions using isolated repositories; no third-party dependencies.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "validate_agents_md.py" +SPEC = importlib.util.spec_from_file_location("guide_validator", SCRIPT) +validator = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validator) + + +class GuideValidationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name).resolve() + self.guide = self.repo / "AGENTS.md" + + def write(self, relative, text): + path = self.repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def package(self, directory, scripts): + self.write(str(Path(directory) / "package.json"), json.dumps({"scripts": scripts})) + + def check(self, text, *, guide="AGENTS.md", local=False, cwd=None): + path = self.write(guide, text) + return validator.validate(argparse.Namespace(repo=str(self.repo), file=str(path), local=local, cwd=cwd)) + + def test_chinese_guide_has_no_heading_contract(self): + self.assertEqual(self.check("# 项目约定\n不要覆盖已有用户修改。\n"), ([], [])) + + def test_html_jsx_and_literal_markers_are_not_placeholders(self): + self.assertEqual(self.check("# Guide\n\nUse `` and `TODO` only in examples.\n"), ([], [])) + + def test_real_unfinished_marker_fails(self): + errors, warnings = self.check("# Guide\n- TODO: add the actual build command\n") + self.assertTrue(errors) + self.assertFalse(warnings) + + def test_empty_guide_fails(self): + self.assertTrue(self.check("")[0]) + + def test_command_and_line_reference_are_not_whole_paths(self): + self.write("scripts/check.py", "print('ok')\n") + self.write("src/main.py", "pass\n") + self.assertEqual(self.check("Run `python scripts/check.py`; see `src/main.py:42`."), ([], [])) + + def test_missing_literal_path_warns(self): + self.assertTrue(self.check("See `src/missing.py`.")[1]) + + def test_route_constraint_is_not_an_external_file_warning(self): + self.assertEqual(self.check("Keep route `/billing`; preserve `/v1/users`."), ([], [])) + + def test_child_paths_and_links_have_correct_bases(self): + self.write("apps/web/src/main.py", "pass\n") + self.write("docs/api.md", "# API\n") + self.assertEqual(self.check("Use `src/main.py` and [API](../../docs/api.md).", guide="apps/web/AGENTS.md", local=True), ([], [])) + + def test_explicit_cwd_does_not_move_markdown_links(self): + self.write("apps/web/src/main.py", "pass\n") + self.write("docs/api.md", "# API\n") + self.assertEqual(self.check("Use `src/main.py` and [API](docs/api.md).", cwd="apps/web"), ([], [])) + + def test_missing_markdown_link_and_url_encoded_spaces(self): + self.write("docs/My Guide.md", "# Details\n") + self.assertEqual(self.check("[Details](docs/My%20Guide.md#notes)"), ([], [])) + self.assertTrue(self.check("[Details](docs/missing.md)")[1]) + + def test_missing_package_script_cannot_borrow_from_sibling(self): + self.package("apps/a", {}) + self.package("apps/b", {"test": "echo ok"}) + errors, warnings = self.check("Run `npm run test`.", guide="apps/a/AGENTS.md", local=True) + self.assertFalse(errors) + self.assertEqual(len(warnings), 1) + self.assertIn("Missing package script", warnings[0]) + + def test_no_package_does_not_scan_dependency_tree(self): + self.package("node_modules/dependency", {"test": "echo ok"}) + errors, warnings = self.check("Run `npm run test`.") + self.assertFalse(errors) + self.assertIn("no package.json", warnings[0]) + + def test_fenced_commands_cd_wrappers_and_directory_flags(self): + self.package("apps/web", {"check": "echo ok"}) + commands = ["cd apps/web && npm run check", "rtk proxy pnpm -C apps/web run check", "npm --prefix=apps/web run check", "yarn --cwd apps/web check"] + for command in commands: + with self.subTest(command=command): + self.assertEqual(self.check("```sh\n" + command + "\n```\n"), ([], [])) + + def test_unsupported_workspace_or_pipeline_is_not_claimed_verified(self): + self.package(".", {"test": "echo ok"}) + for command in ["npm run test --workspace apps/web", "npm run test | cat"]: + with self.subTest(command=command): + self.assertIn("not verified", self.check("`" + command + "`")[1][0]) + + def test_validation_never_executes_documented_commands(self): + self.package(".", {"test": "python -c \"open('executed', 'w').close()\""}) + self.assertEqual(self.check("Run `npm run test`."), ([], [])) + self.assertFalse((self.repo / "executed").exists()) + + def test_bare_build_alias_and_bun_native_command_differ(self): + self.package(".", {}) + for manager in ["yarn", "pnpm"]: + with self.subTest(manager=manager): + self.assertIn("Missing package script", self.check("`" + manager + " build`")[1][0]) + self.assertEqual(self.check("`bun build src/index.ts`"), ([], [])) + + def test_optional_missing_script_and_malformed_package(self): + self.package(".", {}) + self.assertEqual(self.check("`npm run --if-present check`"), ([], [])) + self.write("package.json", "not json") + self.assertIn("Cannot read", self.check("`npm run check`")[1][0]) + + def test_outside_working_directory_fails(self): + self.assertTrue(self.check("# Guide\nKeep user work.\n", cwd="..")[0]) + + def test_cli_warning_and_strict_exit_status(self): + self.write("AGENTS.md", "See `missing.py`.\n") + command = [sys.executable, "-B", str(SCRIPT), "--repo", str(self.repo), "--file", str(self.guide)] + ordinary = subprocess.run(command, capture_output=True, text=True) + strict = subprocess.run(command + ["--warnings-as-errors"], capture_output=True, text=True) + self.assertEqual(ordinary.returncode, 0) + self.assertIn("WARNING", ordinary.stderr) + self.assertEqual(strict.returncode, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index b8900aea..c02fafeb 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-10-05T16:02:10Z" + "syncedAt": "2026-10-06T07:29:21Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index dc1c677b..68668f83 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "41215d77762375b4b5154b12cfbb4372a835f8a0", + "commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-05T16:00:00Z" + "syncedAt": "2026-10-06T07:26:41Z" } diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md new file mode 100644 index 00000000..4ea06954 --- /dev/null +++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md @@ -0,0 +1,90 @@ +--- +name: next-browser-initial-load-optimizer +description: > + Audit and reduce Next.js browser initial-load work. Use for slow route startup, + oversized client bundles, duplicate browser dependencies, or features that can + wait for interaction. +--- + +# Browser initial-load optimizer + +## 1. Set the scope + +Analyze the whole app by default, covering all routes and shared client dependencies. Narrow the scope only when the user specifies a route, dependency, feature or other subset. Choose **audit** or **fix** mode. Audit is the default: generate analyzer artifacts and report candidates while leaving application source, dependencies and lockfiles unchanged. Fix mode requires an explicit request to change the app. Ask before changing visible behavior, timing, compatibility or a trust boundary. For a route's static App Shell, use `next-cache-components-optimizer`; for navigation prefetch work, use `next-partial-prefetching-optimizer`. + +**Done:** the whole-app or user-specified scope, mode and intended behavior are recorded. A request to capture or export data is audit mode, not permission to fix. + +## 2. Capture and export a baseline + +Run from the app directory with its package manager. Read `next analyze --help` for capture options and `next analyze export --help` for replay options in the installed CLI. Reuse a selected saved snapshot, or capture with a distinctive name first; then export it through gzip at its default compression level. Keep before/after `.jsonl.gz` files separate. + +**Sandbox requirement:** If the agent’s sandbox blocks TCP port binding (as Codex’s does), it MUST run the `next analyze --output` capture outside the sandbox. Do not attempt the capture inside that sandbox, even though `--output` does not serve the analyzer UI. + +Run the pipeline in Bash: + +```bash +set -o pipefail +# Capture only when a new baseline is needed +pnpm exec next analyze --output --snapshot 'audit-before-unique-1' +pnpm exec next analyze export --snapshot 'audit-before-unique-1' | gzip > /tmp/analyze-app-before.jsonl.gz +``` + +`--output` builds and saves binary/UI artifacts without serving. `next analyze export` reads a saved snapshot without building; stdout is one typed JSON record per line, with errors on stderr. Omit `--route` for whole-app analysis; add it for a user-specified route. The route filter keeps the whole-app module graph, so scope it in the next step. + +For a custom `distDir`, replay with `--dist-dir ` (relative or absolute): capture loads the app config, replay does not. `--snapshot ` selects a retained name; omission selects the newest snapshot. Capture generates a unique timestamp name when omitted, and **replaces** an existing capture when an explicit name is reused. Use distinct before/after names, including names with spaces, to preserve both baselines. For interactive exploration, capture without `--output` to serve the UI; `next build --analyze` also produces replayable data. + +**Done:** the chosen capture is identified, export succeeded, and the baseline file, snapshot name and analysis scope are recorded. Check the whole pipeline's exit status before processing the compressed file: `pipefail` prevents gzip from hiding a failed export. Export validates one route at a time, so a failure can leave a partial archive even if `gzip -t` accepts it. Discard output when export or gzip fails. + +## 3. Interpret the evidence + +Resolve the schema from the app's **installed Next.js**, using its Node launcher: + +```bash +pnpm exec node -p "require.resolve('next/analyze/graph-v1.schema.json')" +``` + +Read its descriptions for record meanings, joins, attribution and coverage. Stream decompression with `gzip -dc /tmp/analyze-app-before.jsonl.gz` into a line-oriented analysis script rather than loading the whole dump into context. Use the schema to interpret client/server contributions across every route in scope and choose the metric. + +**Done:** the baseline's route-attributed client/server contributions and the metric are identified. Treat this as **build evidence**: claims about observed browser requests, timing or transfer savings need separate evidence. + +## 4. Explain a candidate + +Inspect actual project source and exact importers. Rank client-output contributions across the whole app, or within the user-specified scope, by attributed size, repetition, likely runtime cost, need before interaction and correctness risk. + +### Community → min-cut pass + +For broad audits seeking opportunities across multiple features or a large contributor list, run **community detection → target selection → directed min-cut → source validation** after the initial ranking. Read [Graph methods](references/graph-methods.md) before constructing the graph or running solvers. + +1. Rank detected communities by scoped client-output attribution and inspect their inbound importers. +2. Select large communities, or optional feature regions within them, that can plausibly wait for interaction. Record why each selected region is optional under the route's render conditions. +3. Run directed min-cuts from the verified client roots to those targets to find small sets of importer changes that could detach them. +4. Validate the cut edges against source and behavior constraints; account for each investigated target as a candidate, rejected cut or evidence gap. + +For a narrow audit of a named dependency or feature, use direct importer reasoning when it fully accounts for the relevant paths and proposed boundary; record why the paired pass adds no useful target discovery. If graph evidence or tooling blocks the pass, record the blocker and limit the conclusions accordingly. + +### Graph-evidence checklist + +Before proposing an edit, record these items for each candidate. Mark an inapplicable item with its reason; give missing evidence an explicit gap. + +- **Scope:** name the affected routes, snapshot, render conditions, client/server output class, exact target identities and metric. For reachability claims, identify every selected client root, including applicable client references. +- **Coverage:** summarize relevant unsupported outputs and absent group triggers, and their effect on the claim. Scope conclusions to the known subgraph when completeness is uncertain. +- **Attribution:** count each selected output contribution once. Record repeated-record handling and distinguish source paths from module identities; explain any mapping used for solver weights and preserve unknown weights as gaps. +- **Reachability:** for a lazy boundary, check **all** synchronous root-to-target paths, alternate importers and cycles, including a target that is itself a root. Record which paths the proposed boundary severs and which stay reachable. Verify async and erased type-only imports against source. +- **Source checks:** inspect import triggers, mount-time preloading, module side effects and shared routes. Record the conditions under which an async import executes. The graph establishes indexed reachability; an after snapshot verifies emitted outputs and attribution changes. +- **Method:** record the chosen method, its parameters and why it fits the candidate. + +For lazy interaction features, duplicate packages, server-rendered display work or other attributed assets, read the matching section in [Fix patterns](references/fix-patterns.md) before proposing an edit. + +**Done:** the paired pass and its target dispositions are recorded, or the narrow-scope skip reason or blocker is explicit. Every candidate has a completed checklist, exact source/importer, proposed edit and named behavior checks. Label heuristics as heuristics and qualify conclusions affected by evidence gaps; the report step completes **audit** mode. + +## 5. Verify one change — fix mode only + +Make one small, cohesive change. Capture/export an after snapshot with a new name and compare the **same analysis scope, output class and metric** with the baseline. For whole-app analysis, account for changes across all routes, including shared dependencies. Run relevant behavior tests and type-check; use `next-dev-loop` when verifying the edit in the running app. + +**Done:** retain the change only when the scoped metric improves and the intended behavior passes its checks. Revert a change that fails either condition. If checks are blocked, report the unverified edit and blocker rather than accepting it. Record the accepted after snapshot as the next baseline before another edit. + +## 6. Report and stop + +Audit reports candidates; fix reports accepted edits and any reverted or unverified attempts. Include snapshot names, scoped attribution/deltas, exact source/importer, behavior-check results and blockers. For a graph cut, include its route/render conditions, roots/target and unknowns. Distinguish source facts, build evidence, heuristics and separately observed runtime facts. + +**Done:** every scoped candidate or attempted edit is accounted for, with evidence or a stated gap. diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md new file mode 100644 index 00000000..6050f209 --- /dev/null +++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md @@ -0,0 +1,31 @@ +# Fix patterns + +Read the section matching the candidate before proposing a change. Each section ends with the evidence needed to take that candidate into the main workflow's edit/measure/behavior loop. + +## Lazy interaction feature + +For a large component such as an editor, chart or dialog needed after interaction, inspect its actual importer and confirm the feature is not already async. Add one lazy boundary with a stable placeholder, preserving loading/error states, keyboard/focus, direct visits and client/server behavior. + +Preload on intent only after measuring its benefit. Idle preloading can waste data/battery and compete with important requests. + +**Ready to test:** the synchronous paths that keep the feature reachable are accounted for, and each preserved behavior has a named check. + +## Duplicate browser dependency + +Verify the shipped browser versions and inspect the package-manager graph (`pnpm why` or `npm ls`). Distinguish duplicate shipped JavaScript from module variants and server/client copies. Prefer compatible direct-range or parent upgrades and dedupe before overrides. If resolving duplicates requires a major-version upgrade, verify API compatibility and behavior for every affected consumer. + +Check React/singleton peer constraints and hydration behavior. Recheck the resolved dependency graph and shipped browser code to ensure the change reduces duplication rather than introducing more duplicates through dependencies. + +**Ready to test:** the duplicated browser code is evidenced, the resolution change is compatible with every affected consumer, and checks cover peer/hydration behavior and the resulting shipped duplication. + +## Server-rendered display work + +For Markdown/MDX, parsers, registries or display-only work in a Client Component, establish whether it needs live editing, offline use or browser-only inputs. Move eligible display work to server rendering with a small interactive island, or keep a lazy parser for editing. Preserve sanitizer and authorization boundaries and verify hydration. + +**Ready to test:** the source work selected for relocation has no required browser-only behavior, and rendering, interaction, hydration and trust-boundary checks are named. + +## Other attributed assets + +Inspect polyfills, barrels, layout imports, CSS/fonts/media/WASM when the scoped attribution warrants it. Keep source/build attribution separate from observed requests: a generic asset source alone does not establish a browser request. + +**Ready to test:** the exact source/importer and output contribution explain the proposed edit, and its effects on shared routes and runtime side effects are covered. diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md new file mode 100644 index 00000000..9ffa1555 --- /dev/null +++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md @@ -0,0 +1,20 @@ +# Graph methods + +Use these methods for the main workflow's community → min-cut pass or when direct importer inspection needs graph analysis. Read the installed schema for field meanings; construct a route-scoped graph from exact identities and verified client roots. Missing or unknown evidence stays an explicit gap. + +## Clustering — find logical communities + +Use Louvain/Leiden or another community algorithm on the scoped, deduplicated ordinary import graph. Keep synchronous, asynchronous and traced relationships distinct. If symmetrizing direction, record the weights, resolution, seed and stability across runs. + +**Prioritize deferral candidates:** use scoped client-output attribution to rank communities whose features are optional for the selected route or needed only after interaction. Inspect their actual importers and behavior constraints before proposing a cut. + +**Done:** each proposed community has reproducible parameters, scoped attributed contributions and inspected project importers. Treat it as a candidate to investigate, not an emitted chunk or a valid client/server boundary. + +## Conditional min cut — detach a target + +1. **Define the scenario:** record the route/render conditions, verified client roots and heavy target. Use directed synchronous importer→imported edges and exact output→module memberships. +2. **Establish completeness:** check every relevant output's coverage and group triggers against route output membership. Account for unknown client roles, uncertain triggers and overlapping/cumulative group memberships. A group that cannot isolate one reference or an unsupported membership may supply another path. Output membership and group role establish build provenance, not cold-request scope. +3. **Choose the claim:** make a definitive cut only if every possibly relevant root→target path is accounted for. Otherwise report a cut of the **known subgraph** as provisional, identify the missing evidence and give a verification plan. +4. **Solve and check:** connect selected roots to a super-source and targets to a sink. Choose unit-edge or size/edit-cost capacities deliberately. Protect shared runtime and required initial UI edges with uncuttable capacities or equivalent constraints; record the protected edges and reasons. If no feasible cut remains, report that result. Condense strongly connected components if needed. Check that every synchronous path in the claimed scenario is severed, including alternate roots and cycles. + +**Done:** the cut records its scenario, graph scope, capacities, inspected importer edges and completeness limits. A mathematical cut is a proposed source change, not automatically a safe `import()` or a measured browser saving. Return to the main workflow's behavior checks before accepting a fix. diff --git a/plugins/codex/plugins/pdf/.codex-plugin/plugin.json b/plugins/codex/plugins/pdf/.codex-plugin/plugin.json index 60cb6d1f..f169459d 100644 --- a/plugins/codex/plugins/pdf/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/pdf/.codex-plugin/plugin.json @@ -1,39 +1,40 @@ { "name": "pdf", - "version": "1.0.2", - "description": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。", + "version": "2.0.0", + "description": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。", "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://github.com/anthropics/skills", + "repository": "https://github.com/anthropics/skills", "license": "Proprietary", "keywords": [ - "eapil", - "codex-skill", - "pdf" + "pdf", + "documents", + "zh-CN", + "windows" ], "skills": "./skills/", "interface": { "displayName": "pdf", - "shortDescription": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。", - "longDescription": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。", - "developerName": "EAPIL", + "shortDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。", + "longDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", "category": "Productivity", "capabilities": [ "Read", "Write" ], "defaultPrompt": [ - "使用 pdf 帮我完成这个任务。" + "使用 pdf 处理这份中文文档,并检查最终输出。" ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "websiteURL": "https://github.com/anthropics/skills", + "privacyPolicyURL": "https://github.com/anthropics/skills", + "termsOfServiceURL": "https://github.com/anthropics/skills", "brandColor": "#2563EB", "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" } } diff --git a/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..45c6e45d --- /dev/null +++ b/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "pdf", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "adapter": "claude-skill", + "sourcePath": "skills/pdf", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/pdf/skills/pdf/SKILL.md b/plugins/codex/plugins/pdf/skills/pdf/SKILL.md index b65c5f1a..4fa05ab3 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/SKILL.md +++ b/plugins/codex/plugins/pdf/skills/pdf/SKILL.md @@ -1,314 +1,35 @@ --- name: pdf -description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. Use when Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale, especially on Windows and with Chinese/CJK text. +description: 读取、创建、合并、拆分、旋转、OCR 或填写 PDF 表单,处理简体中文字体与页面预览;适用于 Windows PowerShell 和跨平台终端。 license: Proprietary. LICENSE.txt has complete terms --- -# PDF Processing Guide +# PDF 文档 -## Overview +先读取 [references/platform.md](references/platform.md),确认中文字体、Python 与实际需要的依赖。PDF 不等于扫描图片,先判断页面是否有可提取文字。 -This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions. +| 任务 | 工具 | +| --- | --- | +| 文本、页面预览、坐标检查 | PyMuPDF | +| 合并、拆分、旋转、元数据、加密 | pypdf | +| 表格提取 | pdfplumber,提取后核对表头、合并单元格与换行 | +| 新建中文 PDF | ReportLab 或可用的 Office/HTML 转 PDF 工具 | +| 扫描页 OCR | 本机可用的 OCR;Tesseract 中文需 `chi_sim` 数据 | -## Windows and Chinese-language defaults +## 中文与排版 -- Prefer quoted Windows paths: `"C:\docs\contract-zh.pdf"` -- In Windows PowerShell, set UTF-8 mode before Python PDF scripts: `$env:PYTHONUTF8='1'` -- For Chinese text extraction, validate output in UTF-8 files instead of relying only on console rendering -- For generated PDFs containing Chinese text, register and use CJK-capable fonts (`Microsoft YaHei`, `SimSun`, `Noto Sans CJK SC`) +新文档默认简体中文、A4;已有版式或用户指定版式优先。中文必须使用实际可用的中文字体。优先嵌入授权允许的字体;ReportLab 简单文档可注册 `UnicodeCIDFont('STSong-Light')`。复制文本不乱码、渲染无缺字都需检查。 -## Quick Start +OCR 只针对扫描页或提取失败的页面;中文可用 `chi_sim+eng`,未装语言包时报告缺失。OCR 结果需核对数字、单位、姓名与表格关系,不凭猜测补全。 -```python -from pypdf import PdfReader, PdfWriter +## 表单 -# Read a PDF -reader = PdfReader("document.pdf") -print(f"Pages: {len(reader.pages)}") +涉及填写时按需读取 [forms.md](forms.md),先判断是否有 AcroForm 字段。可填表单使用随附 `extract_form_field_info.py`、`fill_fillable_fields.py`;非可填表单按页面坐标叠加,并检查原内容与填写区域。不要把签名图片视作数字签名。 -# Extract text -text = "" -for page in reader.pages: - text += page.extract_text() +## 预览与检查 + +```powershell +python "$skill\scripts\convert_pdf_to_images.py" ".\输入 中文.pdf" ".\preview" ``` -## Python Libraries - -### pypdf - Basic Operations - -#### Merge PDFs -```python -from pypdf import PdfWriter, PdfReader - -writer = PdfWriter() -for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]: - reader = PdfReader(pdf_file) - for page in reader.pages: - writer.add_page(page) - -with open("merged.pdf", "wb") as output: - writer.write(output) -``` - -#### Split PDF -```python -reader = PdfReader("input.pdf") -for i, page in enumerate(reader.pages): - writer = PdfWriter() - writer.add_page(page) - with open(f"page_{i+1}.pdf", "wb") as output: - writer.write(output) -``` - -#### Extract Metadata -```python -reader = PdfReader("document.pdf") -meta = reader.metadata -print(f"Title: {meta.title}") -print(f"Author: {meta.author}") -print(f"Subject: {meta.subject}") -print(f"Creator: {meta.creator}") -``` - -#### Rotate Pages -```python -reader = PdfReader("input.pdf") -writer = PdfWriter() - -page = reader.pages[0] -page.rotate(90) # Rotate 90 degrees clockwise -writer.add_page(page) - -with open("rotated.pdf", "wb") as output: - writer.write(output) -``` - -### pdfplumber - Text and Table Extraction - -#### Extract Text with Layout -```python -import pdfplumber - -with pdfplumber.open("document.pdf") as pdf: - for page in pdf.pages: - text = page.extract_text() - print(text) -``` - -#### Extract Tables -```python -with pdfplumber.open("document.pdf") as pdf: - for i, page in enumerate(pdf.pages): - tables = page.extract_tables() - for j, table in enumerate(tables): - print(f"Table {j+1} on page {i+1}:") - for row in table: - print(row) -``` - -#### Advanced Table Extraction -```python -import pandas as pd - -with pdfplumber.open("document.pdf") as pdf: - all_tables = [] - for page in pdf.pages: - tables = page.extract_tables() - for table in tables: - if table: # Check if table is not empty - df = pd.DataFrame(table[1:], columns=table[0]) - all_tables.append(df) - -# Combine all tables -if all_tables: - combined_df = pd.concat(all_tables, ignore_index=True) - combined_df.to_excel("extracted_tables.xlsx", index=False) -``` - -### reportlab - Create PDFs - -#### Chinese Font Setup (ReportLab) -```python -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont - -pdfmetrics.registerFont(TTFont('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc')) - -# Then use: canvas.setFont('MicrosoftYaHei', 11) -``` - -#### Basic PDF Creation -```python -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas - -c = canvas.Canvas("hello.pdf", pagesize=letter) -width, height = letter - -# Add text -c.drawString(100, height - 100, "Hello World!") -c.drawString(100, height - 120, "This is a PDF created with reportlab") - -# Add a line -c.line(100, height - 140, 400, height - 140) - -# Save -c.save() -``` - -#### Create PDF with Multiple Pages -```python -from reportlab.lib.pagesizes import letter -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak -from reportlab.lib.styles import getSampleStyleSheet - -doc = SimpleDocTemplate("report.pdf", pagesize=letter) -styles = getSampleStyleSheet() -story = [] - -# Add content -title = Paragraph("Report Title", styles['Title']) -story.append(title) -story.append(Spacer(1, 12)) - -body = Paragraph("This is the body of the report. " * 20, styles['Normal']) -story.append(body) -story.append(PageBreak()) - -# Page 2 -story.append(Paragraph("Page 2", styles['Heading1'])) -story.append(Paragraph("Content for page 2", styles['Normal'])) - -# Build PDF -doc.build(story) -``` - -## Command-Line Tools - -### pdftotext (poppler-utils) -```bash -# Extract text -pdftotext input.pdf output.txt - -# Extract text preserving layout -pdftotext -layout input.pdf output.txt - -# Force UTF-8 output encoding -pdftotext -enc UTF-8 input.pdf output.txt - -# Extract specific pages -pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5 -``` - -### qpdf -```bash -# Merge PDFs -qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf - -# Split pages -qpdf input.pdf --pages . 1-5 -- pages1-5.pdf -qpdf input.pdf --pages . 6-10 -- pages6-10.pdf - -# Rotate pages -qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees - -# Remove password -qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf -``` - -### pdftk (if available) -```bash -# Merge -pdftk file1.pdf file2.pdf cat output merged.pdf - -# Split -pdftk input.pdf burst - -# Rotate -pdftk input.pdf rotate 1east output rotated.pdf -``` - -## Common Tasks - -### Extract Text from Scanned PDFs -```python -# Requires: pip install pytesseract pdf2image -import pytesseract -from pdf2image import convert_from_path - -# Convert PDF to images -images = convert_from_path('scanned.pdf') - -# OCR each page -text = "" -for i, image in enumerate(images): - text += f"Page {i+1}:\n" - text += pytesseract.image_to_string(image, lang='chi_sim+eng') - text += "\n\n" - -print(text) -``` - -### Add Watermark -```python -from pypdf import PdfReader, PdfWriter - -# Create watermark (or load existing) -watermark = PdfReader("watermark.pdf").pages[0] - -# Apply to all pages -reader = PdfReader("document.pdf") -writer = PdfWriter() - -for page in reader.pages: - page.merge_page(watermark) - writer.add_page(page) - -with open("watermarked.pdf", "wb") as output: - writer.write(output) -``` - -### Extract Images -```bash -# Using pdfimages (poppler-utils) -pdfimages -j input.pdf output_prefix - -# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc. -``` - -### Password Protection -```python -from pypdf import PdfReader, PdfWriter - -reader = PdfReader("input.pdf") -writer = PdfWriter() - -for page in reader.pages: - writer.add_page(page) - -# Add password -writer.encrypt("userpassword", "ownerpassword") - -with open("encrypted.pdf", "wb") as output: - writer.write(output) -``` - -## Quick Reference - -| Task | Best Tool | Command/Code | -|------|-----------|--------------| -| Merge PDFs | pypdf | `writer.add_page(page)` | -| Split PDFs | pypdf | One page per file | -| Extract text | pdfplumber | `page.extract_text()` | -| Extract tables | pdfplumber | `page.extract_tables()` | -| Create PDFs | reportlab | Canvas or Platypus | -| Command line merge | qpdf | `qpdf --empty --pages ...` | -| OCR scanned PDFs | pytesseract | Convert to image first | -| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md | - -## Next Steps - -- For advanced pypdfium2 usage, see reference.md -- For JavaScript libraries (pdf-lib), see reference.md -- If you need to fill out a PDF form, follow the instructions in forms.md -- For troubleshooting guides, see reference.md +此脚本使用 PyMuPDF,无需 Poppler,逐页渲染以避免一次加载整个大文档。合并或拆分后核对页面数量和顺序;填写后检查可见内容、字段值与页面边界。更多提取与转换方案见 [reference.md](reference.md),旧平台专用命令以本地环境指导为准。 diff --git a/plugins/codex/plugins/pdf/skills/pdf/forms.md b/plugins/codex/plugins/pdf/skills/pdf/forms.md index 711cfe3d..6e7e1e0d 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/forms.md +++ b/plugins/codex/plugins/pdf/skills/pdf/forms.md @@ -3,12 +3,6 @@ If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions. -## Windows and Chinese-language defaults -- Prefer quoted paths on Windows (for example: `"C:\docs\input-form.pdf"`) -- In Windows PowerShell, enable UTF-8 mode before running scripts: `$env:PYTHONUTF8='1'` -- For Chinese text, visually verify the output PDF after filling (font fallback can affect spacing) -- If `python` points to Python 2 on Windows, use `py -3` - # Fillable fields If the PDF has fillable form fields: - Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format: @@ -82,130 +76,219 @@ Then analyze the images to determine the purpose of each form field (make sure t This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again. # Non-fillable fields -If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below. -- Convert the PDF to PNG images and determine field bounding boxes. -- Create a JSON file with field information and validation images showing the bounding boxes. -- Validate the the bounding boxes. -- Use the bounding boxes to fill in the form. +If the PDF doesn't have fillable form fields, you'll add text annotations. First try to extract coordinates from the PDF structure (more accurate), then fall back to visual estimation if needed. -## Step 1: Visual Analysis (REQUIRED) -- Convert the PDF to PNG images. Run this script from this file's directory: -`python scripts/convert_pdf_to_images.py ` -The script will create a PNG image for each page in the PDF. -- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text. +## Step 1: Try Structure Extraction First -These are some examples of form structures that you might see: +Run this script to extract text labels, lines, and checkboxes with their exact PDF coordinates: +`python scripts/extract_form_structure.py form_structure.json` -*Label inside box* -``` -┌────────────────────────┐ -│ Name: │ -└────────────────────────┘ -``` -The input area should be to the right of the "Name" label and extend to the edge of the box. +This creates a JSON file containing: +- **labels**: Every text element with exact coordinates (x0, top, x1, bottom in PDF points) +- **lines**: Horizontal lines that define row boundaries +- **checkboxes**: Small square rectangles that are checkboxes (with center coordinates) +- **row_boundaries**: Row top/bottom positions calculated from horizontal lines -*Label before line* -``` -Email: _______________________ -``` -The input area should be above the line and include its entire width. +**Check the results**: If `form_structure.json` has meaningful labels (text elements that correspond to form fields), use **Approach A: Structure-Based Coordinates**. If the PDF is scanned/image-based and has few or no labels, use **Approach B: Visual Estimation**. -*Label under line* -``` -_________________________ -Name -``` -The input area should be above the line and include the entire width of the line. This is common for signature and date fields. +--- -*Label above line* -``` -Please enter any special requests: -________________________________________________ -``` -The input area should extend from the bottom of the label to the line, and should include the entire width of the line. +## Approach A: Structure-Based Coordinates (Preferred) -*Checkboxes* -``` -Are you a US citizen? Yes □ No □ -``` -For checkboxes: -- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels. -- Distinguish between label text ("Yes", "No") and the clickable checkbox squares. -- The entry bounding box should cover ONLY the small square, not the text label. +Use this when `extract_form_structure.py` found text labels in the PDF. -### Step 2: Create fields.json and validation images (REQUIRED) -- Create a file named `fields.json` with information for the form fields and bounding boxes in this format: -``` +### A.1: Analyze the Structure + +Read form_structure.json and identify: + +1. **Label groups**: Adjacent text elements that form a single label (e.g., "Last" + "Name") +2. **Row structure**: Labels with similar `top` values are in the same row +3. **Field columns**: Entry areas start after label ends (x0 = label.x1 + gap) +4. **Checkboxes**: Use the checkbox coordinates directly from the structure + +**Coordinate system**: PDF coordinates where y=0 is at TOP of page, y increases downward. + +### A.2: Check for Missing Elements + +The structure extraction may not detect all form elements. Common cases: +- **Circular checkboxes**: Only square rectangles are detected as checkboxes +- **Complex graphics**: Decorative elements or non-standard form controls +- **Faded or light-colored elements**: May not be extracted + +If you see form fields in the PDF images that aren't in form_structure.json, you'll need to use **visual analysis** for those specific fields (see "Hybrid Approach" below). + +### A.3: Create fields.json with PDF Coordinates + +For each field, calculate entry coordinates from the extracted structure: + +**Text fields:** +- entry x0 = label x1 + 5 (small gap after label) +- entry x1 = next label's x0, or row boundary +- entry top = same as label top +- entry bottom = row boundary line below, or label bottom + row_height + +**Checkboxes:** +- Use the checkbox rectangle coordinates directly from form_structure.json +- entry_bounding_box = [checkbox.x0, checkbox.top, checkbox.x1, checkbox.bottom] + +Create fields.json using `pdf_width` and `pdf_height` (signals PDF coordinates): +```json { "pages": [ - { - "page_number": 1, - "image_width": (first page image width in pixels), - "image_height": (first page image height in pixels), - }, - { - "page_number": 2, - "image_width": (second page image width in pixels), - "image_height": (second page image height in pixels), - } - // additional pages + {"page_number": 1, "pdf_width": 612, "pdf_height": 792} ], "form_fields": [ - // Example for a text field. { "page_number": 1, - "description": "The user's last name should be entered here", - // Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap. - "field_label": "Last name", - "label_bounding_box": [30, 125, 95, 142], - "entry_bounding_box": [100, 125, 280, 142], - "entry_text": { - "text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location - "font_size": 14, // optional, defaults to 14 - "font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black) - } + "description": "Last name entry field", + "field_label": "Last Name", + "label_bounding_box": [43, 63, 87, 73], + "entry_bounding_box": [92, 63, 260, 79], + "entry_text": {"text": "Smith", "font_size": 10} }, - // Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT { - "page_number": 2, - "description": "Checkbox that should be checked if the user is over 18", - "entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square + "page_number": 1, + "description": "US Citizen Yes checkbox", "field_label": "Yes", - "label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text - // Use "X" to check a checkbox. - "entry_text": { - "text": "X", - } + "label_bounding_box": [260, 200, 280, 210], + "entry_bounding_box": [285, 197, 292, 205], + "entry_text": {"text": "X"} } - // additional form field entries ] } ``` -Create validation images by running this script from this file's directory for each page: -`python scripts/create_validation_image.py ` +**Important**: Use `pdf_width`/`pdf_height` and coordinates directly from form_structure.json. -The validation images will have red rectangles where text should be entered, and blue rectangles covering label text. +### A.4: Validate Bounding Boxes -### Step 3: Validate Bounding Boxes (REQUIRED) -#### Automated intersection check -- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the `check_bounding_boxes.py` script (run from this file's directory): -`python scripts/check_bounding_boxes.py ` +Before filling, check your bounding boxes for errors: +`python scripts/check_bounding_boxes.py fields.json` -If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not. +This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. -#### Manual image inspection -**CRITICAL: Do not proceed without visually inspecting validation images** -- Red rectangles must ONLY cover input areas -- Red rectangles MUST NOT contain any text -- Blue rectangles should contain label text -- For checkboxes: - - Red rectangle MUST be centered on the checkbox square - - Blue rectangle should cover the text label for the checkbox +--- -- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate. +## Approach B: Visual Estimation (Fallback) +Use this when the PDF is scanned/image-based and structure extraction found no usable text labels (e.g., all text shows as "(cid:X)" patterns). -### Step 4: Add annotations to the PDF -Run this script from this file's directory to create a filled-out PDF using the information in fields.json: -`python scripts/fill_pdf_form_with_annotations.py ` +### B.1: Convert PDF to Images + +`python scripts/convert_pdf_to_images.py ` + +### B.2: Initial Field Identification + +Examine each page image to identify form sections and get **rough estimates** of field locations: +- Form field labels and their approximate positions +- Entry areas (lines, boxes, or blank spaces for text input) +- Checkboxes and their approximate locations + +For each field, note approximate pixel coordinates (they don't need to be precise yet). + +### B.3: Zoom Refinement (CRITICAL for accuracy) + +For each field, crop a region around the estimated position to refine coordinates precisely. + +**Create a zoomed crop using ImageMagick:** +```bash +magick -crop x++ +repage +``` + +Where: +- `, ` = top-left corner of crop region (use your rough estimate minus padding) +- `, ` = size of crop region (field area plus ~50px padding on each side) + +**Example:** To refine a "Name" field estimated around (100, 150): +```bash +magick images_dir/page_1.png -crop 300x80+50+120 +repage crops/name_field.png +``` + +(Note: if the `magick` command isn't available, try `convert` with the same arguments). + +**Examine the cropped image** to determine precise coordinates: +1. Identify the exact pixel where the entry area begins (after the label) +2. Identify where the entry area ends (before next field or edge) +3. Identify the top and bottom of the entry line/box + +**Convert crop coordinates back to full image coordinates:** +- full_x = crop_x + crop_offset_x +- full_y = crop_y + crop_offset_y + +Example: If the crop started at (50, 120) and the entry box starts at (52, 18) within the crop: +- entry_x0 = 52 + 50 = 102 +- entry_top = 18 + 120 = 138 + +**Repeat for each field**, grouping nearby fields into single crops when possible. + +### B.4: Create fields.json with Refined Coordinates + +Create fields.json using `image_width` and `image_height` (signals image coordinates): +```json +{ + "pages": [ + {"page_number": 1, "image_width": 1700, "image_height": 2200} + ], + "form_fields": [ + { + "page_number": 1, + "description": "Last name entry field", + "field_label": "Last Name", + "label_bounding_box": [120, 175, 242, 198], + "entry_bounding_box": [255, 175, 720, 218], + "entry_text": {"text": "Smith", "font_size": 10} + } + ] +} +``` + +**Important**: Use `image_width`/`image_height` and the refined pixel coordinates from the zoom analysis. + +### B.5: Validate Bounding Boxes + +Before filling, check your bounding boxes for errors: +`python scripts/check_bounding_boxes.py fields.json` + +This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. + +--- + +## Hybrid Approach: Structure + Visual + +Use this when structure extraction works for most fields but misses some elements (e.g., circular checkboxes, unusual form controls). + +1. **Use Approach A** for fields that were detected in form_structure.json +2. **Convert PDF to images** for visual analysis of missing fields +3. **Use zoom refinement** (from Approach B) for the missing fields +4. **Combine coordinates**: For fields from structure extraction, use `pdf_width`/`pdf_height`. For visually-estimated fields, you must convert image coordinates to PDF coordinates: + - pdf_x = image_x * (pdf_width / image_width) + - pdf_y = image_y * (pdf_height / image_height) +5. **Use a single coordinate system** in fields.json - convert all to PDF coordinates with `pdf_width`/`pdf_height` + +--- + +## Step 2: Validate Before Filling + +**Always validate bounding boxes before filling:** +`python scripts/check_bounding_boxes.py fields.json` + +This checks for: +- Intersecting bounding boxes (which would cause overlapping text) +- Entry boxes that are too small for the specified font size + +Fix any reported errors in fields.json before proceeding. + +## Step 3: Fill the Form + +The fill script auto-detects the coordinate system and handles conversion: +`python scripts/fill_pdf_form_with_annotations.py fields.json ` + +## Step 4: Verify Output + +Convert the filled PDF to images and verify text placement: +`python scripts/convert_pdf_to_images.py ` + +If text is mispositioned: +- **Approach A**: Check that you're using PDF coordinates from form_structure.json with `pdf_width`/`pdf_height` +- **Approach B**: Check that image dimensions match and coordinates are accurate pixels +- **Hybrid**: Ensure coordinate conversions are correct for visually-estimated fields diff --git a/plugins/codex/plugins/pdf/skills/pdf/reference.md b/plugins/codex/plugins/pdf/skills/pdf/reference.md index b9e63bde..41400bf4 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/reference.md +++ b/plugins/codex/plugins/pdf/skills/pdf/reference.md @@ -2,12 +2,6 @@ This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions. -## Windows and Chinese-language defaults -- Quote Windows file paths in commands and scripts -- Prefer UTF-8 output files when validating extracted Chinese text -- For OCR on Chinese scans, use Tesseract language packs with `chi_sim+eng` -- For generated Chinese PDFs, use embedded CJK fonts instead of default Helvetica - ## pypdfium2 Library (Apache/BSD License) ### Overview @@ -144,29 +138,6 @@ async function createPDF() { } ``` -#### CJK Font Embedding (for Chinese text) -```javascript -import { PDFDocument } from 'pdf-lib'; -import fs from 'fs'; - -async function createChinesePDF() { - const pdfDoc = await PDFDocument.create(); - const page = pdfDoc.addPage([595, 842]); - - const fontBytes = fs.readFileSync('NotoSansCJKsc-Regular.otf'); - const cjkFont = await pdfDoc.embedFont(fontBytes); - - page.drawText('Chinese text sample', { - x: 50, - y: 760, - size: 14, - font: cjkFont - }); - - fs.writeFileSync('cjk-output.pdf', await pdfDoc.save()); -} -``` - #### Advanced Merge and Split Operations ```javascript import { PDFDocument } from 'pdf-lib'; @@ -300,9 +271,6 @@ async function extractAnnotations() { # Extract text with bounding box coordinates (essential for structured data) pdftotext -bbox-layout document.pdf output.xml -# Force UTF-8 text output when needed -pdftotext -enc UTF-8 document.pdf output.txt - # The XML output contains precise coordinates for each text element ``` @@ -628,7 +596,7 @@ def extract_text_with_ocr(pdf_path): images = convert_from_path(pdf_path) text = "" for i, image in enumerate(images): - text += pytesseract.image_to_string(image, lang='chi_sim+eng') + text += pytesseract.image_to_string(image) return text ``` diff --git a/plugins/codex/plugins/pdf/skills/pdf/references/platform.md b/plugins/codex/plugins/pdf/skills/pdf/references/platform.md new file mode 100644 index 00000000..c3bb862f --- /dev/null +++ b/plugins/codex/plugins/pdf/skills/pdf/references/platform.md @@ -0,0 +1,41 @@ +# 简体中文与 Windows 环境 + +以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。 + +```powershell +$env:PYTHONUTF8 = "1" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +python --version +node --version +``` + +若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。 + +需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。 + +## 字体与文本 + +- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。 +- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。 +- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。 +- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。 +- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。 + +## 转换与预览 + +Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。 + +```powershell +# 仅在自动检测不到时设置;不要求添加系统 PATH +$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx" +python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages" +``` + +按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。 + +使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。 + +## 验证与交付 + +生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。 diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py index 4154ef65..2cc5e348 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py @@ -3,8 +3,6 @@ import json import sys -# Script to check that the `fields.json` file that Claude creates when analyzing PDFs -# does not have overlapping bounding boxes. See forms.md. @dataclass @@ -14,7 +12,6 @@ class RectAndField: field: dict -# Returns a list of messages that are printed to stdout for Claude to read. def get_bounding_box_messages(fields_json_stream) -> list[str]: messages = [] fields = json.load(fields_json_stream) @@ -32,7 +29,6 @@ def get_bounding_box_messages(fields_json_stream) -> list[str]: has_error = False for i, ri in enumerate(rects_and_fields): - # This is O(N^2); we can optimize if it becomes a problem. for j in range(i + 1, len(rects_and_fields)): rj = rects_and_fields[j] if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect): @@ -63,8 +59,7 @@ if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: check_bounding_boxes.py [fields.json]") sys.exit(1) - # Input file should be in the `fields.json` format described in forms.md. - with open(sys.argv[1], encoding="utf-8") as f: + with open(sys.argv[1]) as f: messages = get_bounding_box_messages(f) for msg in messages: print(msg) diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py deleted file mode 100644 index 1dbb463c..00000000 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py +++ /dev/null @@ -1,226 +0,0 @@ -import unittest -import json -import io -from check_bounding_boxes import get_bounding_box_messages - - -# Currently this is not run automatically in CI; it's just for documentation and manual checking. -class TestGetBoundingBoxMessages(unittest.TestCase): - - def create_json_stream(self, data): - """Helper to create a JSON stream from data""" - return io.StringIO(json.dumps(data)) - - def test_no_intersections(self): - """Test case with no bounding box intersections""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 30] - }, - { - "description": "Email", - "page_number": 1, - "label_bounding_box": [10, 40, 50, 60], - "entry_bounding_box": [60, 40, 150, 60] - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("SUCCESS" in msg for msg in messages)) - self.assertFalse(any("FAILURE" in msg for msg in messages)) - - def test_label_entry_intersection_same_field(self): - """Test intersection between label and entry of the same field""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 60, 30], - "entry_bounding_box": [50, 10, 150, 30] # Overlaps with label - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) - self.assertFalse(any("SUCCESS" in msg for msg in messages)) - - def test_intersection_between_different_fields(self): - """Test intersection between bounding boxes of different fields""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 30] - }, - { - "description": "Email", - "page_number": 1, - "label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes - "entry_bounding_box": [160, 10, 250, 30] - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) - self.assertFalse(any("SUCCESS" in msg for msg in messages)) - - def test_different_pages_no_intersection(self): - """Test that boxes on different pages don't count as intersecting""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 30] - }, - { - "description": "Email", - "page_number": 2, - "label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page - "entry_bounding_box": [60, 10, 150, 30] - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("SUCCESS" in msg for msg in messages)) - self.assertFalse(any("FAILURE" in msg for msg in messages)) - - def test_entry_height_too_small(self): - """Test that entry box height is checked against font size""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 20], # Height is 10 - "entry_text": { - "font_size": 14 # Font size larger than height - } - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) - self.assertFalse(any("SUCCESS" in msg for msg in messages)) - - def test_entry_height_adequate(self): - """Test that adequate entry box height passes""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 30], # Height is 20 - "entry_text": { - "font_size": 14 # Font size smaller than height - } - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("SUCCESS" in msg for msg in messages)) - self.assertFalse(any("FAILURE" in msg for msg in messages)) - - def test_default_font_size(self): - """Test that default font size is used when not specified""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 20], # Height is 10 - "entry_text": {} # No font_size specified, should use default 14 - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) - self.assertFalse(any("SUCCESS" in msg for msg in messages)) - - def test_no_entry_text(self): - """Test that missing entry_text doesn't cause height check""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("SUCCESS" in msg for msg in messages)) - self.assertFalse(any("FAILURE" in msg for msg in messages)) - - def test_multiple_errors_limit(self): - """Test that error messages are limited to prevent excessive output""" - fields = [] - # Create many overlapping fields - for i in range(25): - fields.append({ - "description": f"Field{i}", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], # All overlap - "entry_bounding_box": [20, 15, 60, 35] # All overlap - }) - - data = {"form_fields": fields} - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - # Should abort after ~20 messages - self.assertTrue(any("Aborting" in msg for msg in messages)) - # Should have some FAILURE messages but not hundreds - failure_count = sum(1 for msg in messages if "FAILURE" in msg) - self.assertGreater(failure_count, 0) - self.assertLess(len(messages), 30) # Should be limited - - def test_edge_touching_boxes(self): - """Test that boxes touching at edges don't count as intersecting""" - data = { - "form_fields": [ - { - "description": "Name", - "page_number": 1, - "label_bounding_box": [10, 10, 50, 30], - "entry_bounding_box": [50, 10, 150, 30] # Touches at x=50 - } - ] - } - - stream = self.create_json_stream(data) - messages = get_bounding_box_messages(stream) - self.assertTrue(any("SUCCESS" in msg for msg in messages)) - self.assertFalse(any("FAILURE" in msg for msg in messages)) - - -if __name__ == '__main__': - unittest.main() diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py index df5c7b60..36dfb951 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py @@ -1,35 +1,11 @@ import sys +from pypdf import PdfReader -# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md. -def has_fillable_fields(pdf_path: str) -> bool: - try: - from pypdf import PdfReader - except ModuleNotFoundError as exc: - raise RuntimeError("Missing dependency: pypdf") from exc - - reader = PdfReader(pdf_path) - return bool(reader.get_fields()) - - -def main(argv: list[str] | None = None) -> int: - args = argv if argv is not None else sys.argv[1:] - if len(args) != 1: - print("Usage: check_fillable_fields.py [input pdf]") - return 1 - - try: - if has_fillable_fields(args[0]): - print("This PDF has fillable form fields") - else: - print("This PDF does not have fillable form fields; you will need to visually determine where to enter data") - return 0 - except RuntimeError as exc: - print(str(exc), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) +reader = PdfReader(sys.argv[1]) +if (reader.get_fields()): + print("This PDF has fillable form fields") +else: + print("This PDF does not have fillable form fields; you will need to visually determine where to enter data") diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py index 31fe382c..4820bbdc 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py @@ -1,55 +1,29 @@ -import sys +"""Render PDF pages without Poppler; requires PyMuPDF.""" + +import argparse from pathlib import Path -from pdf2image import convert_from_path -from pdf2image.exceptions import PDFInfoNotInstalledError +import pymupdf -# Converts each page of a PDF to a PNG image. - - -def convert(pdf_path, output_dir, max_dim=1000): - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - try: - images = convert_from_path(pdf_path, dpi=200) - except PDFInfoNotInstalledError: - raise RuntimeError( - "Poppler is required. Install Poppler and ensure `pdfinfo` is available in PATH." - ) - - for i, image in enumerate(images): - # Scale image if needed to keep width/height under `max_dim` - width, height = image.size - if width > max_dim or height > max_dim: - scale_factor = min(max_dim / width, max_dim / height) - new_width = int(width * scale_factor) - new_height = int(height * scale_factor) - image = image.resize((new_width, new_height)) - - image_path = output_path / f"page_{i+1}.png" - image.save(str(image_path)) - print(f"Saved page {i+1} as {image_path} (size: {image.size})") - - print(f"Converted {len(images)} pages to PNG images") - - -def main(argv: list[str] | None = None) -> int: - args = argv if argv is not None else sys.argv[1:] - if len(args) != 2: - print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") - return 1 - - pdf_path = args[0] - output_directory = args[1] - try: - convert(pdf_path, output_directory) - return 0 - except RuntimeError as exc: - print(str(exc), file=sys.stderr) - return 1 +def convert(pdf_path, output_dir, max_dim=1600): + if max_dim <= 0: + raise ValueError("max_dim must be positive") + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + with pymupdf.open(pdf_path) as document: + for index, page in enumerate(document): + scale = min(max_dim / page.rect.width, max_dim / page.rect.height) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False) + target = output / f"page_{index + 1}.png" + pixmap.save(str(target)) + print(target) if __name__ == "__main__": - raise SystemExit(main()) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--max-dim", type=int, default=1600) + args = parser.parse_args() + convert(args.input, args.output, args.max_dim) diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py index 683c3d91..10eadd81 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py @@ -4,32 +4,26 @@ import sys from PIL import Image, ImageDraw -# Creates "validation" images with rectangles for the bounding box information that -# Claude creates when determining where to add text annotations in PDFs. See forms.md. def create_validation_image(page_number, fields_json_path, input_path, output_path): - # Input file should be in the `fields.json` format described in forms.md. - with open(fields_json_path, "r", encoding="utf-8") as file_obj: - data = json.load(file_obj) + with open(fields_json_path, 'r') as f: + data = json.load(f) - with Image.open(input_path) as image: - draw = ImageDraw.Draw(image) + img = Image.open(input_path) + draw = ImageDraw.Draw(img) num_boxes = 0 - + for field in data["form_fields"]: - if field["page_number"] != page_number: - continue - entry_box = field["entry_bounding_box"] - label_box = field["label_bounding_box"] - # Draw red rectangle over entry bounding box and blue rectangle over the label. - draw.rectangle(entry_box, outline="red", width=2) - draw.rectangle(label_box, outline="blue", width=2) - num_boxes += 2 - - image.save(output_path) - - print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") + if field["page_number"] == page_number: + entry_box = field['entry_bounding_box'] + label_box = field['label_bounding_box'] + draw.rectangle(entry_box, outline='red', width=2) + draw.rectangle(label_box, outline='blue', width=2) + num_boxes += 2 + + img.save(output_path) + print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") if __name__ == "__main__": diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py index 88f40445..64cd4703 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py @@ -4,11 +4,8 @@ import sys from pypdf import PdfReader -# Extracts data for the fillable form fields in a PDF and outputs JSON that -# Claude uses to fill the fields. See forms.md. -# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods. def get_full_annotation_field_id(annotation): components = [] while annotation: @@ -25,12 +22,9 @@ def make_field_dict(field, field_id): if ft == "/Tx": field_dict["type"] = "text" elif ft == "/Btn": - field_dict["type"] = "checkbox" # radio groups handled separately + field_dict["type"] = "checkbox" states = field.get("/_States_", []) if len(states) == 2: - # "/Off" seems to always be the unchecked value, as suggested by - # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448 - # It can be either first or second in the "/_States_" list. if "/Off" in states: field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] field_dict["unchecked_value"] = "/Off" @@ -50,15 +44,6 @@ def make_field_dict(field, field_id): return field_dict -# Returns a list of fillable PDF fields: -# [ -# { -# "field_id": "name", -# "page": 1, -# "type": ("text", "checkbox", "radio_group", or "choice") -# // Per-type additional fields described in forms.md -# }, -# ] def get_field_info(reader: PdfReader): fields = reader.get_fields() @@ -66,19 +51,13 @@ def get_field_info(reader: PdfReader): possible_radio_names = set() for field_id, field in fields.items(): - # Skip if this is a container field with children, except that it might be - # a parent group for radio button options. if field.get("/Kids"): if field.get("/FT") == "/Btn": possible_radio_names.add(field_id) continue field_info_by_id[field_id] = make_field_dict(field, field_id) - # Bounding rects are stored in annotations in page objects. - # Radio button options have a separate annotation for each choice; - # all choices have the same field name. - # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html radio_fields_by_id = {} for page_index, page in enumerate(reader.pages): @@ -90,8 +69,6 @@ def get_field_info(reader: PdfReader): field_info_by_id[field_id]["rect"] = ann.get('/Rect') elif field_id in possible_radio_names: try: - # ann['/AP']['/N'] should have two items. One of them is '/Off', - # the other is the active value. on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] except KeyError: continue @@ -104,17 +81,11 @@ def get_field_info(reader: PdfReader): "page": page_index + 1, "radio_options": [], } - # Note: at least on macOS 15.7, Preview.app doesn't show selected - # radio buttons correctly. (It does if you remove the leading slash - # from the value, but that causes them not to appear correctly in - # Chrome/Firefox/Acrobat/etc). radio_fields_by_id[field_id]["radio_options"].append({ "value": on_values[0], "rect": rect, }) - # Some PDFs have form field definitions without corresponding annotations, - # so we can't tell where they are. Ignore these fields for now. fields_with_location = [] for field_info in field_info_by_id.values(): if "page" in field_info: @@ -122,7 +93,6 @@ def get_field_info(reader: PdfReader): else: print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") - # Sort by page number, then Y position (flipped in PDF coordinate system), then X. def sort_key(f): if "radio_options" in f: rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] @@ -140,8 +110,8 @@ def get_field_info(reader: PdfReader): def write_field_info(pdf_path: str, json_output_path: str): reader = PdfReader(pdf_path) field_info = get_field_info(reader) - with open(json_output_path, "w", encoding="utf-8") as f: - json.dump(field_info, f, indent=2, ensure_ascii=False) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) print(f"Wrote {len(field_info)} fields to {json_output_path}") diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py new file mode 100755 index 00000000..f219e7d5 --- /dev/null +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py @@ -0,0 +1,115 @@ +""" +Extract form structure from a non-fillable PDF. + +This script analyzes the PDF to find: +- Text labels with their exact coordinates +- Horizontal lines (row boundaries) +- Checkboxes (small rectangles) + +Output: A JSON file with the form structure that can be used to generate +accurate field coordinates for filling. + +Usage: python extract_form_structure.py +""" + +import json +import sys +import pdfplumber + + +def extract_form_structure(pdf_path): + structure = { + "pages": [], + "labels": [], + "lines": [], + "checkboxes": [], + "row_boundaries": [] + } + + with pdfplumber.open(pdf_path) as pdf: + for page_num, page in enumerate(pdf.pages, 1): + structure["pages"].append({ + "page_number": page_num, + "width": float(page.width), + "height": float(page.height) + }) + + words = page.extract_words() + for word in words: + structure["labels"].append({ + "page": page_num, + "text": word["text"], + "x0": round(float(word["x0"]), 1), + "top": round(float(word["top"]), 1), + "x1": round(float(word["x1"]), 1), + "bottom": round(float(word["bottom"]), 1) + }) + + for line in page.lines: + if abs(float(line["x1"]) - float(line["x0"])) > page.width * 0.5: + structure["lines"].append({ + "page": page_num, + "y": round(float(line["top"]), 1), + "x0": round(float(line["x0"]), 1), + "x1": round(float(line["x1"]), 1) + }) + + for rect in page.rects: + width = float(rect["x1"]) - float(rect["x0"]) + height = float(rect["bottom"]) - float(rect["top"]) + if 5 <= width <= 15 and 5 <= height <= 15 and abs(width - height) < 2: + structure["checkboxes"].append({ + "page": page_num, + "x0": round(float(rect["x0"]), 1), + "top": round(float(rect["top"]), 1), + "x1": round(float(rect["x1"]), 1), + "bottom": round(float(rect["bottom"]), 1), + "center_x": round((float(rect["x0"]) + float(rect["x1"])) / 2, 1), + "center_y": round((float(rect["top"]) + float(rect["bottom"])) / 2, 1) + }) + + lines_by_page = {} + for line in structure["lines"]: + page = line["page"] + if page not in lines_by_page: + lines_by_page[page] = [] + lines_by_page[page].append(line["y"]) + + for page, y_coords in lines_by_page.items(): + y_coords = sorted(set(y_coords)) + for i in range(len(y_coords) - 1): + structure["row_boundaries"].append({ + "page": page, + "row_top": y_coords[i], + "row_bottom": y_coords[i + 1], + "row_height": round(y_coords[i + 1] - y_coords[i], 1) + }) + + return structure + + +def main(): + if len(sys.argv) != 3: + print("Usage: extract_form_structure.py ") + sys.exit(1) + + pdf_path = sys.argv[1] + output_path = sys.argv[2] + + print(f"Extracting structure from {pdf_path}...") + structure = extract_form_structure(pdf_path) + + with open(output_path, "w") as f: + json.dump(structure, f, indent=2) + + print(f"Found:") + print(f" - {len(structure['pages'])} pages") + print(f" - {len(structure['labels'])} text labels") + print(f" - {len(structure['lines'])} horizontal lines") + print(f" - {len(structure['checkboxes'])} checkboxes") + print(f" - {len(structure['row_boundaries'])} row boundaries") + print(f"Saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py index 573057ac..51c2600f 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py @@ -6,13 +6,11 @@ from pypdf import PdfReader, PdfWriter from extract_form_field_info import get_field_info -# Fills fillable form fields in a PDF. See forms.md. def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str): - with open(fields_json_path, encoding="utf-8") as f: + with open(fields_json_path) as f: fields = json.load(f) - # Group by page number. fields_by_page = {} for field in fields: if "value" in field: @@ -48,8 +46,6 @@ def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: for page, field_values in fields_by_page.items(): writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False) - # This seems to be necessary for many PDF viewers to format the form values correctly. - # It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes. writer.set_need_appearances_writer(True) with open(output_pdf_path, "wb") as f: @@ -75,18 +71,6 @@ def validation_error_for_field_value(field_info, field_value): return None -# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field. -# In _writer.py around line 966: -# -# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0: -# txt = "\n".join(annotation.get_inherited(FA.Opt, [])) -# -# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like -# [["value1", "Text 1"], ["value2", "Text 2"], ...] -# This causes `join` to throw a TypeError because it expects an iterable of strings. -# The horrible workaround is to patch `get_inherited` to return a list of the value strings. -# We call the original method and adjust the return value only if the argument to `get_inherited` -# is `FA.Opt` and if the return value is a list of two-element lists. def monkeypatch_pydpf_method(): from pypdf.generic import DictionaryObject from pypdf.constants import FieldDictionaryAttributes diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py index ea6c60cb..b430069f 100644 --- a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py +++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py @@ -5,64 +5,67 @@ from pypdf import PdfReader, PdfWriter from pypdf.annotations import FreeText -# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md. -def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height): - """Transform bounding box from image coordinates to PDF coordinates""" - # Image coordinates: origin at top-left, y increases downward - # PDF coordinates: origin at bottom-left, y increases upward +def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_height): x_scale = pdf_width / image_width y_scale = pdf_height / image_height - + left = bbox[0] * x_scale right = bbox[2] * x_scale - - # Flip Y coordinates for PDF + top = pdf_height - (bbox[1] * y_scale) bottom = pdf_height - (bbox[3] * y_scale) - + return left, bottom, right, top +def transform_from_pdf_coords(bbox, pdf_height): + left = bbox[0] + right = bbox[2] + + pypdf_top = pdf_height - bbox[1] + pypdf_bottom = pdf_height - bbox[3] + + return left, pypdf_bottom, right, pypdf_top + + def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): - """Fill the PDF form with data from fields.json""" - # `fields.json` format described in forms.md. - with open(fields_json_path, "r", encoding="utf-8") as f: + with open(fields_json_path, "r") as f: fields_data = json.load(f) - # Open the PDF reader = PdfReader(input_pdf_path) writer = PdfWriter() - # Copy all pages to writer writer.append(reader) - # Get PDF dimensions for each page pdf_dimensions = {} for i, page in enumerate(reader.pages): mediabox = page.mediabox pdf_dimensions[i + 1] = [mediabox.width, mediabox.height] - # Process each form field annotations = [] for field in fields_data["form_fields"]: page_num = field["page_number"] - - # Get page dimensions and transform coordinates. + page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num) - image_width = page_info["image_width"] - image_height = page_info["image_height"] pdf_width, pdf_height = pdf_dimensions[page_num] + + if "pdf_width" in page_info: + transformed_entry_box = transform_from_pdf_coords( + field["entry_bounding_box"], + float(pdf_height) + ) + else: + image_width = page_info["image_width"] + image_height = page_info["image_height"] + transformed_entry_box = transform_from_image_coords( + field["entry_bounding_box"], + image_width, image_height, + float(pdf_width), float(pdf_height) + ) - transformed_entry_box = transform_coordinates( - field["entry_bounding_box"], - image_width, image_height, - pdf_width, pdf_height - ) - - # Skip empty fields if "entry_text" not in field or "text" not in field["entry_text"]: continue entry_text = field["entry_text"] @@ -74,8 +77,6 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): font_size = str(entry_text.get("font_size", 14)) + "pt" font_color = entry_text.get("font_color", "000000") - # Font size/color seems to not work reliably across viewers: - # https://github.com/py-pdf/pypdf/issues/2084 annotation = FreeText( text=text, rect=transformed_entry_box, @@ -86,10 +87,8 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): background_color=None, ) annotations.append(annotation) - # page_number is 0-based for pypdf writer.add_annotation(page_number=page_num - 1, annotation=annotation) - # Save the filled PDF with open(output_pdf_path, "wb") as output: writer.write(output) @@ -105,4 +104,4 @@ if __name__ == "__main__": fields_json = sys.argv[2] output_pdf = sys.argv[3] - fill_pdf_form(input_pdf, fields_json, output_pdf) \ No newline at end of file + fill_pdf_form(input_pdf, fields_json, output_pdf) diff --git a/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md b/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md new file mode 100644 index 00000000..d3e046a5 --- /dev/null +++ b/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md @@ -0,0 +1,314 @@ +--- +name: pdf +description: Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill. +license: Proprietary. LICENSE.txt has complete terms +--- + +# PDF Processing Guide + +## Overview + +This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions. + +## Quick Start + +```python +from pypdf import PdfReader, PdfWriter + +# Read a PDF +reader = PdfReader("document.pdf") +print(f"Pages: {len(reader.pages)}") + +# Extract text +text = "" +for page in reader.pages: + text += page.extract_text() +``` + +## Python Libraries + +### pypdf - Basic Operations + +#### Merge PDFs +```python +from pypdf import PdfWriter, PdfReader + +writer = PdfWriter() +for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + +with open("merged.pdf", "wb") as output: + writer.write(output) +``` + +#### Split PDF +```python +reader = PdfReader("input.pdf") +for i, page in enumerate(reader.pages): + writer = PdfWriter() + writer.add_page(page) + with open(f"page_{i+1}.pdf", "wb") as output: + writer.write(output) +``` + +#### Extract Metadata +```python +reader = PdfReader("document.pdf") +meta = reader.metadata +print(f"Title: {meta.title}") +print(f"Author: {meta.author}") +print(f"Subject: {meta.subject}") +print(f"Creator: {meta.creator}") +``` + +#### Rotate Pages +```python +reader = PdfReader("input.pdf") +writer = PdfWriter() + +page = reader.pages[0] +page.rotate(90) # Rotate 90 degrees clockwise +writer.add_page(page) + +with open("rotated.pdf", "wb") as output: + writer.write(output) +``` + +### pdfplumber - Text and Table Extraction + +#### Extract Text with Layout +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + for page in pdf.pages: + text = page.extract_text() + print(text) +``` + +#### Extract Tables +```python +with pdfplumber.open("document.pdf") as pdf: + for i, page in enumerate(pdf.pages): + tables = page.extract_tables() + for j, table in enumerate(tables): + print(f"Table {j+1} on page {i+1}:") + for row in table: + print(row) +``` + +#### Advanced Table Extraction +```python +import pandas as pd + +with pdfplumber.open("document.pdf") as pdf: + all_tables = [] + for page in pdf.pages: + tables = page.extract_tables() + for table in tables: + if table: # Check if table is not empty + df = pd.DataFrame(table[1:], columns=table[0]) + all_tables.append(df) + +# Combine all tables +if all_tables: + combined_df = pd.concat(all_tables, ignore_index=True) + combined_df.to_excel("extracted_tables.xlsx", index=False) +``` + +### reportlab - Create PDFs + +#### Basic PDF Creation +```python +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + +c = canvas.Canvas("hello.pdf", pagesize=letter) +width, height = letter + +# Add text +c.drawString(100, height - 100, "Hello World!") +c.drawString(100, height - 120, "This is a PDF created with reportlab") + +# Add a line +c.line(100, height - 140, 400, height - 140) + +# Save +c.save() +``` + +#### Create PDF with Multiple Pages +```python +from reportlab.lib.pagesizes import letter +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak +from reportlab.lib.styles import getSampleStyleSheet + +doc = SimpleDocTemplate("report.pdf", pagesize=letter) +styles = getSampleStyleSheet() +story = [] + +# Add content +title = Paragraph("Report Title", styles['Title']) +story.append(title) +story.append(Spacer(1, 12)) + +body = Paragraph("This is the body of the report. " * 20, styles['Normal']) +story.append(body) +story.append(PageBreak()) + +# Page 2 +story.append(Paragraph("Page 2", styles['Heading1'])) +story.append(Paragraph("Content for page 2", styles['Normal'])) + +# Build PDF +doc.build(story) +``` + +#### Subscripts and Superscripts + +**IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes. + +Instead, use ReportLab's XML markup tags in Paragraph objects: +```python +from reportlab.platypus import Paragraph +from reportlab.lib.styles import getSampleStyleSheet + +styles = getSampleStyleSheet() + +# Subscripts: use tag +chemical = Paragraph("H2O", styles['Normal']) + +# Superscripts: use tag +squared = Paragraph("x2 + y2", styles['Normal']) +``` + +For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts. + +## Command-Line Tools + +### pdftotext (poppler-utils) +```bash +# Extract text +pdftotext input.pdf output.txt + +# Extract text preserving layout +pdftotext -layout input.pdf output.txt + +# Extract specific pages +pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5 +``` + +### qpdf +```bash +# Merge PDFs +qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf + +# Split pages +qpdf input.pdf --pages . 1-5 -- pages1-5.pdf +qpdf input.pdf --pages . 6-10 -- pages6-10.pdf + +# Rotate pages +qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees + +# Remove password +qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf +``` + +### pdftk (if available) +```bash +# Merge +pdftk file1.pdf file2.pdf cat output merged.pdf + +# Split +pdftk input.pdf burst + +# Rotate +pdftk input.pdf rotate 1east output rotated.pdf +``` + +## Common Tasks + +### Extract Text from Scanned PDFs +```python +# Requires: pip install pytesseract pdf2image +import pytesseract +from pdf2image import convert_from_path + +# Convert PDF to images +images = convert_from_path('scanned.pdf') + +# OCR each page +text = "" +for i, image in enumerate(images): + text += f"Page {i+1}:\n" + text += pytesseract.image_to_string(image) + text += "\n\n" + +print(text) +``` + +### Add Watermark +```python +from pypdf import PdfReader, PdfWriter + +# Create watermark (or load existing) +watermark = PdfReader("watermark.pdf").pages[0] + +# Apply to all pages +reader = PdfReader("document.pdf") +writer = PdfWriter() + +for page in reader.pages: + page.merge_page(watermark) + writer.add_page(page) + +with open("watermarked.pdf", "wb") as output: + writer.write(output) +``` + +### Extract Images +```bash +# Using pdfimages (poppler-utils) +pdfimages -j input.pdf output_prefix + +# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc. +``` + +### Password Protection +```python +from pypdf import PdfReader, PdfWriter + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +for page in reader.pages: + writer.add_page(page) + +# Add password +writer.encrypt("userpassword", "ownerpassword") + +with open("encrypted.pdf", "wb") as output: + writer.write(output) +``` + +## Quick Reference + +| Task | Best Tool | Command/Code | +|------|-----------|--------------| +| Merge PDFs | pypdf | `writer.add_page(page)` | +| Split PDFs | pypdf | One page per file | +| Extract text | pdfplumber | `page.extract_text()` | +| Extract tables | pdfplumber | `page.extract_tables()` | +| Create PDFs | reportlab | Canvas or Platypus | +| Command line merge | qpdf | `qpdf --empty --pages ...` | +| OCR scanned PDFs | pytesseract | Convert to image first | +| Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md | + +## Next Steps + +- For advanced pypdfium2 usage, see REFERENCE.md +- For JavaScript libraries (pdf-lib), see REFERENCE.md +- If you need to fill out a PDF form, follow the instructions in FORMS.md +- For troubleshooting guides, see REFERENCE.md diff --git a/plugins/codex/plugins/postgres/.codex-plugin/plugin.json b/plugins/codex/plugins/postgres/.codex-plugin/plugin.json deleted file mode 100644 index e2448203..00000000 --- a/plugins/codex/plugins/postgres/.codex-plugin/plugin.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "postgres", - "version": "1.0.0", - "description": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", - "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" - }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", - "license": "Proprietary", - "keywords": [ - "eapil", - "codex-skill", - "postgres" - ], - "skills": "./skills/", - "interface": { - "displayName": "postgres", - "shortDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", - "longDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", - "developerName": "EAPIL", - "category": "Data & Analytics", - "capabilities": [ - "Read", - "Write" - ], - "defaultPrompt": [ - "使用 postgres 帮我完成这个任务。" - ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", - "brandColor": "#2563EB", - "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" - } -} diff --git a/plugins/codex/plugins/postgres/assets/market-icon.svg b/plugins/codex/plugins/postgres/assets/market-icon.svg deleted file mode 100644 index c6bf091c..00000000 --- a/plugins/codex/plugins/postgres/assets/market-icon.svg +++ /dev/null @@ -1 +0,0 @@ -postgres diff --git a/plugins/codex/plugins/postgres/skills/postgres/.gitignore b/plugins/codex/plugins/postgres/skills/postgres/.gitignore deleted file mode 100644 index d99e056f..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Credentials - NEVER commit actual connection configs -connections.json - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -*.egg -*.egg-info/ -dist/ -build/ - -# Virtual environments -.venv/ -venv/ -ENV/ - -# Testing -.pytest_cache/ -.coverage -htmlcov/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo diff --git a/plugins/codex/plugins/postgres/skills/postgres/README.md b/plugins/codex/plugins/postgres/skills/postgres/README.md deleted file mode 100644 index 3b8dacc8..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# postgres - -Read-only PostgreSQL query skill. Query multiple databases safely with write protection. - -## Setup - -1. Copy the example config: -```bash -cp connections.example.json connections.json -``` - -2. Add your database credentials: -```json -{ - "databases": [ - { - "name": "prod", - "description": "Production - users, orders, transactions", - "host": "db.example.com", - "port": 5432, - "database": "app_prod", - "user": "readonly", - "password": "secret", - "sslmode": "require" - } - ] -} -``` - -3. Secure the config: -```bash -chmod 600 connections.json -``` - -## Usage - -```bash -# List configured databases -python3 scripts/query.py --list - -# List tables -python3 scripts/query.py --db prod --tables - -# Show schema -python3 scripts/query.py --db prod --schema - -# Run query -python3 scripts/query.py --db prod --query "SELECT * FROM users" --limit 100 -``` - -## Config Fields - -| Field | Required | Default | Description | -|-------|----------|---------|-------------| -| name | Yes | - | Database identifier | -| description | Yes | - | What data it contains (for auto-selection) | -| host | Yes | - | Hostname | -| port | No | 5432 | Port | -| database | Yes | - | Database name | -| user | Yes | - | Username | -| password | Yes | - | Password | -| sslmode | No | prefer | disable, allow, prefer, require, verify-ca, verify-full | - -## Safety Features - -- **Read-only sessions**: PostgreSQL `readonly=True` mode blocks writes at database level -- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH allowed -- **Single statement**: No multi-statement queries (prevents `SELECT 1; DROP TABLE`) -- **Timeouts**: 30s query timeout, 10s connection timeout -- **Memory cap**: Max 10,000 rows per query -- **Credential protection**: Passwords sanitized from error messages - -## Requirements - -```bash -pip install psycopg2-binary -``` diff --git a/plugins/codex/plugins/postgres/skills/postgres/SKILL.md b/plugins/codex/plugins/postgres/skills/postgres/SKILL.md deleted file mode 100644 index 3355580e..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: postgres -description: "Execute read-only SQL queries against multiple PostgreSQL databases. Use when: (1) querying PostgreSQL databases, (2) exploring database schemas/tables, (3) running SELECT queries for data analysis, (4) checking database contents. Supports multiple database connections with descriptions for intelligent auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety." ---- - -# PostgreSQL Read-Only Query Skill - -Execute safe, read-only queries against configured PostgreSQL databases. - -## Requirements - -- Python 3.8+ -- psycopg2-binary: `pip install -r requirements.txt` - -## Setup - -Create `connections.json` in the skill directory or `~/.config/claude/postgres-connections.json`. - -**Security**: Set file permissions to `600` since it contains credentials: -```bash -chmod 600 connections.json -``` - -```json -{ - "databases": [ - { - "name": "production", - "description": "Main app database - users, orders, transactions", - "host": "db.example.com", - "port": 5432, - "database": "app_prod", - "user": "readonly_user", - "password": "your-password", - "sslmode": "require" - } - ] -} -``` - -### Config Fields - -| Field | Required | Description | -|-------|----------|-------------| -| name | Yes | Identifier for the database (case-insensitive) | -| description | Yes | What data this database contains (used for auto-selection) | -| host | Yes | Database hostname | -| port | No | Port number (default: 5432) | -| database | Yes | Database name | -| user | Yes | Username | -| password | Yes | Password | -| sslmode | No | SSL mode: disable, allow, prefer (default), require, verify-ca, verify-full | - -## Usage - -### List configured databases -```bash -python3 scripts/query.py --list -``` - -### Query a database -```bash -python3 scripts/query.py --db production --query "SELECT * FROM users LIMIT 10" -``` - -### List tables -```bash -python3 scripts/query.py --db production --tables -``` - -### Show schema -```bash -python3 scripts/query.py --db production --schema -``` - -### Limit results -```bash -python3 scripts/query.py --db production --query "SELECT * FROM orders" --limit 100 -``` - -## Database Selection - -Match user intent to database `description`: - -| User asks about | Look for description containing | -|-----------------|--------------------------------| -| users, accounts | users, accounts, customers | -| orders, sales | orders, transactions, sales | -| analytics, metrics | analytics, metrics, reports | -| logs, events | logs, events, audit | - -If unclear, run `--list` and ask user which database. - -## Safety Features - -- **Read-only session**: Connection uses PostgreSQL `readonly=True` mode (primary protection) -- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH queries allowed -- **Single statement**: Multiple statements per query rejected -- **SSL support**: Configurable SSL mode for encrypted connections -- **Query timeout**: 30-second statement timeout enforced -- **Memory protection**: Max 10,000 rows per query to prevent OOM -- **Column width cap**: 100 char max per column for readable output -- **Credential sanitization**: Error messages don't leak passwords - -## Troubleshooting - -| Error | Solution | -|-------|----------| -| Config not found | Create `connections.json` in skill directory | -| Authentication failed | Check username/password in config | -| Connection timeout | Verify host/port, check firewall/VPN | -| SSL error | Try `"sslmode": "disable"` for local databases | -| Permission warning | Run `chmod 600 connections.json` | - -## Exit Codes - -- **0**: Success -- **1**: Error (config missing, auth failed, invalid query, database error) - -## Workflow - -1. Run `--list` to show available databases -2. Match user intent to database description -3. Run `--tables` or `--schema` to explore structure -4. Execute query with appropriate LIMIT diff --git a/plugins/codex/plugins/postgres/skills/postgres/connections.example.json b/plugins/codex/plugins/postgres/skills/postgres/connections.example.json deleted file mode 100644 index b41c5680..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/connections.example.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "databases": [ - { - "name": "production", - "description": "Main production database - users, orders, transactions, accounts", - "host": "prod-db.example.com", - "port": 5432, - "database": "app_prod", - "user": "readonly_user", - "password": "your-password-here", - "sslmode": "require" - }, - { - "name": "analytics", - "description": "Analytics warehouse - aggregated metrics, reports, historical data", - "host": "analytics-db.example.com", - "port": 5432, - "database": "analytics", - "user": "analyst", - "password": "your-password-here", - "sslmode": "require" - }, - { - "name": "staging", - "description": "Staging environment - mirrors production for testing", - "host": "localhost", - "port": 5432, - "database": "app_staging", - "user": "dev", - "password": "dev-password", - "sslmode": "prefer" - } - ] -} diff --git a/plugins/codex/plugins/postgres/skills/postgres/requirements.txt b/plugins/codex/plugins/postgres/skills/postgres/requirements.txt deleted file mode 100644 index cbeb9412..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary>=2.9,<3.0 diff --git a/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py b/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py deleted file mode 100644 index 53461858..00000000 --- a/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -""" -Read-only PostgreSQL query executor. -Connects to configured databases and executes SELECT queries only. -""" - -import json -import os -import re -import stat -import sys -import argparse -from pathlib import Path -from typing import Optional - -try: - import psycopg2 -except ImportError: - print("Error: psycopg2 not installed. Run: pip install psycopg2-binary") - sys.exit(1) - -# Constants -SCRIPT_DIR = Path(__file__).parent.parent -CONFIG_LOCATIONS = [ - SCRIPT_DIR / "connections.json", - Path.home() / ".config" / "claude" / "postgres-connections.json", -] -MAX_ROWS = 10000 -MAX_COLUMN_WIDTH = 100 -QUERY_TIMEOUT_MS = 30000 -CONNECTION_TIMEOUT_SEC = 10 -NULL_DISPLAY = "" - - -def is_read_only(query: str) -> bool: - """Basic client-side check. Primary protection is readonly=True session.""" - query_upper = query.upper().strip() - safe_starts = ('SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH', '\\D') - return any(query_upper.startswith(cmd) for cmd in safe_starts) - - -def validate_single_statement(query: str) -> bool: - """Check query contains only one statement.""" - # Remove trailing semicolon and whitespace, then check for remaining semicolons - clean = query.rstrip().rstrip(';') - return ';' not in clean - - -def validate_config_permissions(path: Path) -> None: - """Warn if config file has insecure permissions (Unix only).""" - if os.name != 'nt': # Skip on Windows - mode = path.stat().st_mode - if bool(mode & stat.S_IRWXG) or bool(mode & stat.S_IRWXO): - print(f"WARNING: {path} has insecure permissions!") - print(f"Config contains credentials. Run: chmod 600 {path}") - - -def validate_db_config(db: dict) -> None: - """Validate required fields exist in database config.""" - required = ['name', 'host', 'database', 'user', 'password'] - missing = [f for f in required if f not in db] - if missing: - print(f"Error: Database config missing fields: {', '.join(missing)}") - sys.exit(1) - - -def find_config() -> Optional[Path]: - """Find config file in supported locations.""" - for path in CONFIG_LOCATIONS: - if path.exists(): - return path - return None - - -def load_config(config_path: Optional[Path] = None) -> dict: - """Load database connections from JSON config.""" - path = config_path or find_config() - if not path: - print("Config not found. Searched:") - for loc in CONFIG_LOCATIONS: - print(f" - {loc}") - print("\nCreate connections.json with format:") - print(json.dumps({ - "databases": [{ - "name": "mydb", - "description": "Description of database contents", - "host": "localhost", - "port": 5432, - "database": "mydb", - "user": "user", - "password": "password", - "sslmode": "prefer" - }] - }, indent=2)) - sys.exit(1) - - validate_config_permissions(path) - - with open(path) as f: - return json.load(f) - - -def list_databases(config: dict) -> None: - """List all configured databases.""" - print("Configured databases:\n") - for db in config.get("databases", []): - validate_db_config(db) - print(f" [{db['name']}]") - print(f" Host: {db['host']}:{db.get('port', 5432)}") - print(f" Database: {db['database']}") - print(f" Description: {db.get('description', 'No description')}") - print() - - -def execute_query(db_config: dict, query: str, limit: Optional[int] = None) -> None: - """Execute a read-only query against the specified database.""" - if not is_read_only(query): - print("Error: Only read-only queries (SELECT, SHOW, EXPLAIN) are allowed.") - sys.exit(1) - - if not validate_single_statement(query): - print("Error: Multiple statements not allowed. Execute queries separately.") - sys.exit(1) - - # Apply limit using regex to avoid false positives from string content - if limit and not re.search(r'\bLIMIT\s+\d+', query, re.IGNORECASE): - query = f"{query.rstrip(';')} LIMIT {limit}" - - conn = None - try: - conn = psycopg2.connect( - host=db_config['host'], - port=db_config.get('port', 5432), - database=db_config['database'], - user=db_config['user'], - password=db_config['password'], - sslmode=db_config.get('sslmode', 'prefer'), - connect_timeout=CONNECTION_TIMEOUT_SEC, - options=f'-c statement_timeout={QUERY_TIMEOUT_MS}' - ) - # Primary safety: readonly session prevents any write operations - conn.set_session(readonly=True, autocommit=True) - - with conn.cursor() as cur: - cur.execute(query) - if cur.description: - columns = [desc[0] for desc in cur.description] - rows = cur.fetchmany(MAX_ROWS) - truncated = len(rows) == MAX_ROWS - - # Calculate column widths with cap - widths = [min(len(col), MAX_COLUMN_WIDTH) for col in columns] - for row in rows: - for i, val in enumerate(row): - val_str = str(val) if val is not None else NULL_DISPLAY - widths[i] = min(max(widths[i], len(val_str)), MAX_COLUMN_WIDTH) - - # Print header - header = " | ".join(col[:MAX_COLUMN_WIDTH].ljust(widths[i]) for i, col in enumerate(columns)) - print(header) - print("-" * len(header)) - - # Print rows - for row in rows: - cells = [] - for i, val in enumerate(row): - val_str = str(val) if val is not None else NULL_DISPLAY - if len(val_str) > MAX_COLUMN_WIDTH: - val_str = val_str[:MAX_COLUMN_WIDTH-3] + "..." - cells.append(val_str.ljust(widths[i])) - print(" | ".join(cells)) - - msg = f"\n({len(rows)} rows)" - if truncated: - msg += f" [truncated at {MAX_ROWS}]" - print(msg) - else: - print("Query executed (no result set returned)") - - except psycopg2.Error as e: - error_msg = str(e) - # Sanitize to avoid leaking credentials - if 'password' in error_msg.lower() or 'authentication' in error_msg.lower(): - error_msg = "Authentication failed. Check credentials in connections.json" - print(f"Database error: {error_msg}") - sys.exit(1) - finally: - if conn: - conn.close() - - -def find_database(config: dict, name: str) -> dict: - """Find database config by name (case-insensitive).""" - for db in config.get("databases", []): - if db.get('name', '').lower() == name.lower(): - validate_db_config(db) - return db - available = [db.get('name', 'unnamed') for db in config.get("databases", [])] - print(f"Database '{name}' not found.") - print(f"Available: {', '.join(available)}") - sys.exit(1) - - -def main() -> None: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Execute read-only PostgreSQL queries", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s --list - %(prog)s --db mydb --tables - %(prog)s --db mydb --query "SELECT * FROM users" --limit 100 - """ - ) - parser.add_argument("--config", "-c", type=Path, help="Path to config JSON") - parser.add_argument("--db", "-d", help="Database name to query") - parser.add_argument("--query", "-q", help="SQL query to execute") - parser.add_argument("--limit", "-l", type=int, help="Limit rows returned") - parser.add_argument("--list", action="store_true", help="List configured databases") - parser.add_argument("--schema", "-s", action="store_true", help="Show database schema") - parser.add_argument("--tables", "-t", action="store_true", help="List tables") - - args = parser.parse_args() - config = load_config(args.config) - - if args.list: - list_databases(config) - return - - if not args.db: - print("Error: --db required. Use --list to see available databases.") - sys.exit(1) - - db_config = find_database(config, args.db) - - if args.tables: - query = """ - SELECT table_schema, table_name, table_type - FROM information_schema.tables - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - ORDER BY table_schema, table_name - """ - execute_query(db_config, query, args.limit) - elif args.schema: - query = """ - SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable - FROM information_schema.columns c - JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema - WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') - ORDER BY c.table_schema, c.table_name, c.ordinal_position - """ - execute_query(db_config, query, args.limit) - elif args.query: - execute_query(db_config, args.query, args.limit) - else: - print("Error: --query, --tables, or --schema required") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/plugins/codex/plugins/pptx/.codex-plugin/plugin.json b/plugins/codex/plugins/pptx/.codex-plugin/plugin.json index 536f47d2..54410bfd 100644 --- a/plugins/codex/plugins/pptx/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/pptx/.codex-plugin/plugin.json @@ -1,39 +1,40 @@ { "name": "pptx", - "version": "1.0.2", - "description": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。", + "version": "2.0.0", + "description": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。", "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://github.com/anthropics/skills", + "repository": "https://github.com/anthropics/skills", "license": "Proprietary", "keywords": [ - "eapil", - "codex-skill", - "pptx" + "pptx", + "documents", + "zh-CN", + "windows" ], "skills": "./skills/", "interface": { "displayName": "pptx", - "shortDescription": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。", - "longDescription": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。", - "developerName": "EAPIL", + "shortDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。", + "longDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", "category": "Productivity", "capabilities": [ "Read", "Write" ], "defaultPrompt": [ - "使用 pptx 帮我完成这个任务。" + "使用 pptx 处理这份中文文档,并检查最终输出。" ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "websiteURL": "https://github.com/anthropics/skills", + "privacyPolicyURL": "https://github.com/anthropics/skills", + "termsOfServiceURL": "https://github.com/anthropics/skills", "brandColor": "#2563EB", "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" } } diff --git a/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..a2a7ad5d --- /dev/null +++ b/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "pptx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "adapter": "claude-skill", + "sourcePath": "skills/pptx", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/pptx/skills/pptx/SKILL.md b/plugins/codex/plugins/pptx/skills/pptx/SKILL.md index df5000e1..50cce731 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/SKILL.md +++ b/plugins/codex/plugins/pptx/skills/pptx/SKILL.md @@ -1,232 +1,34 @@ --- name: pptx -description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill." +description: 创建、读取、编辑和检查 PowerPoint 演示文稿或模板(.pptx/.potx),保留模板与可编辑图表,适配简体中文字体和 Windows PowerShell。 license: Proprietary. LICENSE.txt has complete terms --- -# PPTX Skill +# PowerPoint 演示文稿 -## Quick Reference +先读取 [references/platform.md](references/platform.md),确认字体、Node/Python 与需要的转换工具。先看用户模板或参考稿,再决定布局、配色与内容密度。 -| Task | Guide | -|------|-------| -| Read/analyze content | `python -m markitdown presentation.pptx` | -| Edit or create from template | Read [editing.md](editing.md) | -| Create from scratch | Read [pptxgenjs.md](pptxgenjs.md) | +| 任务 | 选择 | +| --- | --- | +| 新建幻灯片 | `pptxgenjs`;简单任务也可使用 `python-pptx` | +| 提取文本 | `python-pptx` 或 markitdown;检查备注与隐藏页 | +| 复杂模板、母版或原生图表编辑 | OOXML 加随附脚本 | +| .ppt 旧格式 | LibreOffice 先转 .pptx | ---- +## 中文与画布 -## Reading Content +新建稿未指定比例时用 16:9,设置画布后再添加元素。用户模板比例和主题优先。使用本机可用的微软雅黑、等线或思源黑体,同时设置主题字体与正文文本字体;简体中文语言标记使用 `zh-CN`。长中文标题主动换行,表格与数据标签保留足够空间,不能只缩小字号隐藏溢出。 -```bash -# Text extraction -python -m markitdown presentation.pptx +`pptxgenjs` 颜色使用六位 HEX,不带 `#`;每次添加元素使用独立选项对象。文本框 margin 与字体测量要一致;原生图表、表格和文本保持可编辑。坐标单位为英寸,必须落在实际画布内。库限制、组合图表和模板 OOXML 细节按需读取 [upstream-guide.md](upstream-guide.md)。 -# Visual overview -python scripts/thumbnail.py presentation.pptx +## 模板编辑 -# Raw XML -python scripts/office/unpack.py presentation.pptx unpacked/ +先完成复制、删除或重排幻灯片,再改正文。使用 `scripts/add_slide.py` 注册复制页,最终页序确定后运行 `scripts/clean.py` 清理失去引用的部分。不要只复制 slide XML;关系、媒体、母版和内容类型必须一起处理。用 `scripts/office_package.py` 解包和重打包,避免依赖 Bash ZIP 命令。 + +```powershell +python "$skill\scripts\office\validate.py" ".\输出.pptx" --original ".\模板.pptx" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\输出.pptx" +python "$skill\scripts\render_pdf.py" ".\preview\输出.pdf" ".\preview\pages" ``` ---- - -## Editing Workflow - -**Read [editing.md](editing.md) for full details.** - -1. Analyze template with `thumbnail.py` -2. Unpack → manipulate slides → edit content → clean → pack - ---- - -## Creating from Scratch - -**Read [pptxgenjs.md](pptxgenjs.md) for full details.** - -Use when no template or reference presentation is available. - ---- - -## Design Ideas - -**Don't create boring slides.** Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide. - -### Before Starting - -- **Pick a bold, content-informed color palette**: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices. -- **Dominance over equality**: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight. -- **Dark/light contrast**: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel. -- **Commit to a visual motif**: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles, thick single-side borders. Carry it across every slide. - -### Color Palettes - -Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration: - -| Theme | Primary | Secondary | Accent | -|-------|---------|-----------|--------| -| **Midnight Executive** | `1E2761` (navy) | `CADCFC` (ice blue) | `FFFFFF` (white) | -| **Forest & Moss** | `2C5F2D` (forest) | `97BC62` (moss) | `F5F5F5` (cream) | -| **Coral Energy** | `F96167` (coral) | `F9E795` (gold) | `2F3C7E` (navy) | -| **Warm Terracotta** | `B85042` (terracotta) | `E7E8D1` (sand) | `A7BEAE` (sage) | -| **Ocean Gradient** | `065A82` (deep blue) | `1C7293` (teal) | `21295C` (midnight) | -| **Charcoal Minimal** | `36454F` (charcoal) | `F2F2F2` (off-white) | `212121` (black) | -| **Teal Trust** | `028090` (teal) | `00A896` (seafoam) | `02C39A` (mint) | -| **Berry & Cream** | `6D2E46` (berry) | `A26769` (dusty rose) | `ECE2D0` (cream) | -| **Sage Calm** | `84B59F` (sage) | `69A297` (eucalyptus) | `50808E` (slate) | -| **Cherry Bold** | `990011` (cherry) | `FCF6F5` (off-white) | `2F3C7E` (navy) | - -### For Each Slide - -**Every slide needs a visual element** — image, chart, icon, or shape. Text-only slides are forgettable. - -**Layout options:** -- Two-column (text left, illustration on right) -- Icon + text rows (icon in colored circle, bold header, description below) -- 2x2 or 2x3 grid (image on one side, grid of content blocks on other) -- Half-bleed image (full left or right side) with content overlay - -**Data display:** -- Large stat callouts (big numbers 60-72pt with small labels below) -- Comparison columns (before/after, pros/cons, side-by-side options) -- Timeline or process flow (numbered steps, arrows) - -**Visual polish:** -- Icons in small colored circles next to section headers -- Italic accent text for key stats or taglines - -### Typography - -**Choose an interesting font pairing** — don't default to Arial. Pick a header font with personality and pair it with a clean body font. - -| Header Font | Body Font | -|-------------|-----------| -| Georgia | Calibri | -| Arial Black | Arial | -| Calibri | Calibri Light | -| Cambria | Calibri | -| Trebuchet MS | Calibri | -| Impact | Arial | -| Palatino | Garamond | -| Consolas | Calibri | - -| Element | Size | -|---------|------| -| Slide title | 36-44pt bold | -| Section header | 20-24pt bold | -| Body text | 14-16pt | -| Captions | 10-12pt muted | - -### Spacing - -- 0.5" minimum margins -- 0.3-0.5" between content blocks -- Leave breathing room—don't fill every inch - -### Avoid (Common Mistakes) - -- **Don't repeat the same layout** — vary columns, cards, and callouts across slides -- **Don't center body text** — left-align paragraphs and lists; center only titles -- **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body -- **Don't default to blue** — pick colors that reflect the specific topic -- **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently -- **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout -- **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets -- **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding -- **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds -- **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead - ---- - -## QA (Required) - -**Assume there are problems. Your job is to find them.** - -Your first render is almost never correct. Approach QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you weren't looking hard enough. - -### Content QA - -```bash -python -m markitdown output.pptx -``` - -Check for missing content, typos, wrong order. - -**When using templates, check for leftover placeholder text:** - -```bash -python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|this.*(page|slide).*layout" -``` - -If grep returns results, fix them before declaring success. - -### Visual QA - -**⚠️ USE SUBAGENTS** — even for 2-3 slides. You've been staring at the code and will see what you expect, not what's there. Subagents have fresh eyes. - -Convert slides to images (see [Converting to Images](#converting-to-images)), then use this prompt: - -``` -Visually inspect these slides. Assume there are issues — find them. - -Look for: -- Overlapping elements (text through shapes, lines through words, stacked elements) -- Text overflow or cut off at edges/box boundaries -- Decorative lines positioned for single-line text but title wrapped to two lines -- Source citations or footers colliding with content above -- Elements too close (< 0.3" gaps) or cards/sections nearly touching -- Uneven gaps (large empty area in one place, cramped in another) -- Insufficient margin from slide edges (< 0.5") -- Columns or similar elements not aligned consistently -- Low-contrast text (e.g., light gray text on cream-colored background) -- Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle) -- Text boxes too narrow causing excessive wrapping -- Leftover placeholder content - -For each slide, list issues or areas of concern, even if minor. - -Read and analyze these images: -1. /path/to/slide-01.jpg (Expected: [brief description]) -2. /path/to/slide-02.jpg (Expected: [brief description]) - -Report ALL issues found, including minor ones. -``` - -### Verification Loop - -1. Generate slides → Convert to images → Inspect -2. **List issues found** (if none found, look again more critically) -3. Fix issues -4. **Re-verify affected slides** — one fix often creates another problem -5. Repeat until a full pass reveals no new issues - -**Do not declare success until you've completed at least one fix-and-verify cycle.** - ---- - -## Converting to Images - -Convert presentations to individual slide images for visual inspection: - -```bash -python scripts/office/soffice.py --headless --convert-to pdf output.pptx -pdftoppm -jpeg -r 150 output.pdf slide -``` - -This creates `slide-01.jpg`, `slide-02.jpg`, etc. - -To re-render specific slides after fixes: - -```bash -pdftoppm -jpeg -r 150 -f N -l N output.pdf slide-fixed -``` - ---- - -## Dependencies - -- `pip install "markitdown[pptx]"` - text extraction -- `pip install Pillow` - thumbnail grids -- `npm install -g pptxgenjs` - creating from scratch -- LibreOffice (`soffice`) - PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`) -- Poppler (`pdftoppm`) - PDF to images +没有模板时省略 `--original`。每页检查文字溢出、缺字、对齐、遮挡、图表标签、图片分辨率和页序。需要模板缩略图且 Poppler 可用时运行 `scripts/thumbnail.py` 并指定独立输出前缀;否则用上面的免 Poppler 逐页预览。 diff --git a/plugins/codex/plugins/pptx/skills/pptx/editing.md b/plugins/codex/plugins/pptx/skills/pptx/editing.md deleted file mode 100644 index f873e8a0..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/editing.md +++ /dev/null @@ -1,205 +0,0 @@ -# Editing Presentations - -## Template-Based Workflow - -When using an existing presentation as a template: - -1. **Analyze existing slides**: - ```bash - python scripts/thumbnail.py template.pptx - python -m markitdown template.pptx - ``` - Review `thumbnails.jpg` to see layouts, and markitdown output to see placeholder text. - -2. **Plan slide mapping**: For each content section, choose a template slide. - - ⚠️ **USE VARIED LAYOUTS** — monotonous presentations are a common failure mode. Don't default to basic title + bullet slides. Actively seek out: - - Multi-column layouts (2-column, 3-column) - - Image + text combinations - - Full-bleed images with text overlay - - Quote or callout slides - - Section dividers - - Stat/number callouts - - Icon grids or icon + text rows - - **Avoid:** Repeating the same text-heavy layout for every slide. - - Match content type to layout style (e.g., key points → bullet slide, team info → multi-column, testimonials → quote slide). - -3. **Unpack**: `python scripts/office/unpack.py template.pptx unpacked/` - -4. **Build presentation** (do this yourself, not with subagents): - - Delete unwanted slides (remove from ``) - - Duplicate slides you want to reuse (`add_slide.py`) - - Reorder slides in `` - - **Complete all structural changes before step 5** - -5. **Edit content**: Update text in each `slide{N}.xml`. - **Use subagents here if available** — slides are separate XML files, so subagents can edit in parallel. - -6. **Clean**: `python scripts/clean.py unpacked/` - -7. **Pack**: `python scripts/office/pack.py unpacked/ output.pptx --original template.pptx` - ---- - -## Scripts - -| Script | Purpose | -|--------|---------| -| `unpack.py` | Extract and pretty-print PPTX | -| `add_slide.py` | Duplicate slide or create from layout | -| `clean.py` | Remove orphaned files | -| `pack.py` | Repack with validation | -| `thumbnail.py` | Create visual grid of slides | - -### unpack.py - -```bash -python scripts/office/unpack.py input.pptx unpacked/ -``` - -Extracts PPTX, pretty-prints XML, escapes smart quotes. - -### add_slide.py - -```bash -python scripts/add_slide.py unpacked/ slide2.xml # Duplicate slide -python scripts/add_slide.py unpacked/ slideLayout2.xml # From layout -``` - -Prints `` to add to `` at desired position. - -### clean.py - -```bash -python scripts/clean.py unpacked/ -``` - -Removes slides not in ``, unreferenced media, orphaned rels. - -### pack.py - -```bash -python scripts/office/pack.py unpacked/ output.pptx --original input.pptx -``` - -Validates, repairs, condenses XML, re-encodes smart quotes. - -### thumbnail.py - -```bash -python scripts/thumbnail.py input.pptx [output_prefix] [--cols N] -``` - -Creates `thumbnails.jpg` with slide filenames as labels. Default 3 columns, max 12 per grid. - -**Use for template analysis only** (choosing layouts). For visual QA, use `soffice` + `pdftoppm` to create full-resolution individual slide images—see SKILL.md. - ---- - -## Slide Operations - -Slide order is in `ppt/presentation.xml` → ``. - -**Reorder**: Rearrange `` elements. - -**Delete**: Remove ``, then run `clean.py`. - -**Add**: Use `add_slide.py`. Never manually copy slide files—the script handles notes references, Content_Types.xml, and relationship IDs that manual copying misses. - ---- - -## Editing Content - -**Subagents:** If available, use them here (after completing step 4). Each slide is a separate XML file, so subagents can edit in parallel. In your prompt to subagents, include: -- The slide file path(s) to edit -- **"Use the Edit tool for all changes"** -- The formatting rules and common pitfalls below - -For each slide: -1. Read the slide's XML -2. Identify ALL placeholder content—text, images, charts, icons, captions -3. Replace each placeholder with final content - -**Use the Edit tool, not sed or Python scripts.** The Edit tool forces specificity about what to replace and where, yielding better reliability. - -### Formatting Rules - -- **Bold all headers, subheadings, and inline labels**: Use `b="1"` on ``. This includes: - - Slide titles - - Section headers within a slide - - Inline labels like (e.g.: "Status:", "Description:") at the start of a line -- **Never use unicode bullets (•)**: Use proper list formatting with `` or `` -- **Bullet consistency**: Let bullets inherit from the layout. Only specify `` or ``. - ---- - -## Common Pitfalls - -### Template Adaptation - -When source content has fewer items than the template: -- **Remove excess elements entirely** (images, shapes, text boxes), don't just clear text -- Check for orphaned visuals after clearing text content -- Run visual QA to catch mismatched counts - -When replacing text with different length content: -- **Shorter replacements**: Usually safe -- **Longer replacements**: May overflow or wrap unexpectedly -- Test with visual QA after text changes -- Consider truncating or splitting content to fit the template's design constraints - -**Template slots ≠ Source items**: If template has 4 team members but source has 3 users, delete the 4th member's entire group (image + text boxes), not just the text. - -### Multi-Item Content - -If source has multiple items (numbered lists, multiple sections), create separate `` elements for each — **never concatenate into one string**. - -**❌ WRONG** — all items in one paragraph: -```xml - - Step 1: Do the first thing. Step 2: Do the second thing. - -``` - -**✅ CORRECT** — separate paragraphs with bold headers: -```xml - - - Step 1 - - - - Do the first thing. - - - - Step 2 - - -``` - -Copy `` from the original paragraph to preserve line spacing. Use `b="1"` on headers. - -### Smart Quotes - -Handled automatically by unpack/pack. But the Edit tool converts smart quotes to ASCII. - -**When adding new text with quotes, use XML entities:** - -```xml -the “Agreement” -``` - -| Character | Name | Unicode | XML Entity | -|-----------|------|---------|------------| -| `“` | Left double quote | U+201C | `“` | -| `”` | Right double quote | U+201D | `”` | -| `‘` | Left single quote | U+2018 | `‘` | -| `’` | Right single quote | U+2019 | `’` | - -### Other - -- **Whitespace**: Use `xml:space="preserve"` on `` with leading/trailing spaces -- **XML parsing**: Use `defusedxml.minidom`, not `xml.etree.ElementTree` (corrupts namespaces) diff --git a/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md b/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md deleted file mode 100644 index 6bfed908..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md +++ /dev/null @@ -1,420 +0,0 @@ -# PptxGenJS Tutorial - -## Setup & Basic Structure - -```javascript -const pptxgen = require("pptxgenjs"); - -let pres = new pptxgen(); -pres.layout = 'LAYOUT_16x9'; // or 'LAYOUT_16x10', 'LAYOUT_4x3', 'LAYOUT_WIDE' -pres.author = 'Your Name'; -pres.title = 'Presentation Title'; - -let slide = pres.addSlide(); -slide.addText("Hello World!", { x: 0.5, y: 0.5, fontSize: 36, color: "363636" }); - -pres.writeFile({ fileName: "Presentation.pptx" }); -``` - -## Layout Dimensions - -Slide dimensions (coordinates in inches): -- `LAYOUT_16x9`: 10" × 5.625" (default) -- `LAYOUT_16x10`: 10" × 6.25" -- `LAYOUT_4x3`: 10" × 7.5" -- `LAYOUT_WIDE`: 13.3" × 7.5" - ---- - -## Text & Formatting - -```javascript -// Basic text -slide.addText("Simple Text", { - x: 1, y: 1, w: 8, h: 2, fontSize: 24, fontFace: "Arial", - color: "363636", bold: true, align: "center", valign: "middle" -}); - -// Character spacing (use charSpacing, not letterSpacing which is silently ignored) -slide.addText("SPACED TEXT", { x: 1, y: 1, w: 8, h: 1, charSpacing: 6 }); - -// Rich text arrays -slide.addText([ - { text: "Bold ", options: { bold: true } }, - { text: "Italic ", options: { italic: true } } -], { x: 1, y: 3, w: 8, h: 1 }); - -// Multi-line text (requires breakLine: true) -slide.addText([ - { text: "Line 1", options: { breakLine: true } }, - { text: "Line 2", options: { breakLine: true } }, - { text: "Line 3" } // Last item doesn't need breakLine -], { x: 0.5, y: 0.5, w: 8, h: 2 }); - -// Text box margin (internal padding) -slide.addText("Title", { - x: 0.5, y: 0.3, w: 9, h: 0.6, - margin: 0 // Use 0 when aligning text with other elements like shapes or icons -}); -``` - -**Tip:** Text boxes have internal margin by default. Set `margin: 0` when you need text to align precisely with shapes, lines, or icons at the same x-position. - ---- - -## Lists & Bullets - -```javascript -// ✅ CORRECT: Multiple bullets -slide.addText([ - { text: "First item", options: { bullet: true, breakLine: true } }, - { text: "Second item", options: { bullet: true, breakLine: true } }, - { text: "Third item", options: { bullet: true } } -], { x: 0.5, y: 0.5, w: 8, h: 3 }); - -// ❌ WRONG: Never use unicode bullets -slide.addText("• First item", { ... }); // Creates double bullets - -// Sub-items and numbered lists -{ text: "Sub-item", options: { bullet: true, indentLevel: 1 } } -{ text: "First", options: { bullet: { type: "number" }, breakLine: true } } -``` - ---- - -## Shapes - -```javascript -slide.addShape(pres.shapes.RECTANGLE, { - x: 0.5, y: 0.8, w: 1.5, h: 3.0, - fill: { color: "FF0000" }, line: { color: "000000", width: 2 } -}); - -slide.addShape(pres.shapes.OVAL, { x: 4, y: 1, w: 2, h: 2, fill: { color: "0000FF" } }); - -slide.addShape(pres.shapes.LINE, { - x: 1, y: 3, w: 5, h: 0, line: { color: "FF0000", width: 3, dashType: "dash" } -}); - -// With transparency -slide.addShape(pres.shapes.RECTANGLE, { - x: 1, y: 1, w: 3, h: 2, - fill: { color: "0088CC", transparency: 50 } -}); - -// Rounded rectangle (rectRadius only works with ROUNDED_RECTANGLE, not RECTANGLE) -// ⚠️ Don't pair with rectangular accent overlays — they won't cover rounded corners. Use RECTANGLE instead. -slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { - x: 1, y: 1, w: 3, h: 2, - fill: { color: "FFFFFF" }, rectRadius: 0.1 -}); - -// With shadow -slide.addShape(pres.shapes.RECTANGLE, { - x: 1, y: 1, w: 3, h: 2, - fill: { color: "FFFFFF" }, - shadow: { type: "outer", color: "000000", blur: 6, offset: 2, angle: 135, opacity: 0.15 } -}); -``` - -Shadow options: - -| Property | Type | Range | Notes | -|----------|------|-------|-------| -| `type` | string | `"outer"`, `"inner"` | | -| `color` | string | 6-char hex (e.g. `"000000"`) | No `#` prefix, no 8-char hex — see Common Pitfalls | -| `blur` | number | 0-100 pt | | -| `offset` | number | 0-200 pt | **Must be non-negative** — negative values corrupt the file | -| `angle` | number | 0-359 degrees | Direction the shadow falls (135 = bottom-right, 270 = upward) | -| `opacity` | number | 0.0-1.0 | Use this for transparency, never encode in color string | - -To cast a shadow upward (e.g. on a footer bar), use `angle: 270` with a positive offset — do **not** use a negative offset. - -**Note**: Gradient fills are not natively supported. Use a gradient image as a background instead. - ---- - -## Images - -### Image Sources - -```javascript -// From file path -slide.addImage({ path: "images/chart.png", x: 1, y: 1, w: 5, h: 3 }); - -// From URL -slide.addImage({ path: "https://example.com/image.jpg", x: 1, y: 1, w: 5, h: 3 }); - -// From base64 (faster, no file I/O) -slide.addImage({ data: "image/png;base64,iVBORw0KGgo...", x: 1, y: 1, w: 5, h: 3 }); -``` - -### Image Options - -```javascript -slide.addImage({ - path: "image.png", - x: 1, y: 1, w: 5, h: 3, - rotate: 45, // 0-359 degrees - rounding: true, // Circular crop - transparency: 50, // 0-100 - flipH: true, // Horizontal flip - flipV: false, // Vertical flip - altText: "Description", // Accessibility - hyperlink: { url: "https://example.com" } -}); -``` - -### Image Sizing Modes - -```javascript -// Contain - fit inside, preserve ratio -{ sizing: { type: 'contain', w: 4, h: 3 } } - -// Cover - fill area, preserve ratio (may crop) -{ sizing: { type: 'cover', w: 4, h: 3 } } - -// Crop - cut specific portion -{ sizing: { type: 'crop', x: 0.5, y: 0.5, w: 2, h: 2 } } -``` - -### Calculate Dimensions (preserve aspect ratio) - -```javascript -const origWidth = 1978, origHeight = 923, maxHeight = 3.0; -const calcWidth = maxHeight * (origWidth / origHeight); -const centerX = (10 - calcWidth) / 2; - -slide.addImage({ path: "image.png", x: centerX, y: 1.2, w: calcWidth, h: maxHeight }); -``` - -### Supported Formats - -- **Standard**: PNG, JPG, GIF (animated GIFs work in Microsoft 365) -- **SVG**: Works in modern PowerPoint/Microsoft 365 - ---- - -## Icons - -Use react-icons to generate SVG icons, then rasterize to PNG for universal compatibility. - -### Setup - -```javascript -const React = require("react"); -const ReactDOMServer = require("react-dom/server"); -const sharp = require("sharp"); -const { FaCheckCircle, FaChartLine } = require("react-icons/fa"); - -function renderIconSvg(IconComponent, color = "#000000", size = 256) { - return ReactDOMServer.renderToStaticMarkup( - React.createElement(IconComponent, { color, size: String(size) }) - ); -} - -async function iconToBase64Png(IconComponent, color, size = 256) { - const svg = renderIconSvg(IconComponent, color, size); - const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); - return "image/png;base64," + pngBuffer.toString("base64"); -} -``` - -### Add Icon to Slide - -```javascript -const iconData = await iconToBase64Png(FaCheckCircle, "#4472C4", 256); - -slide.addImage({ - data: iconData, - x: 1, y: 1, w: 0.5, h: 0.5 // Size in inches -}); -``` - -**Note**: Use size 256 or higher for crisp icons. The size parameter controls the rasterization resolution, not the display size on the slide (which is set by `w` and `h` in inches). - -### Icon Libraries - -Install: `npm install -g react-icons react react-dom sharp` - -Popular icon sets in react-icons: -- `react-icons/fa` - Font Awesome -- `react-icons/md` - Material Design -- `react-icons/hi` - Heroicons -- `react-icons/bi` - Bootstrap Icons - ---- - -## Slide Backgrounds - -```javascript -// Solid color -slide.background = { color: "F1F1F1" }; - -// Color with transparency -slide.background = { color: "FF3399", transparency: 50 }; - -// Image from URL -slide.background = { path: "https://example.com/bg.jpg" }; - -// Image from base64 -slide.background = { data: "image/png;base64,iVBORw0KGgo..." }; -``` - ---- - -## Tables - -```javascript -slide.addTable([ - ["Header 1", "Header 2"], - ["Cell 1", "Cell 2"] -], { - x: 1, y: 1, w: 8, h: 2, - border: { pt: 1, color: "999999" }, fill: { color: "F1F1F1" } -}); - -// Advanced with merged cells -let tableData = [ - [{ text: "Header", options: { fill: { color: "6699CC" }, color: "FFFFFF", bold: true } }, "Cell"], - [{ text: "Merged", options: { colspan: 2 } }] -]; -slide.addTable(tableData, { x: 1, y: 3.5, w: 8, colW: [4, 4] }); -``` - ---- - -## Charts - -```javascript -// Bar chart -slide.addChart(pres.charts.BAR, [{ - name: "Sales", labels: ["Q1", "Q2", "Q3", "Q4"], values: [4500, 5500, 6200, 7100] -}], { - x: 0.5, y: 0.6, w: 6, h: 3, barDir: 'col', - showTitle: true, title: 'Quarterly Sales' -}); - -// Line chart -slide.addChart(pres.charts.LINE, [{ - name: "Temp", labels: ["Jan", "Feb", "Mar"], values: [32, 35, 42] -}], { x: 0.5, y: 4, w: 6, h: 3, lineSize: 3, lineSmooth: true }); - -// Pie chart -slide.addChart(pres.charts.PIE, [{ - name: "Share", labels: ["A", "B", "Other"], values: [35, 45, 20] -}], { x: 7, y: 1, w: 5, h: 4, showPercent: true }); -``` - -### Better-Looking Charts - -Default charts look dated. Apply these options for a modern, clean appearance: - -```javascript -slide.addChart(pres.charts.BAR, chartData, { - x: 0.5, y: 1, w: 9, h: 4, barDir: "col", - - // Custom colors (match your presentation palette) - chartColors: ["0D9488", "14B8A6", "5EEAD4"], - - // Clean background - chartArea: { fill: { color: "FFFFFF" }, roundedCorners: true }, - - // Muted axis labels - catAxisLabelColor: "64748B", - valAxisLabelColor: "64748B", - - // Subtle grid (value axis only) - valGridLine: { color: "E2E8F0", size: 0.5 }, - catGridLine: { style: "none" }, - - // Data labels on bars - showValue: true, - dataLabelPosition: "outEnd", - dataLabelColor: "1E293B", - - // Hide legend for single series - showLegend: false, -}); -``` - -**Key styling options:** -- `chartColors: [...]` - hex colors for series/segments -- `chartArea: { fill, border, roundedCorners }` - chart background -- `catGridLine/valGridLine: { color, style, size }` - grid lines (`style: "none"` to hide) -- `lineSmooth: true` - curved lines (line charts) -- `legendPos: "r"` - legend position: "b", "t", "l", "r", "tr" - ---- - -## Slide Masters - -```javascript -pres.defineSlideMaster({ - title: 'TITLE_SLIDE', background: { color: '283A5E' }, - objects: [{ - placeholder: { options: { name: 'title', type: 'title', x: 1, y: 2, w: 8, h: 2 } } - }] -}); - -let titleSlide = pres.addSlide({ masterName: "TITLE_SLIDE" }); -titleSlide.addText("My Title", { placeholder: "title" }); -``` - ---- - -## Common Pitfalls - -⚠️ These issues cause file corruption, visual bugs, or broken output. Avoid them. - -1. **NEVER use "#" with hex colors** - causes file corruption - ```javascript - color: "FF0000" // ✅ CORRECT - color: "#FF0000" // ❌ WRONG - ``` - -2. **NEVER encode opacity in hex color strings** - 8-char colors (e.g., `"00000020"`) corrupt the file. Use the `opacity` property instead. - ```javascript - shadow: { type: "outer", blur: 6, offset: 2, color: "00000020" } // ❌ CORRUPTS FILE - shadow: { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.12 } // ✅ CORRECT - ``` - -3. **Use `bullet: true`** - NEVER unicode symbols like "•" (creates double bullets) - -4. **Use `breakLine: true`** between array items or text runs together - -5. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead - -6. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects - -7. **NEVER reuse option objects across calls** - PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape. - ```javascript - const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }; - slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); // ❌ second call gets already-converted values - slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); - - const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }); - slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); // ✅ fresh object each time - slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); - ``` - -8. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead. - ```javascript - // ❌ WRONG: Accent bar doesn't cover rounded corners - slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); - slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); - - // ✅ CORRECT: Use RECTANGLE for clean alignment - slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); - slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); - ``` - ---- - -## Quick Reference - -- **Shapes**: RECTANGLE, OVAL, LINE, ROUNDED_RECTANGLE -- **Charts**: BAR, LINE, PIE, DOUGHNUT, SCATTER, BUBBLE, RADAR -- **Layouts**: LAYOUT_16x9 (10"×5.625"), LAYOUT_16x10, LAYOUT_4x3, LAYOUT_WIDE -- **Alignment**: "left", "center", "right" -- **Chart data labels**: "outEnd", "inEnd", "center" diff --git a/plugins/codex/plugins/pptx/skills/pptx/references/platform.md b/plugins/codex/plugins/pptx/skills/pptx/references/platform.md new file mode 100644 index 00000000..c3bb862f --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/references/platform.md @@ -0,0 +1,41 @@ +# 简体中文与 Windows 环境 + +以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。 + +```powershell +$env:PYTHONUTF8 = "1" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +python --version +node --version +``` + +若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。 + +需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。 + +## 字体与文本 + +- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。 +- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。 +- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。 +- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。 +- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。 + +## 转换与预览 + +Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。 + +```powershell +# 仅在自动检测不到时设置;不要求添加系统 PATH +$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx" +python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages" +``` + +按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。 + +使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。 + +## 验证与交付 + +生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。 diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py old mode 100644 new mode 100755 index 13700df0..f013ea94 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py @@ -1,51 +1,41 @@ -"""Add a new slide to an unpacked PPTX directory. +"""Add a slide to a PPTX: duplicate an existing slide or instantiate a layout. -Usage: python add_slide.py +Does all of the package bookkeeping, so the deck stays valid: + - writes the new ppt/slides/slideN.xml (and its .rels, minus any + notesSlide reference, so the source's speaker notes aren't shared) + - registers it in [Content_Types].xml + - adds a slide relationship with a fresh rId to presentation.xml.rels + - inserts with a fresh id into + — at the end, or after --after SLIDE -The source can be: - - A slide file (e.g., slide2.xml) - duplicates the slide - - A layout file (e.g., slideLayout2.xml) - creates from layout +Works on an unpacked directory (during an editing session) or directly on a +.pptx/.potx file (extracted to a temp dir, then rezipped atomically; the +temp dir is discarded, so unpack the output if you still need to edit the +new slide's content). -Examples: - python add_slide.py unpacked/ slide2.xml - # Duplicates slide2, creates slide5.xml +Usage: + python add_slide.py unpacked/ slide2.xml # duplicate slide2 + python add_slide.py unpacked/ slideLayout3.xml # new slide from a layout + python add_slide.py unpacked/ slide2.xml --after slide2.xml + python add_slide.py deck.pptx slide2.xml # rewrite deck.pptx in place + python add_slide.py deck.pptx slide2.xml -o out.pptx - python add_slide.py unpacked/ slideLayout2.xml - # Creates slide5.xml from slideLayout2.xml - -To see available layouts: ls unpacked/ppt/slideLayouts/ - -Prints the element to add to presentation.xml. +A duplicated slide still holds the source's content: edit ppt/slides/slideN.xml +(printed on success) to change it. To list layouts: ls /ppt/slideLayouts/ """ +import argparse import re import shutil import sys +from typing import NoReturn +import tempfile +import zipfile from pathlib import Path +from office.helpers import rezip, safe_extract -def get_next_slide_number(slides_dir: Path) -> int: - existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml") - if (m := re.match(r"slide(\d+)\.xml", f.name))] - return max(existing) + 1 if existing else 1 - - -def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None: - slides_dir = unpacked_dir / "ppt" / "slides" - rels_dir = slides_dir / "_rels" - layouts_dir = unpacked_dir / "ppt" / "slideLayouts" - - layout_path = layouts_dir / layout_file - if not layout_path.exists(): - print(f"Error: {layout_path} not found", file=sys.stderr) - sys.exit(1) - - next_num = get_next_slide_number(slides_dir) - dest = f"slide{next_num}.xml" - dest_slide = slides_dir / dest - dest_rels = rels_dir / f"{dest}.rels" - - slide_xml = ''' +MINIMAL_SLIDE_XML = ''' @@ -68,98 +58,25 @@ def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None: ''' - dest_slide.write_text(slide_xml, encoding="utf-8") - rels_dir.mkdir(exist_ok=True) - rels_xml = f''' - - -''' - dest_rels.write_text(rels_xml, encoding="utf-8") +SHARED_PART_TYPES = ("chart", "diagramData", "oleObject", "package") - _add_to_content_types(unpacked_dir, dest) +NOTES_SLIDE_TYPE_RE = re.compile(r"""Type=["'][^"']*/relationships/notesSlide["']""") +RELATIONSHIP_RE = re.compile(r"]*?(?:/>|>.*?)", re.DOTALL) - rid = _add_to_presentation_rels(unpacked_dir, dest) - - next_slide_id = _get_next_slide_id(unpacked_dir) - - print(f"Created {dest} from {layout_file}") - print(f'Add to presentation.xml : ') +SLIDE_ID_MIN = 256 +SLIDE_ID_MAX = 2147483647 -def duplicate_slide(unpacked_dir: Path, source: str) -> None: - slides_dir = unpacked_dir / "ppt" / "slides" - rels_dir = slides_dir / "_rels" - - source_slide = slides_dir / source - - if not source_slide.exists(): - print(f"Error: {source_slide} not found", file=sys.stderr) - sys.exit(1) - - next_num = get_next_slide_number(slides_dir) - dest = f"slide{next_num}.xml" - dest_slide = slides_dir / dest - - source_rels = rels_dir / f"{source}.rels" - dest_rels = rels_dir / f"{dest}.rels" - - shutil.copy2(source_slide, dest_slide) - - if source_rels.exists(): - shutil.copy2(source_rels, dest_rels) - - rels_content = dest_rels.read_text(encoding="utf-8") - rels_content = re.sub( - r'\s*]*Type="[^"]*notesSlide"[^>]*/>\s*', - "\n", - rels_content, - ) - dest_rels.write_text(rels_content, encoding="utf-8") - - _add_to_content_types(unpacked_dir, dest) - - rid = _add_to_presentation_rels(unpacked_dir, dest) - - next_slide_id = _get_next_slide_id(unpacked_dir) - - print(f"Created {dest} from {source}") - print(f'Add to presentation.xml : ') +def _die(msg: str) -> NoReturn: + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) -def _add_to_content_types(unpacked_dir: Path, dest: str) -> None: - content_types_path = unpacked_dir / "[Content_Types].xml" - content_types = content_types_path.read_text(encoding="utf-8") - - new_override = f'' - - if f"/ppt/slides/{dest}" not in content_types: - content_types = content_types.replace("", f" {new_override}\n") - content_types_path.write_text(content_types, encoding="utf-8") - - -def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str: - pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - pres_rels = pres_rels_path.read_text(encoding="utf-8") - - rids = [int(m) for m in re.findall(r'Id="rId(\d+)"', pres_rels)] - next_rid = max(rids) + 1 if rids else 1 - rid = f"rId{next_rid}" - - new_rel = f'' - - if f"slides/{dest}" not in pres_rels: - pres_rels = pres_rels.replace("", f" {new_rel}\n") - pres_rels_path.write_text(pres_rels, encoding="utf-8") - - return rid - - -def _get_next_slide_id(unpacked_dir: Path) -> int: - pres_path = unpacked_dir / "ppt" / "presentation.xml" - pres_content = pres_path.read_text(encoding="utf-8") - slide_ids = [int(m) for m in re.findall(r']*id="(\d+)"', pres_content)] - return max(slide_ids) + 1 if slide_ids else 256 +def get_next_slide_number(slides_dir: Path) -> int: + existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml") + if (m := re.match(r"slide(\d+)\.xml", f.name))] + return max(existing) + 1 if existing else 1 def parse_source(source: str) -> tuple[str, str | None]: @@ -169,27 +86,282 @@ def parse_source(source: str) -> tuple[str, str | None]: return ("slide", None) -if __name__ == "__main__": - if len(sys.argv) != 3: - print("Usage: python add_slide.py ", file=sys.stderr) - print("", file=sys.stderr) - print("Source can be:", file=sys.stderr) - print(" slide2.xml - duplicate an existing slide", file=sys.stderr) - print(" slideLayout2.xml - create from a layout template", file=sys.stderr) - print("", file=sys.stderr) - print("To see available layouts: ls /ppt/slideLayouts/", file=sys.stderr) - sys.exit(1) +def create_slide_from_layout(unpacked_dir: Path, layout_file: str, after: str | None = None) -> str: + slides_dir = unpacked_dir / "ppt" / "slides" + rels_dir = slides_dir / "_rels" + layout_path = unpacked_dir / "ppt" / "slideLayouts" / layout_file - unpacked_dir = Path(sys.argv[1]) - source = sys.argv[2] + if not layout_path.exists(): + _die(f"{layout_path} not found") - if not unpacked_dir.exists(): - print(f"Error: {unpacked_dir} not found", file=sys.stderr) - sys.exit(1) + next_num = get_next_slide_number(slides_dir) + dest = f"slide{next_num}.xml" + after_rid = _precheck_registration(unpacked_dir, after, dest) + slides_dir.mkdir(parents=True, exist_ok=True) - source_type, layout_file = parse_source(source) + (slides_dir / dest).write_text(MINIMAL_SLIDE_XML, encoding="utf-8") - if source_type == "layout" and layout_file is not None: - create_slide_from_layout(unpacked_dir, layout_file) + rels_dir.mkdir(exist_ok=True) + rels_xml = f''' + + +''' + (rels_dir / f"{dest}.rels").write_text(rels_xml, encoding="utf-8") + + _register_slide(unpacked_dir, dest, layout_file, after_rid) + return dest + + +def duplicate_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: + slides_dir = unpacked_dir / "ppt" / "slides" + rels_dir = slides_dir / "_rels" + source_slide = slides_dir / source + + if not source_slide.exists(): + _die(f"{source_slide} not found") + + next_num = get_next_slide_number(slides_dir) + dest = f"slide{next_num}.xml" + after_rid = _precheck_registration(unpacked_dir, after, dest) + + shutil.copy2(source_slide, slides_dir / dest) + + source_rels = rels_dir / f"{source}.rels" + shared_parts: list[str] = [] + if source_rels.exists(): + dest_rels = rels_dir / f"{dest}.rels" + shutil.copy2(source_rels, dest_rels) + rels_content = dest_rels.read_text(encoding="utf-8") + rels_content = RELATIONSHIP_RE.sub( + lambda m: "" if NOTES_SLIDE_TYPE_RE.search(m.group(0)) else m.group(0), + rels_content, + ) + dest_rels.write_text(rels_content, encoding="utf-8") + shared_parts = sorted({ + t for t in re.findall(r'Type="[^"]*/relationships/(\w+)"', rels_content) + if t in SHARED_PART_TYPES + }) + + _register_slide(unpacked_dir, dest, source, after_rid) + if shared_parts: + print( + f"Note: {dest} shares its {', '.join(shared_parts)} part(s) with {source} " + f"(they are referenced, not copied) — editing those parts changes both slides" + ) + return dest + + +def _precheck_registration(unpacked_dir: Path, after: str | None, dest: str) -> str | None: + pres_path = unpacked_dir / "ppt" / "presentation.xml" + if not pres_path.exists(): + _die(f"{pres_path} not found — is this an unpacked PPTX?") + xml = pres_path.read_text(encoding="utf-8") + + has_slot = ( + "" in xml + or re.search(r"", xml) + or "" in xml + ) + if not has_slot: + _die("presentation.xml has no (or to anchor a new one)") + + stale = [] + content_types = unpacked_dir / "[Content_Types].xml" + if content_types.exists() and f'PartName="/ppt/slides/{dest}"' in content_types.read_text(encoding="utf-8"): + stale.append("[Content_Types].xml") + pres_rels = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" + if pres_rels.exists() and _find_slide_relationship( + pres_rels.read_text(encoding="utf-8"), dest + ): + stale.append("presentation.xml.rels") + if stale: + _die( + f"{dest} is still registered in {' and '.join(stale)} but absent from ppt/slides/ — " + f"run clean.py first" + ) + + if not after: + return None + after_rid = _rid_for_slide(unpacked_dir, after) + if not re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml): + _die(f"{after} ({after_rid}) is not listed in ") + return after_rid + + +def _register_slide(unpacked_dir: Path, dest: str, source_desc: str, after_rid: str | None) -> None: + _add_to_content_types(unpacked_dir, dest) + rid = _add_to_presentation_rels(unpacked_dir, dest) + slide_id = _get_next_slide_id(unpacked_dir) + pos, total = _insert_into_sld_id_lst(unpacked_dir, slide_id, rid, after_rid) + + print(f"Created ppt/slides/{dest} from {source_desc}") + print( + f'Inserted into ' + f"at position {pos} of {total}" + ) + + +def _add_to_content_types(unpacked_dir: Path, dest: str) -> None: + content_types_path = unpacked_dir / "[Content_Types].xml" + content_types = content_types_path.read_text(encoding="utf-8") + + new_override = f'' + + if f'PartName="/ppt/slides/{dest}"' not in content_types: + content_types = content_types.replace("", f" {new_override}\n") + content_types_path.write_text(content_types, encoding="utf-8") + + +def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str: + pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" + pres_rels = pres_rels_path.read_text(encoding="utf-8") + + existing = _find_slide_relationship(pres_rels, dest) + if existing: + return existing + + pres_xml = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") + used = {int(n) for n in re.findall(r'\bId="rId(\d+)"', pres_rels)} + used |= {int(n) for n in re.findall(r'\br:id="rId(\d+)"', pres_xml)} + rid = f"rId{max(used) + 1 if used else 1}" + + new_rel = f'' + pres_rels = pres_rels.replace("", f" {new_rel}\n") + pres_rels_path.write_text(pres_rels, encoding="utf-8") + + return rid + + +def _find_slide_relationship(pres_rels: str, slide_name: str) -> str | None: + for m in re.finditer(r"]*>", pres_rels): + element = m.group(0) + if re.search(rf'Target="(?:/ppt/)?slides/{re.escape(slide_name)}"', element): + id_match = re.search(r'\bId="([^"]+)"', element) + if id_match: + return id_match.group(1) + return None + + +def _get_next_slide_id(unpacked_dir: Path) -> int: + pres_content = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") + used = {int(m) for m in re.findall(r']*\bid="(\d+)"', pres_content)} + + candidate = max((i for i in used if i >= SLIDE_ID_MIN), default=SLIDE_ID_MIN - 1) + 1 + if candidate <= SLIDE_ID_MAX and candidate not in used: + return candidate + for i in range(SLIDE_ID_MIN, SLIDE_ID_MAX + 1): + if i not in used: + return i + _die("no slide id available in [256, 2147483647] — the deck is full") + + +def _insert_into_sld_id_lst( + unpacked_dir: Path, slide_id: int, rid: str, after_rid: str | None = None +) -> tuple[int, int]: + pres_path = unpacked_dir / "ppt" / "presentation.xml" + xml = pres_path.read_text(encoding="utf-8") + entry = f'' + + if f'r:id="{rid}"' in xml: + _die(f"presentation.xml already references {rid}; refusing to add a duplicate") + + if after_rid: + open_tag = re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml) + if not open_tag: + _die(f"{after_rid} is not listed in ") + end = open_tag.end() + if not open_tag.group(0).endswith("/>"): + close = xml.find("", end) + if close == -1: + _die(f"unclosed for {after_rid} in presentation.xml") + end = close + len("") + xml = xml[:end] + entry + xml[end:] + elif "" in xml: + xml = xml.replace("", f"{entry}", 1) + elif re.search(r"", xml): + xml = re.sub(r"", f"{entry}", xml, count=1) + elif "" in xml: + xml = xml.replace( + "", f"{entry}", 1 + ) else: - duplicate_slide(unpacked_dir, source) + _die("presentation.xml has no (or to anchor a new one)") + + pres_path.write_text(xml, encoding="utf-8") + + lst = re.search(r"(.*)", xml, re.DOTALL) + entries = re.findall(r"]*>", lst.group(1)) if lst else [] + position = next( + (i for i, e in enumerate(entries, 1) if f'r:id="{rid}"' in e), len(entries) + ) + return position, len(entries) + + +def _rid_for_slide(unpacked_dir: Path, slide_name: str) -> str: + pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" + rid = _find_slide_relationship(pres_rels_path.read_text(encoding="utf-8"), slide_name) + if not rid: + _die(f"{slide_name} has no relationship in presentation.xml.rels") + return rid + + +def add_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: + source_type, layout_file = parse_source(source) + if source_type == "layout" and layout_file is not None: + return create_slide_from_layout(unpacked_dir, layout_file, after) + return duplicate_slide(unpacked_dir, source, after) + + +def add_slide_to_package( + package: Path, source: str, after: str | None = None, output: Path | None = None +) -> str: + out = output or package + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with zipfile.ZipFile(package) as zf: + safe_extract(zf, tmp_path) + dest = add_slide(tmp_path, source, after) + rezip(tmp_path, out) + print(f"Wrote {out} — the new slide is ppt/slides/{dest} inside it (unpack to edit its content)") + return dest + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Add a slide to a PPTX: duplicate a slide or instantiate a layout. " + "Registers content types, relationships, and ." + ) + parser.add_argument("target", help="Unpacked PPTX directory OR a .pptx/.potx file") + parser.add_argument( + "source", + help="slideN.xml to duplicate, or slideLayoutN.xml to create from a layout " + "(list layouts with: ls /ppt/slideLayouts/)", + ) + parser.add_argument( + "--after", + metavar="SLIDE", + help="insert after this slide, e.g. slide2.xml (default: append at the end)", + ) + parser.add_argument( + "-o", + "--output", + help="output file (only with a .pptx/.potx target; default: rewrite the input in place)", + ) + args = parser.parse_args() + + target = Path(args.target) + if target.is_dir(): + if args.output: + parser.error("--output is only valid for .pptx/.potx input; a directory is modified in place") + add_slide(target, args.source, args.after) + elif target.is_file() and target.suffix.lower() in (".pptx", ".potx"): + try: + add_slide_to_package(target, args.source, args.after, Path(args.output) if args.output else None) + except (OSError, ValueError, zipfile.BadZipFile) as e: + _die(str(e)) + else: + _die(f"{target} is neither a directory nor a .pptx/.potx file") + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py old mode 100644 new mode 100755 index 3d13994c..551dd231 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py @@ -15,13 +15,30 @@ This script removes: - Content-Type overrides for deleted files """ +import posixpath +import re import sys from pathlib import Path import defusedxml.minidom +from office.helpers import SLIDE_REL_TYPE, opc_target, rels_source_part -import re + +def _slide_rids(pres_rels_path: Path, unpacked_dir: Path) -> dict[str, str]: + source_part = rels_source_part(pres_rels_path, unpacked_dir) + rels_dom = defusedxml.minidom.parse(str(pres_rels_path)) + + rids: dict[str, str] = {} + for rel in rels_dom.getElementsByTagName("Relationship"): + if rel.getAttribute("Type") != SLIDE_REL_TYPE: + continue + part = opc_target( + rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") + ) + if part is not None: + rids[rel.getAttribute("Id")] = part + return rids def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]: @@ -31,19 +48,20 @@ def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]: if not pres_path.exists() or not pres_rels_path.exists(): return set() - rels_dom = defusedxml.minidom.parse(str(pres_rels_path)) - rid_to_slide = {} - for rel in rels_dom.getElementsByTagName("Relationship"): - rid = rel.getAttribute("Id") - target = rel.getAttribute("Target") - rel_type = rel.getAttribute("Type") - if "slide" in rel_type and target.startswith("slides/"): - rid_to_slide[rid] = target.replace("slides/", "") + rid_to_slide = _slide_rids(pres_rels_path, unpacked_dir) pres_content = pres_path.read_text(encoding="utf-8") referenced_rids = set(re.findall(r']*r:id="([^"]+)"', pres_content)) - return {rid_to_slide[rid] for rid in referenced_rids if rid in rid_to_slide} + return { + posixpath.basename(rid_to_slide[rid]) + for rid in referenced_rids + if rid in rid_to_slide + } + + +class RefusedToClean(Exception): + """The package does not look the way a readable package should.""" def remove_orphaned_slides(unpacked_dir: Path) -> list[str]: @@ -55,9 +73,25 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]: return [] referenced_slides = get_slides_in_sldidlst(unpacked_dir) + on_disk = sorted(slides_dir.glob("slide*.xml")) + + if on_disk and not any(s.name in referenced_slides for s in on_disk): + listed = re.findall( + r']*r:id="([^"]+)"', + (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") + if (unpacked_dir / "ppt" / "presentation.xml").exists() + else "", + ) + if listed: + raise RefusedToClean( + f" lists {len(listed)} slide(s) and none of the " + f"{len(on_disk)} slide(s) on disk match any of them. Refusing to " + f"delete them all — this is a parse failure, not an empty deck." + ) + removed = [] - for slide_file in slides_dir.glob("slide*.xml"): + for slide_file in on_disk: if slide_file.name not in referenced_slides: rel_path = slide_file.relative_to(unpacked_dir) slide_file.unlink() @@ -70,16 +104,21 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]: if removed and pres_rels_path.exists(): rels_dom = defusedxml.minidom.parse(str(pres_rels_path)) + source_part = rels_source_part(pres_rels_path, unpacked_dir) changed = False for rel in list(rels_dom.getElementsByTagName("Relationship")): - target = rel.getAttribute("Target") - if target.startswith("slides/"): - slide_name = target.replace("slides/", "") - if slide_name not in referenced_slides: - if rel.parentNode: - rel.parentNode.removeChild(rel) - changed = True + if rel.getAttribute("Type") != SLIDE_REL_TYPE: + continue + part = opc_target( + rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") + ) + if part is None: + continue + if posixpath.basename(part) not in referenced_slides: + if rel.parentNode: + rel.parentNode.removeChild(rel) + changed = True if changed: with open(pres_rels_path, "wb") as f: @@ -103,24 +142,18 @@ def remove_trash_directory(unpacked_dir: Path) -> list[str]: return removed -def get_slide_referenced_files(unpacked_dir: Path) -> set: +def _referenced_by(rels_files, unpacked_dir: Path) -> set: referenced = set() - slides_rels_dir = unpacked_dir / "ppt" / "slides" / "_rels" - if not slides_rels_dir.exists(): - return referenced - - for rels_file in slides_rels_dir.glob("*.rels"): + for rels_file in rels_files: + source_part = rels_source_part(rels_file, unpacked_dir) dom = defusedxml.minidom.parse(str(rels_file)) for rel in dom.getElementsByTagName("Relationship"): - target = rel.getAttribute("Target") - if not target: - continue - target_path = (rels_file.parent.parent / target).resolve() - try: - referenced.add(target_path.relative_to(unpacked_dir.resolve())) - except ValueError: - pass + part = opc_target( + rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") + ) + if part is not None: + referenced.add(Path(part)) return referenced @@ -128,7 +161,6 @@ def get_slide_referenced_files(unpacked_dir: Path) -> set: def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]: resource_dirs = ["charts", "diagrams", "drawings"] removed = [] - slide_referenced = get_slide_referenced_files(unpacked_dir) for dir_name in resource_dirs: rels_dir = unpacked_dir / "ppt" / dir_name / "_rels" @@ -137,35 +169,15 @@ def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]: for rels_file in rels_dir.glob("*.rels"): resource_file = rels_dir.parent / rels_file.name.replace(".rels", "") - try: - resource_rel_path = resource_file.resolve().relative_to(unpacked_dir.resolve()) - except ValueError: - continue - - if not resource_file.exists() or resource_rel_path not in slide_referenced: + if not resource_file.exists(): rels_file.unlink() - rel_path = rels_file.relative_to(unpacked_dir) - removed.append(str(rel_path)) + removed.append(str(rels_file.relative_to(unpacked_dir))) return removed def get_referenced_files(unpacked_dir: Path) -> set: - referenced = set() - - for rels_file in unpacked_dir.rglob("*.rels"): - dom = defusedxml.minidom.parse(str(rels_file)) - for rel in dom.getElementsByTagName("Relationship"): - target = rel.getAttribute("Target") - if not target: - continue - target_path = (rels_file.parent.parent / target).resolve() - try: - referenced.add(target_path.relative_to(unpacked_dir.resolve())) - except ValueError: - pass - - return referenced + return _referenced_by(sorted(unpacked_dir.rglob("*.rels")), unpacked_dir) def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]: @@ -241,6 +253,12 @@ def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None: def clean_unused_files(unpacked_dir: Path) -> list[str]: all_removed = [] + if list(unpacked_dir.rglob("*.rels")) and not get_referenced_files(unpacked_dir): + raise RefusedToClean( + "no relationship in this package names a part we can resolve. " + "Refusing to treat every file as unreferenced." + ) + slides_removed = remove_orphaned_slides(unpacked_dir) all_removed.extend(slides_removed) @@ -276,7 +294,12 @@ if __name__ == "__main__": print(f"Error: {unpacked_dir} not found", file=sys.stderr) sys.exit(1) - removed = clean_unused_files(unpacked_dir) + try: + removed = clean_unused_files(unpacked_dir) + except (RefusedToClean, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + print("Nothing was deleted.", file=sys.stderr) + sys.exit(1) if removed: print(f"Removed {len(removed)} unreferenced files:") diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py index e69de29b..d3c5817c 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py @@ -0,0 +1,111 @@ +import os +import posixpath +import re +import stat +import tempfile +import urllib.parse +import zipfile +from pathlib import Path + +OOXML_FAMILY = { + ".docx": "docx", + ".dotx": "docx", + ".pptx": "pptx", + ".potx": "pptx", + ".xlsx": "xlsx", + ".xltx": "xlsx", +} + +_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") + +SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" + + +def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: + if not target: + return None + if target_mode.lower() == "external": + return None + if _SCHEME_RE.match(target): + return None + + target = urllib.parse.unquote(target) + + if "\\" in target: + raise ValueError(f"relationship target is not a POSIX part name: {target!r}") + + if target.startswith("/"): + joined = target.lstrip("/") + else: + joined = posixpath.join(posixpath.dirname(source_part), target) + + parts: list[str] = [] + for segment in posixpath.normpath(joined).split("/"): + if segment in ("", "."): + continue + if segment == "..": + if not parts: + raise ValueError(f"relationship target escapes the package: {target!r}") + parts.pop() + else: + parts.append(segment) + + if not parts: + raise ValueError(f"relationship target resolves to nothing: {target!r}") + return "/".join(parts) + + +def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: + owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) + return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") + + +def part_text(data: bytes) -> str: + return data.decode("utf-8", "surrogateescape") + + +XML_SPACE = " \t\r\n" + + +def rendered_text(text: str, preserve: bool) -> str: + return text if preserve else text.strip(XML_SPACE) + + +def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: + dest = dest.resolve() + for m in zf.infolist(): + if stat.S_ISLNK(m.external_attr >> 16): + raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") + target = (dest / m.filename).resolve() + if not target.is_relative_to(dest): + raise ValueError(f"unsafe archive entry: {m.filename!r}") + zf.extract(m, dest) + + +def rezip(src_dir: Path, out_path: Path) -> None: + files = sorted(p for p in src_dir.rglob("*") if p.is_file()) + ct = src_dir / "[Content_Types].xml" + fd, tmp_name = tempfile.mkstemp( + prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent + ) + tmp_out = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: + if ct.exists(): + zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) + for f in files: + if f == ct: + continue + zf.write(f, f.relative_to(src_dir)) + if out_path.exists(): + mode = out_path.stat().st_mode & 0o777 + else: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + os.chmod(tmp_out, mode) + os.replace(tmp_out, out_path) + finally: + if tmp_out.exists(): + tmp_out.unlink() diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py deleted file mode 100644 index ad7c25ee..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Merge adjacent runs with identical formatting in DOCX. - -Merges adjacent elements that have identical properties. -Works on runs in paragraphs and inside tracked changes (, ). - -Also: -- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) -- Removes proofErr elements (spell/grammar markers that block merging) -""" - -from pathlib import Path - -import defusedxml.minidom - - -def merge_runs(input_dir: str) -> tuple[int, str]: - doc_xml = Path(input_dir) / "word" / "document.xml" - - if not doc_xml.exists(): - return 0, f"Error: {doc_xml} not found" - - try: - dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) - root = dom.documentElement - - _remove_elements(root, "proofErr") - _strip_run_rsid_attrs(root) - - containers = {run.parentNode for run in _find_elements(root, "r")} - - merge_count = 0 - for container in containers: - merge_count += _merge_runs_in(container) - - doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) - return merge_count, f"Merged {merge_count} runs" - - except Exception as e: - return 0, f"Error: {e}" - - - - -def _find_elements(root, tag: str) -> list: - results = [] - - def traverse(node): - if node.nodeType == node.ELEMENT_NODE: - name = node.localName or node.tagName - if name == tag or name.endswith(f":{tag}"): - results.append(node) - for child in node.childNodes: - traverse(child) - - traverse(root) - return results - - -def _get_child(parent, tag: str): - for child in parent.childNodes: - if child.nodeType == child.ELEMENT_NODE: - name = child.localName or child.tagName - if name == tag or name.endswith(f":{tag}"): - return child - return None - - -def _get_children(parent, tag: str) -> list: - results = [] - for child in parent.childNodes: - if child.nodeType == child.ELEMENT_NODE: - name = child.localName or child.tagName - if name == tag or name.endswith(f":{tag}"): - results.append(child) - return results - - -def _is_adjacent(elem1, elem2) -> bool: - node = elem1.nextSibling - while node: - if node == elem2: - return True - if node.nodeType == node.ELEMENT_NODE: - return False - if node.nodeType == node.TEXT_NODE and node.data.strip(): - return False - node = node.nextSibling - return False - - - - -def _remove_elements(root, tag: str): - for elem in _find_elements(root, tag): - if elem.parentNode: - elem.parentNode.removeChild(elem) - - -def _strip_run_rsid_attrs(root): - for run in _find_elements(root, "r"): - for attr in list(run.attributes.values()): - if "rsid" in attr.name.lower(): - run.removeAttribute(attr.name) - - - - -def _merge_runs_in(container) -> int: - merge_count = 0 - run = _first_child_run(container) - - while run: - while True: - next_elem = _next_element_sibling(run) - if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): - _merge_run_content(run, next_elem) - container.removeChild(next_elem) - merge_count += 1 - else: - break - - _consolidate_text(run) - run = _next_sibling_run(run) - - return merge_count - - -def _first_child_run(container): - for child in container.childNodes: - if child.nodeType == child.ELEMENT_NODE and _is_run(child): - return child - return None - - -def _next_element_sibling(node): - sibling = node.nextSibling - while sibling: - if sibling.nodeType == sibling.ELEMENT_NODE: - return sibling - sibling = sibling.nextSibling - return None - - -def _next_sibling_run(node): - sibling = node.nextSibling - while sibling: - if sibling.nodeType == sibling.ELEMENT_NODE: - if _is_run(sibling): - return sibling - sibling = sibling.nextSibling - return None - - -def _is_run(node) -> bool: - name = node.localName or node.tagName - return name == "r" or name.endswith(":r") - - -def _can_merge(run1, run2) -> bool: - rpr1 = _get_child(run1, "rPr") - rpr2 = _get_child(run2, "rPr") - - if (rpr1 is None) != (rpr2 is None): - return False - if rpr1 is None: - return True - return rpr1.toxml() == rpr2.toxml() - - -def _merge_run_content(target, source): - for child in list(source.childNodes): - if child.nodeType == child.ELEMENT_NODE: - name = child.localName or child.tagName - if name != "rPr" and not name.endswith(":rPr"): - target.appendChild(child) - - -def _consolidate_text(run): - t_elements = _get_children(run, "t") - - for i in range(len(t_elements) - 1, 0, -1): - curr, prev = t_elements[i], t_elements[i - 1] - - if _is_adjacent(prev, curr): - prev_text = prev.firstChild.data if prev.firstChild else "" - curr_text = curr.firstChild.data if curr.firstChild else "" - merged = prev_text + curr_text - - if prev.firstChild: - prev.firstChild.data = merged - else: - prev.appendChild(run.ownerDocument.createTextNode(merged)) - - if merged.startswith(" ") or merged.endswith(" "): - prev.setAttribute("xml:space", "preserve") - elif prev.hasAttribute("xml:space"): - prev.removeAttribute("xml:space") - - run.removeChild(curr) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py new file mode 100644 index 00000000..209cb7c5 --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py @@ -0,0 +1,170 @@ +"""Find chart XML that PowerPoint refuses but the schema accepts. + +Detection only: for either fault more than one repair is valid, and only the +author knows which was meant. +""" + + +from __future__ import annotations + +import re +from typing import Mapping + +from . import part_text + + +_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") + +_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") +_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") + +def _strip_ext_lst(text: str) -> str: + out, cursor = [], 0 + for lo, hi in _ext_lst_spans(text): + out.append(text[cursor:lo]) + cursor = hi + out.append(text[cursor:]) + return "".join(out) + +_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) +ILLEGAL_ON_STACKED = frozenset({"outEnd"}) +LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") + + +def _check_stacked_label_positions(part: str, xml: str) -> list[str]: + problems: list[str] = [] + for match in _BAR_GROUP_RE.finditer(xml): + block = _strip_ext_lst(match.group(0)) + group = match.group(1) + + grouping = _GROUPING_RE.search(block) + if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: + continue + + bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] + for pos in sorted(set(bad)): + problems.append( + f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' + f"{grouping.group(1)} {group}; PowerPoint allows only " + f"{', '.join(LEGAL_ON_STACKED)} there" + ) + return problems + + + +_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +_AXID_RE = re.compile( + r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" +) + +_AXIS_DECL_RE = re.compile( + r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" +) + +AXID_LIMIT = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, + "surfaceChart": 3, "surface3DChart": 3, +} + +AXID_MINIMUM = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, + "line3DChart": 3, "surface3DChart": 3, +} + + +def _declared_axes(xml: str) -> dict[str, list[str]]: + axes: dict[str, list[str]] = {} + for kind, axid in _AXIS_DECL_RE.findall(xml): + axes.setdefault(kind, []).append(axid) + return axes + + +def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: + category = axes.get("catAx", []) + axes.get("dateAx", []) + value = axes.get("valAx", []) + series = axes.get("serAx", []) + if len(category) != 1 or len(value) != 1 or len(series) > 1: + return None + ids = [category[0], value[0]] + if limit >= 3 and series: + ids.append(series[0]) + return ids + + +def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: + if kind not in AXID_LIMIT: + return None + ids = _AXID_RE.findall(block) + declared = {i for group in axes.values() for i in group} + if len([i for i in ids if i in declared]) >= 2: + return None + return ids + + +def _check_chart_axis_references(part: str, xml: str) -> list[str]: + axes = _declared_axes(xml) + problems: list[str] = [] + declared = {i for group in axes.values() for i in group} + for match in _ANY_CHART_GROUP_RE.finditer(xml): + kind, block = match.group(1), match.group(0) + ids = _undeclared_axes(kind, block, axes) + if ids is None: + continue + if not ids: + problems.append( + f"{part}: declares no this part can resolve; a chart " + f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" + ) + continue + dead = [i for i in ids if i not in declared] + canonical = _canonical_ids(axes, AXID_LIMIT[kind]) + if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: + hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" + else: + hint = ("Fix: the part declares several axes of a kind -- declare the " + "secondary axes the series expects, or drop them") + detail = (f"of which {', '.join(dead)} name no declared axis" + if dead else f"only {len(ids)} of which this part declares") + problems.append( + f"{part}: references axId {', '.join(ids)}, {detail}, " + f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" + ) + return problems + + +def _ext_lst_spans(text: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + depth = 0 + start = 0 + for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): + closing, self_closing = match.group(1), match.group(2) + if self_closing: + continue + if closing: + depth -= 1 + if depth == 0: + spans.append((start, match.end())) + else: + if depth == 0: + start = match.start() + depth += 1 + return spans + + +CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) + + +def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: + problems: list[str] = [] + for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): + xml = part_text(files[part]) + for check in CHART_CHECKS: + problems.extend(check(part, xml)) + return problems diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_slide.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_slide.py new file mode 100644 index 00000000..22f9aee0 --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_slide.py @@ -0,0 +1,60 @@ +"""Pick the slide-XML schema errors PowerPoint refuses the file over. + +A denylist over lxml's messages, so an unrecognised error class is a miss rather +than a false alarm. +""" + + +from __future__ import annotations + +import re + +SLIDE_PART_RE = re.compile( + r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" + r"/[^/]+\.xml" +) + +FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"\}tableStyleId': This element is not expected"), + "two in one (the schema allows one)", + ), + ( + re.compile(r"\}srgbClr', attribute 'val'"), + "a colour that is not six hex digits", + ), + ( + re.compile(r"\}txBody': Missing child element"), + "a with no children", + ), + ( + re.compile(r"\}miter', attribute 'lim'"), + 'a line join with lim="NaN"', + ), + ( + re.compile(r"\}uLnTx': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}overrideClrMapping': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}nvGrpSpPr': Missing child element"), + "a with no children", + ), +) + + +def is_schema_verdict(error: str) -> bool: + return error.startswith("Element ") + + +def fatal_slide_errors(errors: set[str]) -> list[str]: + out = [] + for error in sorted(errors): + for pattern, meaning in FATAL_SLIDE_ERRORS: + if pattern.search(error): + out.append(f"{meaning}: {error}") + break + return out diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_theme.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_theme.py new file mode 100644 index 00000000..84466201 --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_theme.py @@ -0,0 +1,114 @@ +"""Find masters sharing a theme part in the way PowerPoint refuses to open. + +Reports only; the fix is to move back to directly after + in ppt/presentation.xml. +""" + + +from __future__ import annotations + +import posixpath +import re +from typing import Mapping + +from . import part_text + +THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" + +_MASTER_RE = re.compile( + r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" + r"(?:slide|notes|handout)Master(?P\d+)\.xml$" +) +_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} + +_RELATIONSHIP_RE = re.compile( + r"]*?(?:/>|>.*?)", re.DOTALL +) + + +def _sort_key(name: str) -> tuple[int, int]: + m = _MASTER_RE.match(name) + assert m is not None + return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) + + +def _rels_path(part: str) -> str: + directory, base = posixpath.split(part) + return f"{directory}/_rels/{base}.rels" + + +def _resolve(rels_path: str, target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + part_dir = posixpath.dirname(posixpath.dirname(rels_path)) + return posixpath.normpath(posixpath.join(part_dir, target)) + + +def _theme_rel(files: Mapping[str, bytes], master: str): + rels_path = _rels_path(master) + rels = files.get(rels_path) + if rels is None: + return None + for element in _RELATIONSHIP_RE.findall(part_text(rels)): + if f'Type="{THEME_REL_TYPE}"' not in element: + continue + target = re.search(r'\bTarget="([^"]+)"', element) + if target is None: + continue + return rels_path, element, _resolve(rels_path, target.group(1)) + return None + + +def _masters(files: Mapping[str, bytes]) -> list[str]: + return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) + + +_PRESENTATION = "ppt/presentation.xml" +_NOTES_MASTERS = "ppt/notesMasters/" +_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) +_AFTER_SLDIDLST_RE = re.compile( + r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL +) + + +def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: + data = files.get(_PRESENTATION) + if data is None: + return False + match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) + return match is not None and match.group(1) == " bool: + return inert_notes and master.startswith(_NOTES_MASTERS) + + +def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + ] + + +def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + inert_notes = _notes_master_share_is_inert(files) + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + if not _is_inert(master, inert_notes) + ] diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/simplify_redlines.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/simplify_redlines.py deleted file mode 100644 index db963bb9..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/simplify_redlines.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Simplify tracked changes by merging adjacent w:ins or w:del elements. - -Merges adjacent elements from the same author into a single element. -Same for elements. This makes heavily-redlined documents easier to -work with by reducing the number of tracked change wrappers. - -Rules: -- Only merges w:ins with w:ins, w:del with w:del (same element type) -- Only merges if same author (ignores timestamp differences) -- Only merges if truly adjacent (only whitespace between them) -""" - -import xml.etree.ElementTree as ET -import zipfile -from pathlib import Path - -import defusedxml.minidom - -WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - -def simplify_redlines(input_dir: str) -> tuple[int, str]: - doc_xml = Path(input_dir) / "word" / "document.xml" - - if not doc_xml.exists(): - return 0, f"Error: {doc_xml} not found" - - try: - dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) - root = dom.documentElement - - merge_count = 0 - - containers = _find_elements(root, "p") + _find_elements(root, "tc") - - for container in containers: - merge_count += _merge_tracked_changes_in(container, "ins") - merge_count += _merge_tracked_changes_in(container, "del") - - doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) - return merge_count, f"Simplified {merge_count} tracked changes" - - except Exception as e: - return 0, f"Error: {e}" - - -def _merge_tracked_changes_in(container, tag: str) -> int: - merge_count = 0 - - tracked = [ - child - for child in container.childNodes - if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) - ] - - if len(tracked) < 2: - return 0 - - i = 0 - while i < len(tracked) - 1: - curr = tracked[i] - next_elem = tracked[i + 1] - - if _can_merge_tracked(curr, next_elem): - _merge_tracked_content(curr, next_elem) - container.removeChild(next_elem) - tracked.pop(i + 1) - merge_count += 1 - else: - i += 1 - - return merge_count - - -def _is_element(node, tag: str) -> bool: - name = node.localName or node.tagName - return name == tag or name.endswith(f":{tag}") - - -def _get_author(elem) -> str: - author = elem.getAttribute("w:author") - if not author: - for attr in elem.attributes.values(): - if attr.localName == "author" or attr.name.endswith(":author"): - return attr.value - return author - - -def _can_merge_tracked(elem1, elem2) -> bool: - if _get_author(elem1) != _get_author(elem2): - return False - - node = elem1.nextSibling - while node and node != elem2: - if node.nodeType == node.ELEMENT_NODE: - return False - if node.nodeType == node.TEXT_NODE and node.data.strip(): - return False - node = node.nextSibling - - return True - - -def _merge_tracked_content(target, source): - while source.firstChild: - child = source.firstChild - source.removeChild(child) - target.appendChild(child) - - -def _find_elements(root, tag: str) -> list: - results = [] - - def traverse(node): - if node.nodeType == node.ELEMENT_NODE: - name = node.localName or node.tagName - if name == tag or name.endswith(f":{tag}"): - results.append(node) - for child in node.childNodes: - traverse(child) - - traverse(root) - return results - - -def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: - if not doc_xml_path.exists(): - return {} - - try: - tree = ET.parse(doc_xml_path) - root = tree.getroot() - except ET.ParseError: - return {} - - namespaces = {"w": WORD_NS} - author_attr = f"{{{WORD_NS}}}author" - - authors: dict[str, int] = {} - for tag in ["ins", "del"]: - for elem in root.findall(f".//w:{tag}", namespaces): - author = elem.get(author_attr) - if author: - authors[author] = authors.get(author, 0) + 1 - - return authors - - -def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: - try: - with zipfile.ZipFile(docx_path, "r") as zf: - if "word/document.xml" not in zf.namelist(): - return {} - with zf.open("word/document.xml") as f: - tree = ET.parse(f) - root = tree.getroot() - - namespaces = {"w": WORD_NS} - author_attr = f"{{{WORD_NS}}}author" - - authors: dict[str, int] = {} - for tag in ["ins", "del"]: - for elem in root.findall(f".//w:{tag}", namespaces): - author = elem.get(author_attr) - if author: - authors[author] = authors.get(author, 0) + 1 - return authors - except (zipfile.BadZipFile, ET.ParseError): - return {} - - -def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: - modified_xml = modified_dir / "word" / "document.xml" - modified_authors = get_tracked_change_authors(modified_xml) - - if not modified_authors: - return default - - original_authors = _get_authors_from_docx(original_docx) - - new_changes: dict[str, int] = {} - for author, count in modified_authors.items(): - original_count = original_authors.get(author, 0) - diff = count - original_count - if diff > 0: - new_changes[author] = diff - - if not new_changes: - return default - - if len(new_changes) == 1: - return next(iter(new_changes)) - - raise ValueError( - f"Multiple authors added new changes: {new_changes}. " - "Cannot infer which author to validate." - ) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/pack.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/pack.py deleted file mode 100644 index db29ed8b..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/pack.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Pack a directory into a DOCX, PPTX, or XLSX file. - -Validates with auto-repair, condenses XML formatting, and creates the Office file. - -Usage: - python pack.py [--original ] [--validate true|false] - -Examples: - python pack.py unpacked/ output.docx --original input.docx - python pack.py unpacked/ output.pptx --validate false -""" - -import argparse -import sys -import shutil -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom - -from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator - -def pack( - input_directory: str, - output_file: str, - original_file: str | None = None, - validate: bool = True, - infer_author_func=None, -) -> tuple[None, str]: - input_dir = Path(input_directory) - output_path = Path(output_file) - suffix = output_path.suffix.lower() - - if not input_dir.is_dir(): - return None, f"Error: {input_dir} is not a directory" - - if suffix not in {".docx", ".pptx", ".xlsx"}: - return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" - - if validate and original_file: - original_path = Path(original_file) - if original_path.exists(): - success, output = _run_validation( - input_dir, original_path, suffix, infer_author_func - ) - if output: - print(output) - if not success: - return None, f"Error: Validation failed for {input_dir}" - - with tempfile.TemporaryDirectory() as temp_dir: - temp_content_dir = Path(temp_dir) / "content" - shutil.copytree(input_dir, temp_content_dir) - - for pattern in ["*.xml", "*.rels"]: - for xml_file in temp_content_dir.rglob(pattern): - _condense_xml(xml_file) - - output_path.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: - for f in temp_content_dir.rglob("*"): - if f.is_file(): - zf.write(f, f.relative_to(temp_content_dir)) - - return None, f"Successfully packed {input_dir} to {output_file}" - - -def _run_validation( - unpacked_dir: Path, - original_file: Path, - suffix: str, - infer_author_func=None, -) -> tuple[bool, str | None]: - output_lines = [] - validators = [] - - if suffix == ".docx": - author = "Claude" - if infer_author_func: - try: - author = infer_author_func(unpacked_dir, original_file) - except ValueError as e: - print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) - - validators = [ - DOCXSchemaValidator(unpacked_dir, original_file), - RedliningValidator(unpacked_dir, original_file, author=author), - ] - elif suffix == ".pptx": - validators = [PPTXSchemaValidator(unpacked_dir, original_file)] - - if not validators: - return True, None - - total_repairs = sum(v.repair() for v in validators) - if total_repairs: - output_lines.append(f"Auto-repaired {total_repairs} issue(s)") - - success = all(v.validate() for v in validators) - - if success: - output_lines.append("All validations PASSED!") - - return success, "\n".join(output_lines) if output_lines else None - - -def _condense_xml(xml_file: Path) -> None: - try: - with open(xml_file, encoding="utf-8") as f: - dom = defusedxml.minidom.parse(f) - - for element in dom.getElementsByTagName("*"): - if element.tagName.endswith(":t"): - continue - - for child in list(element.childNodes): - if ( - child.nodeType == child.TEXT_NODE - and child.nodeValue - and child.nodeValue.strip() == "" - ) or child.nodeType == child.COMMENT_NODE: - element.removeChild(child) - - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - except Exception as e: - print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) - raise - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Pack a directory into a DOCX, PPTX, or XLSX file" - ) - parser.add_argument("input_directory", help="Unpacked Office document directory") - parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") - parser.add_argument( - "--original", - help="Original file for validation comparison", - ) - parser.add_argument( - "--validate", - type=lambda x: x.lower() == "true", - default=True, - metavar="true|false", - help="Run validation with auto-repair (default: true)", - ) - args = parser.parse_args() - - _, message = pack( - args.input_directory, - args.output_file, - original_file=args.original, - validate=args.validate, - ) - print(message) - - if "Error" in message: - sys.exit(1) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/soffice.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/soffice.py index c7f7e328..49bfe9f9 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/soffice.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/soffice.py @@ -1,183 +1,61 @@ -""" -Helper for running LibreOffice (soffice) in environments where AF_UNIX -sockets may be blocked (e.g., sandboxed VMs). Detects the restriction -at runtime and applies an LD_PRELOAD shim if needed. - -Usage: - from office.soffice import run_soffice, get_soffice_env - - # Option 1 – run soffice directly - result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) - - # Option 2 – get env dict for your own subprocess calls - env = get_soffice_env() - subprocess.run(["soffice", ...], env=env) -""" +"""Portable LibreOffice runner for Windows, macOS and Linux.""" import os -import socket +import platform +import shutil import subprocess +import sys import tempfile from pathlib import Path -def get_soffice_env() -> dict: +def resolve_soffice_binary(): + explicit = os.environ.get("SOFFICE_PATH") + if explicit: + candidate = Path(explicit).expanduser() + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError(f"SOFFICE_PATH does not exist: {candidate}") + names = ("soffice.com", "soffice.exe", "soffice") if platform.system() == "Windows" else ("soffice",) + for name in names: + binary = shutil.which(name) + if binary: + return binary + candidates = [Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")] + if platform.system() == "Windows": + candidates = [ + Path(root) / "LibreOffice/program" / name + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")) if root + for name in ("soffice.com", "soffice.exe") + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError("LibreOffice not found. Install it or set SOFFICE_PATH to its executable.") + + +def get_soffice_env(): env = os.environ.copy() - env["SAL_USE_VCLPLUGIN"] = "svp" - - if _needs_shim(): - shim = _ensure_shim() - env["LD_PRELOAD"] = str(shim) - + env["PYTHONUTF8"] = "1" + if platform.system() == "Linux": + env.setdefault("SAL_USE_VCLPLUGIN", "svp") return env -def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: - env = get_soffice_env() - return subprocess.run(["soffice"] + args, env=env, **kwargs) - - - -_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" - - -def _needs_shim() -> bool: - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.close() - return False - except OSError: - return True - - -def _ensure_shim() -> Path: - if _SHIM_SO.exists(): - return _SHIM_SO - - src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" - src.write_text(_SHIM_SOURCE) - subprocess.run( - ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], - check=True, - capture_output=True, - ) - src.unlink() - return _SHIM_SO - - - -_SHIM_SOURCE = r""" -#define _GNU_SOURCE -#include -#include -#include -#include -#include -#include -#include - -static int (*real_socket)(int, int, int); -static int (*real_socketpair)(int, int, int, int[2]); -static int (*real_listen)(int, int); -static int (*real_accept)(int, struct sockaddr *, socklen_t *); -static int (*real_close)(int); -static int (*real_read)(int, void *, size_t); - -/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ -static int is_shimmed[1024]; -static int peer_of[1024]; -static int wake_r[1024]; /* accept() blocks reading this */ -static int wake_w[1024]; /* close() writes to this */ -static int listener_fd = -1; /* FD that received listen() */ - -__attribute__((constructor)) -static void init(void) { - real_socket = dlsym(RTLD_NEXT, "socket"); - real_socketpair = dlsym(RTLD_NEXT, "socketpair"); - real_listen = dlsym(RTLD_NEXT, "listen"); - real_accept = dlsym(RTLD_NEXT, "accept"); - real_close = dlsym(RTLD_NEXT, "close"); - real_read = dlsym(RTLD_NEXT, "read"); - for (int i = 0; i < 1024; i++) { - peer_of[i] = -1; - wake_r[i] = -1; - wake_w[i] = -1; - } -} - -/* ---- socket ---------------------------------------------------------- */ -int socket(int domain, int type, int protocol) { - if (domain == AF_UNIX) { - int fd = real_socket(domain, type, protocol); - if (fd >= 0) return fd; - /* socket(AF_UNIX) blocked – fall back to socketpair(). */ - int sv[2]; - if (real_socketpair(domain, type, protocol, sv) == 0) { - if (sv[0] >= 0 && sv[0] < 1024) { - is_shimmed[sv[0]] = 1; - peer_of[sv[0]] = sv[1]; - int wp[2]; - if (pipe(wp) == 0) { - wake_r[sv[0]] = wp[0]; - wake_w[sv[0]] = wp[1]; - } - } - return sv[0]; - } - errno = EPERM; - return -1; - } - return real_socket(domain, type, protocol); -} - -/* ---- listen ---------------------------------------------------------- */ -int listen(int sockfd, int backlog) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - listener_fd = sockfd; - return 0; - } - return real_listen(sockfd, backlog); -} - -/* ---- accept ---------------------------------------------------------- */ -int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - /* Block until close() writes to the wake pipe. */ - if (wake_r[sockfd] >= 0) { - char buf; - real_read(wake_r[sockfd], &buf, 1); - } - errno = ECONNABORTED; - return -1; - } - return real_accept(sockfd, addr, addrlen); -} - -/* ---- close ----------------------------------------------------------- */ -int close(int fd) { - if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { - int was_listener = (fd == listener_fd); - is_shimmed[fd] = 0; - - if (wake_w[fd] >= 0) { /* unblock accept() */ - char c = 0; - write(wake_w[fd], &c, 1); - real_close(wake_w[fd]); - wake_w[fd] = -1; - } - if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } - if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } - - if (was_listener) - _exit(0); /* conversion done – exit */ - } - return real_close(fd); -} -""" - +def run_soffice(args, **kwargs): + binary = resolve_soffice_binary() + kwargs.setdefault("env", get_soffice_env()) + kwargs.setdefault("timeout", 120) + if any(str(arg).startswith("-env:UserInstallation=") for arg in args): + return subprocess.run([binary, *args], **kwargs) + with tempfile.TemporaryDirectory(prefix="skill-lo-", ignore_cleanup_errors=True) as profile: + profile_arg = f"-env:UserInstallation={Path(profile).resolve().as_uri()}" + return subprocess.run([binary, profile_arg, *args], **kwargs) if __name__ == "__main__": - import sys - result = run_soffice(sys.argv[1:]) - sys.exit(result.returncode) + try: + raise SystemExit(run_soffice(sys.argv[1:]).returncode) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/unpack.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/unpack.py deleted file mode 100644 index 00152533..00000000 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/unpack.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Unpack Office files (DOCX, PPTX, XLSX) for editing. - -Extracts the ZIP archive, pretty-prints XML files, and optionally: -- Merges adjacent runs with identical formatting (DOCX only) -- Simplifies adjacent tracked changes from same author (DOCX only) - -Usage: - python unpack.py [options] - -Examples: - python unpack.py document.docx unpacked/ - python unpack.py presentation.pptx unpacked/ - python unpack.py document.docx unpacked/ --merge-runs false -""" - -import argparse -import sys -import zipfile -from pathlib import Path - -import defusedxml.minidom - -from helpers.merge_runs import merge_runs as do_merge_runs -from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines - -SMART_QUOTE_REPLACEMENTS = { - "\u201c": "“", - "\u201d": "”", - "\u2018": "‘", - "\u2019": "’", -} - - -def unpack( - input_file: str, - output_directory: str, - merge_runs: bool = True, - simplify_redlines: bool = True, -) -> tuple[None, str]: - input_path = Path(input_file) - output_path = Path(output_directory) - suffix = input_path.suffix.lower() - - if not input_path.exists(): - return None, f"Error: {input_file} does not exist" - - if suffix not in {".docx", ".pptx", ".xlsx"}: - return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" - - try: - output_path.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(input_path, "r") as zf: - zf.extractall(output_path) - - xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) - for xml_file in xml_files: - _pretty_print_xml(xml_file) - - message = f"Unpacked {input_file} ({len(xml_files)} XML files)" - - if suffix == ".docx": - if simplify_redlines: - simplify_count, _ = do_simplify_redlines(str(output_path)) - message += f", simplified {simplify_count} tracked changes" - - if merge_runs: - merge_count, _ = do_merge_runs(str(output_path)) - message += f", merged {merge_count} runs" - - for xml_file in xml_files: - _escape_smart_quotes(xml_file) - - return None, message - - except zipfile.BadZipFile: - return None, f"Error: {input_file} is not a valid Office file" - except Exception as e: - return None, f"Error unpacking: {e}" - - -def _pretty_print_xml(xml_file: Path) -> None: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) - except Exception: - pass - - -def _escape_smart_quotes(xml_file: Path) -> None: - try: - content = xml_file.read_text(encoding="utf-8") - for char, entity in SMART_QUOTE_REPLACEMENTS.items(): - content = content.replace(char, entity) - xml_file.write_text(content, encoding="utf-8") - except Exception: - pass - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" - ) - parser.add_argument("input_file", help="Office file to unpack") - parser.add_argument("output_directory", help="Output directory") - parser.add_argument( - "--merge-runs", - type=lambda x: x.lower() == "true", - default=True, - metavar="true|false", - help="Merge adjacent runs with identical formatting (DOCX only, default: true)", - ) - parser.add_argument( - "--simplify-redlines", - type=lambda x: x.lower() == "true", - default=True, - metavar="true|false", - help="Merge adjacent tracked changes from same author (DOCX only, default: true)", - ) - args = parser.parse_args() - - _, message = unpack( - args.input_file, - args.output_directory, - merge_runs=args.merge_runs, - simplify_redlines=args.simplify_redlines, - ) - print(message) - - if "Error" in message: - sys.exit(1) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validate.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validate.py old mode 100644 new mode 100755 index 03b01f6e..29ca186a --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validate.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validate.py @@ -6,7 +6,7 @@ Usage: The first argument can be either: - An unpacked directory containing the Office document XML files -- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory +- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory Auto-repair fixes: - paraId/durableId values that exceed OOXML limits @@ -19,20 +19,43 @@ import tempfile import zipfile from pathlib import Path +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import OOXML_FAMILY, rezip, safe_extract from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def _fail(message: str): + print(f"Error: {message}", file=sys.stderr) + sys.exit(2) + + +def _has_tracked_changes(unpacked_dir: Path) -> bool: + document = unpacked_dir / "word" / "document.xml" + if not document.is_file(): + return False + try: + root = ET.parse(document).getroot() + except (ET.ParseError, DefusedXmlException): + return False + tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} + return any(elem.tag in tracked for elem in root.iter()) + def main(): parser = argparse.ArgumentParser(description="Validate Office document XML files") parser.add_argument( "path", - help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", ) parser.add_argument( "--original", required=False, default=None, - help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", ) parser.add_argument( "-v", @@ -43,63 +66,102 @@ def main(): parser.add_argument( "--auto-repair", action="store_true", - help="Automatically repair common issues (hex IDs, whitespace preservation)", + help="Automatically repair common issues (hex IDs, whitespace preservation). " + "Modifies the input in place: repairs to a packed file are written back to it.", ) parser.add_argument( "--author", - default="Claude", - help="Author name for redlining validation (default: Claude)", + default=None, + help="The name you are redlining under. Passing it turns on the " + "tracked-change check: any text differing from --original without a " + "/ recording it is reported. Untracked edits carry no " + "author, so the check covers them whoever made them — the name marks " + "the run as redlining work and is not used to filter. Requires " + "--original; docx only.", ) args = parser.parse_args() + if args.author is not None and not args.original: + _fail("--author requires --original") + path = Path(args.path) - assert path.exists(), f"Error: {path} does not exist" + if not path.exists(): + _fail(f"{path} does not exist") original_file = None if args.original: original_file = Path(args.original) - assert original_file.is_file(), f"Error: {original_file} is not a file" - assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( - f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + if not original_file.is_file(): + _fail(f"{original_file} is not a file") + if original_file.suffix.lower() not in OOXML_FAMILY: + _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") + + family = OOXML_FAMILY.get((original_file or path).suffix.lower()) + if family is None: + _fail( + f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." ) - file_extension = (original_file or path).suffix.lower() - assert file_extension in [".docx", ".pptx", ".xlsx"], ( - f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." - ) + if args.author is not None and family != "docx": + _fail(f"--author only applies to docx files, not {family}") - if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: - temp_dir = tempfile.mkdtemp() - with zipfile.ZipFile(path, "r") as zf: - zf.extractall(temp_dir) - unpacked_dir = Path(temp_dir) + packed_file = None + temp_dir_ctx = None + if path.is_file() and path.suffix.lower() in OOXML_FAMILY: + packed_file = path + temp_dir_ctx = tempfile.TemporaryDirectory() + unpacked_dir = Path(temp_dir_ctx.name) + try: + with zipfile.ZipFile(path, "r") as zf: + safe_extract(zf, unpacked_dir) + except (zipfile.BadZipFile, ValueError, OSError) as e: + _fail(f"cannot unpack {path}: {e}") else: - assert path.is_dir(), f"Error: {path} is not a directory or Office file" + if not path.is_dir(): + _fail(f"{path} is not a directory or Office file") unpacked_dir = path - match file_extension: - case ".docx": + match family: + case "docx": validators = [ DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), ] - if original_file: + if args.author is not None: validators.append( - RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) ) - case ".pptx": + elif original_file and _has_tracked_changes(unpacked_dir): + print( + "Note: this document has tracked changes; they were not " + "checked against the original (pass --author to check)." + ) + case "pptx": validators = [ PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), ] + case "xlsx": + exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") + print( + f"No XSD schema validation is performed for xlsx-family files ({exts}). " + "For formula-error checking, use scripts/recalc.py instead." + ) + sys.exit(0) case _: - print(f"Error: Validation not supported for file type {file_extension}") + print(f"Error: Validation not supported for file type {family}") sys.exit(1) if args.auto_repair: total_repairs = sum(v.repair() for v in validators) if total_repairs: print(f"Auto-repaired {total_repairs} issue(s)") + if packed_file is not None: + rezip(unpacked_dir, packed_file) + print(f"Wrote repaired file to {packed_file}") - success = all(v.validate() for v in validators) + success = all([v.validate() for v in validators]) + + if temp_dir_ctx is not None: + temp_dir_ctx.cleanup() if success: print("All validations PASSED!") diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/base.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/base.py index db4a06a2..33fc97bb 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/base.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/base.py @@ -6,8 +6,20 @@ import re from pathlib import Path import defusedxml.minidom +from functools import lru_cache + import lxml.etree +from helpers import safe_extract + + +@lru_cache(maxsize=None) +def _load_schema(schema_path: str): + with open(schema_path, "rb") as xsd_file: + xsd_doc = lxml.etree.parse( + xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path + ) + return lxml.etree.XMLSchema(xsd_doc) class BaseSchemaValidator: @@ -119,21 +131,28 @@ class BaseSchemaValidator: try: content = xml_file.read_text(encoding="utf-8") dom = defusedxml.minidom.parseString(content) - modified = False + pending = [] for elem in dom.getElementsByTagName("*"): - if elem.tagName.endswith(":t") and elem.firstChild: - text = elem.firstChild.nodeValue - if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + local_name = elem.tagName.rsplit(":", 1)[-1] + if local_name in ("t", "delText", "instrText", "delInstrText"): + text = "".join( + child.data + for child in elem.childNodes + if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) + ) + ws = (" ", "\t", "\n", "\r") + if text and (text.startswith(ws) or text.endswith(ws)): if elem.getAttribute("xml:space") != "preserve": elem.setAttribute("xml:space", "preserve") text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) - print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") - repairs += 1 - modified = True + pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") - if modified: + if pending: xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) except Exception: pass @@ -212,6 +231,8 @@ class BaseSchemaValidator: elem.getparent().remove(elem) for elem in root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue tag = ( elem.tag.split("}")[-1].lower() if "}" in elem.tag @@ -326,6 +347,8 @@ class BaseSchemaValidator: namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, ): target = rel.get("Target") + if rel.get("TargetMode") == "External": + continue if target and not target.startswith( ("http", "mailto:") ): @@ -423,6 +446,8 @@ class BaseSchemaValidator: r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE rid_attrs_to_check = ["id", "embed", "link"] for elem in xml_root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue for attr_name in rid_attrs_to_check: rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") if not rid_attr: @@ -747,18 +772,16 @@ class BaseSchemaValidator: return xml_doc - def _validate_single_file_xsd(self, xml_file, base_path): - schema_path = self._get_schema_path(xml_file) + def _preprocess_for_schema(self, xml_doc, relative_path): + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): + schema_path = schema_path or self._get_schema_path(xml_file) if not schema_path: return None, None try: - with open(schema_path, "rb") as xsd_file: - parser = lxml.etree.XMLParser() - xsd_doc = lxml.etree.parse( - xsd_file, parser=parser, base_url=str(schema_path) - ) - schema = lxml.etree.XMLSchema(xsd_doc) + schema = _load_schema(str(schema_path)) with open(xml_file, "r") as f: xml_doc = lxml.etree.parse(f) @@ -773,6 +796,8 @@ class BaseSchemaValidator: ): xml_doc = self._clean_ignorable_namespaces(xml_doc) + xml_doc = self._preprocess_for_schema(xml_doc, relative_path) + if schema.validate(xml_doc): return True, set() else: @@ -784,7 +809,7 @@ class BaseSchemaValidator: except Exception as e: return False, {str(e)} - def _get_original_file_errors(self, xml_file): + def _get_original_file_errors(self, xml_file, schema_path=None): if self.original_file is None: return set() @@ -798,8 +823,11 @@ class BaseSchemaValidator: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - zip_ref.extractall(temp_path) + try: + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + safe_extract(zip_ref, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() original_xml_file = temp_path / relative_path @@ -807,7 +835,7 @@ class BaseSchemaValidator: return set() is_valid, errors = self._validate_single_file_xsd( - original_xml_file, temp_path + original_xml_file, temp_path, schema_path=schema_path ) return errors if errors else set() diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/docx.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/docx.py index fec405e6..b1814994 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/docx.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/docx.py @@ -6,10 +6,13 @@ import random import re import tempfile import zipfile +from pathlib import Path import defusedxml.minidom import lxml.etree +from helpers import safe_extract + from .base import BaseSchemaValidator @@ -186,7 +189,7 @@ class DOCXSchemaValidator(BaseSchemaValidator): try: with tempfile.TemporaryDirectory() as temp_dir: with zipfile.ZipFile(original, "r") as zip_ref: - zip_ref.extractall(temp_dir) + safe_extract(zip_ref, Path(temp_dir)) doc_xml_path = temp_dir + "/word/document.xml" root = lxml.etree.parse(doc_xml_path).getroot() @@ -241,9 +244,12 @@ class DOCXSchemaValidator(BaseSchemaValidator): return True def compare_paragraph_counts(self): - original_count = self.count_paragraphs_in_original() new_count = self.count_paragraphs_in_unpacked() + if self.original_file is None: + print(f"\nParagraphs: {new_count}") + return + original_count = self.count_paragraphs_in_original() diff = new_count - original_count diff_str = f"+{diff}" if diff > 0 else str(diff) print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") @@ -260,9 +266,15 @@ class DOCXSchemaValidator(BaseSchemaValidator): try: for elem in lxml.etree.parse(str(xml_file)).iter(): if val := elem.get(para_id_attr): - if self._parse_id_value(val, base=16) >= 0x80000000: + try: + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + except ValueError: errors.append( - f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + f" {xml_file.name}:{elem.sourceline}: " + f"paraId={val} is not valid hex" ) if val := elem.get(durable_id_attr): @@ -279,13 +291,19 @@ class DOCXSchemaValidator(BaseSchemaValidator): f"durableId={val} must be decimal in numbering.xml" ) else: - if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + try: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: errors.append( f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" + f"durableId={val} is not valid hex" ) - except Exception: - pass + except lxml.etree.XMLSyntaxError: + continue if errors: print(f"FAILED - {len(errors)} ID constraint violations:") @@ -389,52 +407,54 @@ class DOCXSchemaValidator(BaseSchemaValidator): return repairs def repair_durableId(self) -> int: + DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") repairs = 0 + renames: dict = {} for xml_file in self.xml_files: try: content = xml_file.read_text(encoding="utf-8") dom = defusedxml.minidom.parseString(content) + is_numbering = xml_file.name == "numbering.xml" + base = 10 if is_numbering else 16 + pending = [] + seen_in_file = set() modified = False for elem in dom.getElementsByTagName("*"): - if not elem.hasAttribute("w16cid:durableId"): - continue + for attr_name in DURABLE_ID_ATTRS: + if not elem.hasAttribute(attr_name): + continue - durable_id = elem.getAttribute("w16cid:durableId") - needs_repair = False - - if xml_file.name == "numbering.xml": + durable_id = elem.getAttribute(attr_name) try: - needs_repair = ( - self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF - ) - except ValueError: - needs_repair = True - else: - try: - needs_repair = ( - self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF - ) + key = self._parse_id_value(durable_id, base=base) + needs_repair = key >= 0x7FFFFFFF except ValueError: + key = durable_id needs_repair = True - if needs_repair: - value = random.randint(1, 0x7FFFFFFE) - if xml_file.name == "numbering.xml": - new_id = str(value) - else: - new_id = f"{value:08X}" + if needs_repair: + if key in seen_in_file: + value = random.randint(1, 0x7FFFFFFE) + else: + seen_in_file.add(key) + if key not in renames: + renames[key] = random.randint(1, 0x7FFFFFFE) + value = renames[key] + new_id = str(value) if is_numbering else f"{value:08X}" - elem.setAttribute("w16cid:durableId", new_id) - print( - f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" - ) - repairs += 1 - modified = True + elem.setAttribute(attr_name, new_id) + pending.append( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + modified = True if modified: xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) except Exception: pass diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/pptx.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/pptx.py index 09842aa9..318f0e61 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/pptx.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/pptx.py @@ -3,6 +3,9 @@ Validator for PowerPoint presentation XML files against XSD schemas. """ import re +from pathlib import Path + +from helpers import opc_target, rels_source_part, safe_extract from .base import BaseSchemaValidator @@ -57,8 +60,171 @@ class PPTXSchemaValidator(BaseSchemaValidator): if not self.validate_no_duplicate_slide_layouts(): all_valid = False + if not self.validate_master_theme_uniqueness(): + all_valid = False + + if not self.validate_charts(): + all_valid = False + + if not self.validate_slides(): + all_valid = False + return all_valid + def _package_map(self) -> dict: + wanted = [] + wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) + wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) + wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) + for group in ("slideMasters", "notesMasters", "handoutMasters"): + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) + return { + p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() + for p in wanted + if p.is_file() + } + + def validate_master_theme_uniqueness(self): + from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes + + shared = live_shared_master_themes(self._package_map()) + if shared: + print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") + for message in shared: + print(f" {message}") + if any(m.startswith(_NOTES_MASTERS) for m in shared): + print(" Fix: in ppt/presentation.xml, move back to " + "directly after . PowerPoint reads that happily.") + else: + print(" Fix: give each master its own theme part.") + return False + + if self.verbose: + print("PASSED - No master shares a theme part in a way PowerPoint refuses") + return True + + def validate_charts(self): + from helpers.pptx_chart import find_chart_problems + + problems = find_chart_problems(self._package_map()) + if problems: + print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") + for message in problems: + print(f" {message}") + return False + + if self.verbose: + print("PASSED - Charts satisfy the constraints PowerPoint enforces") + return True + + def _original_slide_defects(self, schema) -> set[str]: + import tempfile + import zipfile + + from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors + + if self.original_file is None: + return set() + + found: set[str] = set() + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + try: + with zipfile.ZipFile(self.original_file, "r") as zf: + safe_extract(zf, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() + + for part in sorted(temp_path.rglob("*.xml")): + relative = part.relative_to(temp_path).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + part.resolve(), temp_path.resolve(), schema_path=schema + ) + if ok is None or ok or not errors: + continue + found |= set(fatal_slide_errors(set(errors))) + return found + + def validate_slides(self): + from helpers.pptx_slide import ( + SLIDE_PART_RE, + fatal_slide_errors, + is_schema_verdict, + ) + + schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] + inherited = self._original_slide_defects(schema) + problems: list[str] = [] + broken: list[str] = [] + + for xml_file in self.xml_files: + relative = xml_file.relative_to(self.unpacked_dir).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema + ) + if ok is None or not errors: + continue + + unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] + if unreadable: + broken.extend(unreadable) + continue + if ok: + continue + + for message in fatal_slide_errors(set(errors)): + if message in inherited: + continue + problems.append(f"{relative}: {message}") + + if broken: + print(f"FAILED - Could not check {len(broken)} slide part(s):") + for message in sorted(broken): + print(f" {message[:240]}") + + if problems: + print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") + for message in sorted(problems): + print(f" {message[:240]}") + + if broken or problems: + return False + + if self.verbose: + print("PASSED - Slide XML has none of the defects PowerPoint refuses") + return True + + def _get_schema_path(self, xml_file): + if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): + return None + return super()._get_schema_path(xml_file) + + def _preprocess_for_schema(self, xml_doc, relative_path): + if relative_path.as_posix() != "ppt/presentation.xml": + return xml_doc + + root = xml_doc.getroot() + ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" + notes = root.find(f"{ns}notesMasterIdLst") + slides = root.find(f"{ns}sldIdLst") + if notes is None or slides is None: + return xml_doc + + children = list(root) + if children.index(notes) < children.index(slides): + return xml_doc + + root.remove(notes) + root.insert(list(root).index(slides), notes) + return xml_doc + def validate_uuid_ids(self): import lxml.etree @@ -229,17 +395,17 @@ class PPTXSchemaValidator(BaseSchemaValidator): ): rel_type = rel.get("Type", "") if "notesSlide" in rel_type: - target = rel.get("Target", "") - if target: - normalized_target = target.replace("../", "") - + part = opc_target( + rel.get("Target", ""), + rels_source_part(rels_file, self.unpacked_dir), + rel.get("TargetMode", ""), + ) + if part: slide_name = rels_file.stem.replace( ".xml", "" ) - if normalized_target not in notes_slide_references: - notes_slide_references[normalized_target] = [] - notes_slide_references[normalized_target].append( + notes_slide_references.setdefault(part, []).append( (slide_name, rels_file) ) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/redlining.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/redlining.py index 71c81b6b..4185c51f 100644 --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/redlining.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/validators/redlining.py @@ -1,5 +1,14 @@ """ Validator for tracked changes in Word documents. + +Detects untracked edits in word/document.xml: text that differs from the +original without a / wrapper recording it. The tracked changes +that are new relative to the original are undone, and the result is compared +against the original; whatever text still differs was edited without being +tracked. + +Only the document body is compared. Headers, footers, footnotes and endnotes +are separate parts and are not checked. """ import subprocess @@ -7,14 +16,18 @@ import tempfile import zipfile from pathlib import Path +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import rendered_text, safe_extract + class RedliningValidator: - def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + def __init__(self, unpacked_dir, original_docx, verbose=False): self.unpacked_dir = Path(unpacked_dir) self.original_docx = Path(original_docx) self.verbose = verbose - self.author = author self.namespaces = { "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" } @@ -28,40 +41,12 @@ class RedliningValidator: print(f"FAILED - Modified document.xml not found at {modified_file}") return False - try: - import xml.etree.ElementTree as ET - - tree = ET.parse(modified_file) - root = tree.getroot() - - del_elements = root.findall(".//w:del", self.namespaces) - ins_elements = root.findall(".//w:ins", self.namespaces) - - author_del_elements = [ - elem - for elem in del_elements - if elem.get(f"{{{self.namespaces['w']}}}author") == self.author - ] - author_ins_elements = [ - elem - for elem in ins_elements - if elem.get(f"{{{self.namespaces['w']}}}author") == self.author - ] - - if not author_del_elements and not author_ins_elements: - if self.verbose: - print(f"PASSED - No tracked changes by {self.author} found.") - return True - - except Exception: - pass - with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) try: with zipfile.ZipFile(self.original_docx, "r") as zip_ref: - zip_ref.extractall(temp_path) + safe_extract(zip_ref, temp_path) except Exception as e: print(f"FAILED - Error unpacking original docx: {e}") return False @@ -74,18 +59,16 @@ class RedliningValidator: return False try: - import xml.etree.ElementTree as ET - modified_tree = ET.parse(modified_file) modified_root = modified_tree.getroot() original_tree = ET.parse(original_file) original_root = original_tree.getroot() - except ET.ParseError as e: + except (ET.ParseError, DefusedXmlException) as e: print(f"FAILED - Error parsing XML files: {e}") return False - self._remove_author_tracked_changes(original_root) - self._remove_author_tracked_changes(modified_root) + new_changes = self._new_tracked_changes(original_root, modified_root) + self._remove_tracked_changes(modified_root, new_changes) modified_text = self._extract_text_content(modified_root) original_text = self._extract_text_content(original_root) @@ -98,20 +81,91 @@ class RedliningValidator: return False if self.verbose: - print(f"PASSED - All changes by {self.author} are properly tracked") + print( + f"PASSED - All {len(new_changes)} change(s) against the original " + "are properly tracked" + ) return True + def _tracked_change_elements(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] + + def _rendered_text(self, elem): + preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" + return rendered_text(elem.text or "", preserve) + + def _text_elements(self, elem): + w = self.namespaces["w"] + return [ + node + for node in elem.iter() + if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") + ] + + def _tracked_change_key(self, elem): + w = self.namespaces["w"] + text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) + return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) + + def _new_tracked_changes(self, original_root, modified_root): + original = self._tracked_change_elements(original_root) + modified = self._tracked_change_elements(modified_root) + + pool = {} + for elem in original: + pool.setdefault(self._tracked_change_key(elem), []).append(elem) + + matched, leftover = set(), [] + for elem in modified: + bucket = pool.get(self._tracked_change_key(elem)) + if bucket: + matched.add(bucket.pop()) + else: + leftover.append(elem) + + def group(elem): + return self._tracked_change_key(elem)[:3] + + def text_of(elems): + return "".join(self._tracked_change_key(e)[3] for e in elems) + + unmatched_original = {} + for elem in original: + if elem not in matched: + unmatched_original.setdefault(group(elem), []).append(elem) + + by_group = {} + for elem in leftover: + by_group.setdefault(group(elem), []).append(elem) + + new = set() + for key, elems in by_group.items(): + rebuilt = text_of(elems) + if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): + continue + new.update(elems) + return new + def _generate_detailed_diff(self, original_text, modified_text): error_parts = [ - f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "FAILED - Document text doesn't match after removing the tracked changes", "", "Likely causes:", " 1. Modified text inside another author's or tags", " 2. Made edits without proper tracked changes", " 3. Didn't nest inside when deleting another's insertion", + " 4. Rewrote another author's / and changed its text on", + " the way. A tracked change from the original is recognised by its", + " author, date and text; anything that doesn't reproduce one exactly", + " reads as new, and the text it carried is reported missing.", "", "For pre-redlined documents, use correct patterns:", " - To reject another's INSERTION: Nest inside their ", + " - To reject PART of one: nest around only the runs you reject.", + " Their may be split around it, so long as the pieces keep", + " their author and date and still spell out the same text.", " - To restore another's DELETION: Add new AFTER their ", "", ] @@ -195,15 +249,14 @@ class RedliningValidator: return None - def _remove_author_tracked_changes(self, root): + def _remove_tracked_changes(self, root, targets): ins_tag = f"{{{self.namespaces['w']}}}ins" del_tag = f"{{{self.namespaces['w']}}}del" - author_attr = f"{{{self.namespaces['w']}}}author" for parent in root.iter(): to_remove = [] for child in parent: - if child.tag == ins_tag and child.get(author_attr) == self.author: + if child.tag == ins_tag and child in targets: to_remove.append(child) for elem in to_remove: parent.remove(elem) @@ -214,7 +267,7 @@ class RedliningValidator: for parent in root.iter(): to_process = [] for child in parent: - if child.tag == del_tag and child.get(author_attr) == self.author: + if child.tag == del_tag and child in targets: to_process.append((child, list(parent).index(child))) for del_elem, del_index in reversed(to_process): @@ -234,8 +287,7 @@ class RedliningValidator: for p_elem in root.findall(f".//{p_tag}"): text_parts = [] for t_elem in p_elem.findall(f".//{t_tag}"): - if t_elem.text: - text_parts.append(t_elem.text) + text_parts.append(self._rendered_text(t_elem)) paragraph_text = "".join(text_parts) if paragraph_text: paragraphs.append(paragraph_text) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office_package.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office_package.py new file mode 100644 index 00000000..39b1f27b --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office_package.py @@ -0,0 +1,52 @@ +"""Unpack or repack an OOXML document without shell-specific ZIP commands.""" + +import argparse +import os +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +def unpack(source, destination): + root = Path(destination).resolve() + if root.exists() and any(root.iterdir()): + raise ValueError("Unpack destination must be empty") + with zipfile.ZipFile(source) as archive: + for info in archive.infolist(): + name = PurePosixPath(info.filename.replace("\\", "/")) + if name.is_absolute() or ".." in name.parts or any(":" in part for part in name.parts): + raise ValueError(f"Unsafe ZIP entry: {info.filename}") + if (info.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"ZIP symlink is not supported: {info.filename}") + archive.extractall(root) + + +def pack(source, destination): + root = Path(source).resolve() + output = Path(destination).resolve() + if output.is_relative_to(root): + raise ValueError("Output must be outside the unpacked directory") + if not (root / "[Content_Types].xml").is_file(): + raise ValueError("Source must be the package root containing [Content_Types].xml") + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(suffix=output.suffix, dir=output.parent) + os.close(fd) + try: + with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: + for file in sorted(root.rglob("*")): + if file.is_symlink(): + raise ValueError(f"Package symlink is not supported: {file}") + if file.is_file(): + archive.write(file, file.relative_to(root).as_posix()) + os.replace(temporary, output) + finally: + Path(temporary).unlink(missing_ok=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("unpack", "pack")) + parser.add_argument("input") + parser.add_argument("output") + args = parser.parse_args() + {"unpack": unpack, "pack": pack}[args.action](args.input, args.output) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/render_pdf.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/render_pdf.py new file mode 100644 index 00000000..4820bbdc --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/render_pdf.py @@ -0,0 +1,29 @@ +"""Render PDF pages without Poppler; requires PyMuPDF.""" + +import argparse +from pathlib import Path + +import pymupdf + + +def convert(pdf_path, output_dir, max_dim=1600): + if max_dim <= 0: + raise ValueError("max_dim must be positive") + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + with pymupdf.open(pdf_path) as document: + for index, page in enumerate(document): + scale = min(max_dim / page.rect.width, max_dim / page.rect.height) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False) + target = output / f"page_{index + 1}.png" + pixmap.save(str(target)) + print(target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--max-dim", type=int, default=1600) + args = parser.parse_args() + convert(args.input, args.output, args.max_dim) diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/thumbnail.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/thumbnail.py old mode 100644 new mode 100755 index edcbdc0f..d49cac0e --- a/plugins/codex/plugins/pptx/skills/pptx/scripts/thumbnail.py +++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/thumbnail.py @@ -16,6 +16,7 @@ Examples: """ import argparse +import posixpath import subprocess import sys import tempfile @@ -23,9 +24,12 @@ import zipfile from pathlib import Path import defusedxml.minidom -from office.soffice import get_soffice_env +from defusedxml import ElementTree +from office.helpers import SLIDE_REL_TYPE, opc_target +from office.soffice import run_soffice from PIL import Image, ImageDraw, ImageFont + THUMBNAIL_WIDTH = 300 CONVERSION_DPI = 100 MAX_COLS = 6 @@ -92,28 +96,45 @@ def main(): sys.exit(1) +def _is_hidden(zf: zipfile.ZipFile, part: str) -> bool: + try: + with zf.open(part) as f: + for _, root in ElementTree.iterparse(f, events=("start",)): + return root.get("show") in ("0", "false") + except (KeyError, ElementTree.ParseError): + return False + return False + + def get_slide_info(pptx_path: Path) -> list[dict]: with zipfile.ZipFile(pptx_path, "r") as zf: rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8") rels_dom = defusedxml.minidom.parseString(rels_content) - rid_to_slide = {} + rid_to_part = {} for rel in rels_dom.getElementsByTagName("Relationship"): - rid = rel.getAttribute("Id") - target = rel.getAttribute("Target") - rel_type = rel.getAttribute("Type") - if "slide" in rel_type and target.startswith("slides/"): - rid_to_slide[rid] = target.replace("slides/", "") + if rel.getAttribute("Type") != SLIDE_REL_TYPE: + continue + part = opc_target( + rel.getAttribute("Target"), + "ppt/presentation.xml", + rel.getAttribute("TargetMode"), + ) + if part is not None: + rid_to_part[rel.getAttribute("Id")] = part pres_content = zf.read("ppt/presentation.xml").decode("utf-8") pres_dom = defusedxml.minidom.parseString(pres_content) + present = set(zf.namelist()) + slides = [] for sld_id in pres_dom.getElementsByTagName("p:sldId"): - rid = sld_id.getAttribute("r:id") - if rid in rid_to_slide: - hidden = sld_id.getAttribute("show") == "0" - slides.append({"name": rid_to_slide[rid], "hidden": hidden}) + part = rid_to_part.get(sld_id.getAttribute("r:id")) + if part is not None and part in present: + slides.append( + {"name": posixpath.basename(part), "hidden": _is_hidden(zf, part)} + ) return slides @@ -123,6 +144,15 @@ def build_slide_list( visible_images: list[Path], temp_dir: Path, ) -> list[tuple[Path, str]]: + visible_count = sum(1 for info in slide_info if not info["hidden"]) + rendered_hidden = len(visible_images) == len(slide_info) != visible_count + + if not rendered_hidden and visible_count != len(visible_images): + raise ValueError( + f"LibreOffice rendered {len(visible_images)} page(s) for {visible_count} " + f"visible slide(s) of {len(slide_info)}; thumbnails would be mislabeled" + ) + if visible_images: with Image.open(visible_images[0]) as img: placeholder_size = img.size @@ -133,15 +163,15 @@ def build_slide_list( visible_idx = 0 for info in slide_info: - if info["hidden"]: + if info["hidden"] and not rendered_hidden: placeholder_path = temp_dir / f"hidden-{info['name']}.jpg" placeholder_img = create_hidden_placeholder(placeholder_size) placeholder_img.save(placeholder_path, "JPEG") slides.append((placeholder_path, f"{info['name']} (hidden)")) else: - if visible_idx < len(visible_images): - slides.append((visible_images[visible_idx], info["name"])) - visible_idx += 1 + label = f"{info['name']} (hidden)" if info["hidden"] else info["name"] + slides.append((visible_images[visible_idx], label)) + visible_idx += 1 return slides @@ -158,22 +188,14 @@ def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image: def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]: pdf_path = temp_dir / f"{pptx_path.stem}.pdf" - result = subprocess.run( - [ - "soffice", - "--headless", - "--convert-to", - "pdf", - "--outdir", - str(temp_dir), - str(pptx_path), - ], + result = run_soffice( + ["--headless", "--convert-to", "pdf", "--outdir", str(temp_dir), str(pptx_path)], capture_output=True, text=True, - env=get_soffice_env(), ) if result.returncode != 0 or not pdf_path.exists(): - raise RuntimeError("PDF conversion failed") + detail = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"PDF conversion failed: {detail}" if detail else "PDF conversion failed") result = subprocess.run( [ diff --git a/plugins/codex/plugins/pptx/skills/pptx/upstream-guide.md b/plugins/codex/plugins/pptx/skills/pptx/upstream-guide.md new file mode 100644 index 00000000..41cd2306 --- /dev/null +++ b/plugins/codex/plugins/pptx/skills/pptx/upstream-guide.md @@ -0,0 +1,238 @@ +--- +name: pptx +description: "Use this skill any time a .pptx or .potx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx or .potx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates (.potx), layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx or .potx filename, regardless of what they plan to do with the content afterward. If a .pptx or .potx file needs to be opened, created, or touched, use this skill." +license: Proprietary. LICENSE.txt has complete terms +--- + +# PPTX creation, editing, and analysis + +A `.pptx` is a ZIP archive of XML files. Choose your approach by task: + +| Task | Approach | +|---|---| +| **Create** a new deck | Write a `pptxgenjs` script — see gotchas below | +| **Edit** an existing deck, or build from a template | unzip → edit `ppt/slides/slideN.xml` → zip | +| **Read** content | `markitdown deck.pptx` (one block per slide under `` markers); visual grid: `python scripts/thumbnail.py deck.pptx` | + +## Scripts + +Paths are relative to this skill's directory. Everything else is plain Python, `node`, or shell. + +| Script | What it does | +|---|---| +| `scripts/thumbnail.py deck.pptx [prefix]` | Labeled grid of every slide, for picking template layouts. `.pptx` only. Pass `prefix` — it defaults to `thumbnails`, which overwrites the grids of any other deck done in the same directory | +| `scripts/add_slide.py unpacked/ slide2.xml [--after slideN.xml]` | Duplicate a slide (or a `slideLayoutN.xml`) with all the package bookkeeping. Also takes a `.pptx` directly with `-o out.pptx` | +| `scripts/clean.py unpacked/` | Delete slides, media, and rels no longer referenced. Run **after** `` is final | +| `scripts/office/validate.py deck.pptx [--original src.pptx]` | Schema, relationship, content-type, chart and slide checks; each failure names its fix. Pass `--original` for any template-derived deck — it baselines the schema checks against the template, so the template's own XSD errors don't read as yours | +| `scripts/office/soffice.py --headless --convert-to pdf deck.pptx` | LibreOffice wrapper — bare `soffice` hangs in this sandbox | + +## Creating with pptxgenjs — gotchas + +`pptxgenjs` is preinstalled — do not run `npm install` first; write the script and `require('pptxgenjs')` directly. Only if that require fails: `npm install pptxgenjs`. The model knows the API; these are the footguns: + +- **Set `pres.layout` before adding slides.** The default canvas is `LAYOUT_16x9` = **10" × 5.625"**, not 13.3" wide. Coordinates past the edge are written, not clamped — the shape just isn't on the slide. (`LAYOUT_WIDE` is 13.3" × 7.5".) +- **Hex colors: never `#`, never 8 digits.** `color: "FF0000"`. Both `"#FF0000"` and alpha baked into the hex (`"00000020"`) **corrupt the file**. For translucency: `transparency: 0-100` on fills and images, `opacity: 0.0-1.0` on shadows — each is silently ignored on the other. +- **pptxgenjs mutates option objects in place** (converts values to EMU on first use). Never share one `shadow`/options object across two `add*` calls — build a fresh object each time. +- **Shadow `offset` must be ≥ 0** — a negative offset corrupts the file. To cast a shadow upward, use `angle: 270` with a positive offset. +- **`letterSpacing` is silently ignored** — the real option is `charSpacing`. +- **Lists:** `bullet: true` on each item, never a literal `•` (renders double bullets). Set `breakLine: true` on every array item except the last. Space bulleted paragraphs with `paraSpaceAfter`, not `lineSpacing` (huge gaps). +- **One `new pptxgen()` per output file** — never reuse an instance. +- **`rectRadius` only works on `ROUNDED_RECTANGLE`**, not `RECTANGLE`. +- **Gradient fills aren't supported** — use a gradient image as the background instead. +- **Text boxes have built-in internal padding** — set `margin: 0` whenever text must align with a shape, line, or icon at the same x. +- **Speaker notes go in `slide.addNotes("...")`** (plain text, once per slide), never in a text box on the slide. +- **Keep charts native.** Use `addChart()` for everything PowerPoint can chart (pass an array of `{type, data, options}` for combos). For PowerPoint-native features the library doesn't expose (trendlines, error bars), compute the extra series yourself or post-process the generated OOXML — do not fall back to a rendered image. Only chart types PowerPoint has no native form for (Sankey, network, chord) go in as images. +- **Default charts render bare** — no title, no data labels, dated palette. Set `showTitle` + `title`, `showValue: true` + `dataLabelPosition`, `chartColors: [...]` from your palette, and quiet the frame (`catAxisLabelColor`/`valAxisLabelColor`, `valGridLine: { color, size }`, `catGridLine: { style: "none" }`, `showLegend: false` for a single series). +- **On a stacked bar or column chart, `dataLabelPosition` must be `ctr`, `inEnd`, or `inBase`.** `outEnd` **corrupts the file**. +- **A combo series using `secondaryValAxis`/`secondaryCatAxis` needs both `valAxes` and `catAxes` on the chart options, two entries each.** Without them pptxgenjs writes axis *ids* it never declares, and PowerPoint **discards that chart** and reports the file as corrupt. Supplying only `valAxes` is not enough. +- **After `writeFile()`, run `python scripts/office/validate.py deck.pptx`.** It reports the two chart faults above and the slide-XML defects PowerPoint refuses, and names the fix for each. Fix them in your generator, not by hand-editing the packed XML. +- **Never reorder the children of ``.** pptxgenjs writes `` right after `` and points both masters at one theme part. PowerPoint reads that happily — move the element and the same deck becomes unopenable. +- **Icons:** render `react-icons` to SVG (`ReactDOMServer.renderToStaticMarkup`), rasterize with `sharp` at ≥256px, and insert via `addImage({ data: "image/png;base64," + buf.toString("base64") })` — the `image/png;base64,` prefix is required (`react-icons`, `react`, `react-dom`, and `sharp` are preinstalled — `npm install react-icons react react-dom sharp` only if a require fails). + +## Editing existing decks and templates + +Pick layouts first: `python scripts/thumbnail.py template.pptx template-thumbs` writes a labeled grid of every slide and prints the file(s) it created — `template-thumbs.jpg`, split into `template-thumbs-N.jpg` past 12 slides. **Always pass that second argument, named after the deck.** It defaults to `thumbnails`, so two decks thumbnailed in one directory silently overwrite each other's grids — the first deck's are simply gone (template analysis only — visual QA needs the full-resolution renders from [Converting to Images](#converting-to-images); it only accepts `.pptx`, so copy a `.potx` to a `.pptx` name first). Use it with `markitdown` to map each content section onto a template slide, and vary the layouts — don't put every section on the same title-and-bullets slide. + +```bash +python3 -c "import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall('unpacked')" deck.pptx +python scripts/add_slide.py unpacked/ slide2.xml --after slide2.xml # duplicate a slide (or slideLayoutN.xml); prints the new slide's path +# reorder / delete slides = edit in ppt/presentation.xml +python scripts/clean.py unpacked/ # after deletions: removes orphaned slides, media, rels +# edit slide content in ppt/slides/slideN.xml +(cd unpacked && rm -f ../out.pptx && zip -Xr ../out.pptx .) # zip from INSIDE the dir; rm first or deleted parts survive +python scripts/office/validate.py out.pptx --original deck.pptx +``` + +- **Do all structural work — add, delete, reorder — before editing any slide's content.** `add_slide.py` copies a slide file verbatim, so duplicating after you edit clones the edited content; and `clean.py` deletes any slide missing from ``, including one you just wrote. +- **Never copy a slide file by hand** — `add_slide.py` does every registration a new slide needs and reports what it made (`Created ppt/slides/slide17.xml from slide2.xml`). It also works directly on a file: `add_slide.py deck.pptx slide2.xml -o out.pptx` — **pass `-o`, or it rewrites the input deck in place.** A duplicated slide still *references* its source's chart/SmartArt/embedded-object parts rather than cloning them, so editing one slide's chart changes the other's. +- **If you use `python-pptx`**, three things it won't do: duplicate a slide (its only entry point is `add_slide(layout)`), preserve formatting through `text_frame.text = "..."` (that collapses the paragraph to a single unstyled run — assign `run.text` instead), or read the SVG/EMF most template art uses (`add_picture` raises `UnidentifiedImageError`). +- Legacy `.ppt` must be converted first: `python scripts/office/soffice.py --headless --convert-to pptx file.ppt`. `.potx` templates unpack and pack identically — keep the `.potx` extension on the output. +- To reuse a template icon or image, duplicate a slide or layout that already contains it. + +When filling in a template: + +- If you script an XML transform, parse with `defusedxml.minidom` — round-tripping OOXML through `xml.etree.ElementTree` rewrites namespace prefixes and corrupts the deck. +- **Template slots ≠ source items.** If the template shows 4 team members and you have 3, delete the 4th member's entire group (image + text boxes), not just its text — then check for orphaned visuals in QA. +- One `` per list item — never concatenate items into a single paragraph. Copy the sibling `` to preserve spacing, and put `b="1"` on the `` of titles, section headers, and inline labels (`Status:`, `Owner:`). +- Let bullets inherit from the layout; only add ``, `` (numbered), or `` to override — never a literal `•` in the text. +- Text with leading or trailing spaces needs `xml:space="preserve"` on its ``. + +## Design Ideas + +**Don't create boring slides.** Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide. + +### Before Starting + +- **Pick a bold, content-informed color palette**: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices. +- **Dominance over equality**: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight. +- **Dark/light contrast**: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel. +- **Commit to a visual motif**: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles. Carry it across every slide. **Do not use a color bar or accent stripe as your motif** (see Avoid list). + +### Color Palettes + +Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration: + +| Theme | Primary | Secondary | Accent | +|-------|---------|-----------|--------| +| **Midnight Executive** | `1E2761` (navy) | `CADCFC` (ice blue) | `FFFFFF` (white) | +| **Forest & Moss** | `2C5F2D` (forest) | `97BC62` (moss) | `F5F5F5` (cream) | +| **Coral Energy** | `F96167` (coral) | `F9E795` (gold) | `2F3C7E` (navy) | +| **Warm Terracotta** | `B85042` (terracotta) | `E7E8D1` (sand) | `A7BEAE` (sage) | +| **Ocean Gradient** | `065A82` (deep blue) | `1C7293` (teal) | `21295C` (midnight) | +| **Charcoal Minimal** | `36454F` (charcoal) | `F2F2F2` (off-white) | `212121` (black) | +| **Teal Trust** | `028090` (teal) | `00A896` (seafoam) | `02C39A` (mint) | +| **Berry & Cream** | `6D2E46` (berry) | `A26769` (dusty rose) | `ECE2D0` (cream) | +| **Sage Calm** | `84B59F` (sage) | `69A297` (eucalyptus) | `50808E` (slate) | +| **Cherry Bold** | `990011` (cherry) | `FCF6F5` (off-white) | `2F3C7E` (navy) | + +### For Each Slide + +**Every slide needs a visual element** — image, chart, icon, or shape. Text-only slides are forgettable. + +**Layout options:** +- Two-column (text left, illustration on right) +- Icon + text rows (icon in colored circle, bold header, description below) +- 2x2 or 2x3 grid (image on one side, grid of content blocks on other) +- Half-bleed image (full left or right side) with content overlay + +**Data display:** +- Large stat callouts (big numbers 60-72pt with small labels below) +- Comparison columns (before/after, pros/cons, side-by-side options) +- Timeline or process flow (numbered steps, arrows) + +**Visual polish:** +- Icons in small colored circles next to section headers +- Italic accent text for key stats or taglines + +### Typography + +**Font names you write into the .pptx are rendered by the user's PowerPoint, not by this environment.** Your visual QA renders via LibreOffice, which substitutes fonts it doesn't have — and for some fonts the substitute has different widths, so your QA preview can show text overflow (or fit) that the real deck won't have. To keep your QA trustworthy: + +- **Safe fonts** (render true-to-width in QA *and* ship with Office): **Arial, Calibri, Cambria, Times New Roman, Courier New, Bookman Old Style, Century Schoolbook**. Use these for body text and anything where fit matters. +- **Headers with personality at zero QA risk**: pair a safe-list serif header (Cambria, Bookman Old Style, Century Schoolbook) with a safe-list sans body (Calibri or Arial). You get visual contrast without giving up reliable overflow checks. +- **If the user asks for a font outside the safe list** (e.g. Georgia or Trebuchet MS): use it where the user asked, but size those containers with extra slack (~10%) and don't trust QA text-fit on those elements — the preview of that font is approximate. If the user hasn't specified, prefer safe-list fonts for body text. +- **QA-unreliable fonts** (substitute has different widths — overflow checks can be wrong): Georgia, Trebuchet MS, Impact, Arial Black, Garamond, Consolas, Palatino Linotype. Calibri Light substitution varies by environment; treat as QA-unreliable. Fine for titles/accents with slack; don't trust QA text-fit on these. +- **Never default to Aptos** — Office's post-2023 default has no metric-compatible substitute here *and* is missing from older Office installs, so it's unreliable on both ends. + +| Element | Size | +|---------|------| +| Slide title | 36-44pt bold | +| Section header | 20-24pt bold | +| Body text | 14-16pt | +| Captions | 10-12pt muted | + +### Spacing + +- 0.5" minimum margins +- 0.3-0.5" between content blocks +- Leave breathing room—don't fill every inch + +### Avoid (Common Mistakes) + +- **Don't repeat the same layout** — vary columns, cards, and callouts across slides +- **Don't center body text** — left-align paragraphs and lists; center only titles +- **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body +- **Don't default to blue** — pick colors that reflect the specific topic +- **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently +- **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout +- **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets +- **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding +- **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds +- **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead +- **NEVER add decorative color bars or accent stripes** — this includes: header/footer bars spanning the slide width, vertical sidebar stripes down one edge of the slide, thin accent stripes along one edge of a card or content block, and "single-side borders" on rectangles. These read as AI-generated filler. If you want to set a card apart, use a subtle background tint, a drop shadow, or an icon — not an edge stripe. +- **Don't default to cream/beige backgrounds** — when no background is specified, use white (`FFFFFF`) or the user's brand palette; avoid warm-neutral defaults like `F5F5DC`, `FAF0E6`, `FAEBD7`, `FFF8E1` +- **Don't ship text that overflows its shape** — if text doesn't fit, reduce font size, split across slides, or enlarge the container; never leave content cut off or spilling past bounds + +## QA (Required) + +Your first render usually has a few real issues — overlaps, overflow, misalignment. Find and fix those, re-render only the slides you changed, and stop. + +### Content QA + +```bash +markitdown output.pptx +``` + +Check for missing content, typos, wrong order. + +**When using templates, check for leftover placeholder text:** + +```bash +markitdown output.pptx | grep -iE "\bx{3,}\b|lorem|ipsum|\bTODO|\[insert|this.*(page|slide).*layout" +``` + +If grep returns results, fix them before declaring success. + +### File QA (required) + +```bash +python scripts/office/validate.py output.pptx # built from scratch +python scripts/office/validate.py output.pptx --original src.pptx # built from a template +``` + +**If the deck came from a template, always pass `--original`.** A template may itself +contain parts the XSD rejects, so a bare run can report failures you never caused — and +a genuine regression can hide among them. `--original` baselines +the schema and slide checks against the template, suppressing errors it already had. +The structural checks — relationships, content types, charts — ignore `--original` and +report template-inherited problems either way, so read those on their own merits. + +pptxgenjs emits chart XML PowerPoint refuses to open, and every other tool +accepts: python-pptx opens those decks, LibreOffice renders them, the XSD +passes them. Every failure names its fix. Fix it in the generator and rebuild. + +### Visual QA + +Convert the slides to images (see [Converting to Images](#converting-to-images)) and inspect every one. After staring at the generating code you tend to see what you expect rather than what rendered, so look at the images fresh (a subagent works well for this if you have one). User-visible defects to look for: + +- **Text overflow or text cut off at a box or slide boundary — check this first.** It is the most common defect and always user-visible. (For a font the previewer renders unreliably per Typography, the preview is approximate: trust the ~10% slack you left, not its apparent fit.) +- Overlapping elements (text through shapes, lines through words, stacked elements) +- Source citations or footers colliding with content above +- Elements too close (< 0.3" gaps) or cards/sections nearly touching +- Uneven gaps (large empty area in one place, cramped in another) +- Insufficient margin from slide edges (< 0.5") +- Columns or similar elements not aligned consistently +- Low-contrast text (e.g., light gray text on cream-colored background) +- Template decoration mispositioned after text replacement — e.g., a title underline positioned for one line, but the replaced title wrapped to two +- Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle) +- Text boxes too narrow causing excessive wrapping +- Leftover placeholder content + +## Converting to Images + +Convert presentations to individual slide images for visual inspection: + +```bash +python scripts/office/soffice.py --headless --convert-to pdf output.pptx +rm -f slide-*.jpg +pdftoppm -jpeg -r 150 output.pdf slide +ls -1 "$PWD"/slide-*.jpg +``` + +**Pass the absolute paths printed above directly to the view tool.** The `rm` clears stale images from prior runs. `pdftoppm` zero-pads based on page count: `slide-1.jpg` for decks under 10 pages, `slide-01.jpg` for 10-99, `slide-001.jpg` for 100+. + +**After fixes, rerun all four commands above** — the PDF must be regenerated from the edited `.pptx` before `pdftoppm` can reflect your changes. + +## Dependencies + +`pptxgenjs` (npm, preinstalled — install only if `require('pptxgenjs')` fails) · `markitdown[pptx]`, `Pillow`, `defusedxml`, `lxml` (pip — text dump, thumbnail, clean, validate) · LibreOffice (`soffice`, auto-configured for sandboxed environments via `scripts/office/soffice.py`) · `pdftoppm` (Poppler) diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json index 8d1a5dca..2795e6c8 100644 --- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "shadcn", "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", - "commit": "dba27166ad7b146b2bd840dcfb0a7d0fd95f5c8c", + "commit": "7ff7dbf8669fa3392c294ee745dc8d8c3cee842c", "adapter": "claude-skill", "sourcePath": "skills/shadcn", - "syncedAt": "2026-10-05T16:00:00Z" + "syncedAt": "2026-10-06T07:26:41Z" } diff --git a/plugins/codex/plugins/xlsx/.codex-plugin/plugin.json b/plugins/codex/plugins/xlsx/.codex-plugin/plugin.json index 9d94f916..a1244592 100644 --- a/plugins/codex/plugins/xlsx/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/xlsx/.codex-plugin/plugin.json @@ -1,39 +1,40 @@ { "name": "xlsx", - "version": "1.0.3", - "description": "一款功能完备的电子表格制作、编辑与分析工具,支持公式应用、格式设置、数据分析及数据可视化。当Claude需要使用.xlsx、.xlsm、.csv、.tsv等格式的电子表格时,该工具能够满足以下需求:(1) 创建包含公式和格式设置的新的电子表格;(2) 读取或分析数据;(3) 在保留公式的基础上修改现有电子表格;(4) 对电子表格中的数据进行分析并生成可视化结果;(5) 重新计算各类公式。", + "version": "2.0.0", + "description": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。", "author": { - "name": "EAPIL", - "url": "https://git.playones.com/arechen/EapilSkillMarket" + "name": "Anthropic; EAPIL localization", + "url": "https://github.com/anthropics" }, - "homepage": "https://git.playones.com/arechen/EapilSkillMarket", - "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "homepage": "https://github.com/anthropics/skills", + "repository": "https://github.com/anthropics/skills", "license": "Proprietary", "keywords": [ - "eapil", - "codex-skill", - "xlsx" + "xlsx", + "documents", + "zh-CN", + "windows" ], "skills": "./skills/", "interface": { "displayName": "xlsx", - "shortDescription": "一款功能完备的电子表格制作、编辑与分析工具,支持公式应用、格式设置、数据分析及数据可视化。当Claude需要使用.xlsx、.xlsm、.csv、.tsv等格式的电子表格时,该工具能够满足以下需求:(1) 创建包含公式和格式设置的新的电子表格;(2) 读取或分析数据;(3) 在保留公式的基础上修改现有电子表格;(4) 对电子表格中的数据进行分析并生成可视化结果;(5) 重新计算各类公式。", - "longDescription": "一款功能完备的电子表格制作、编辑与分析工具,支持公式应用、格式设置、数据分析及数据可视化。当Claude需要使用.xlsx、.xlsm、.csv、.tsv等格式的电子表格时,该工具能够满足以下需求:(1) 创建包含公式和格式设置的新的电子表格;(2) 读取或分析数据;(3) 在保留公式的基础上修改现有电子表格;(4) 对电子表格中的数据进行分析并生成可视化结果;(5) 重新计算各类公式。", - "developerName": "EAPIL", + "shortDescription": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。", + "longDescription": "创建和编辑 Excel,验证公式,适配中文数据与 Windows Excel。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。", + "developerName": "Anthropic; EAPIL", "category": "Productivity", "capabilities": [ "Read", "Write" ], "defaultPrompt": [ - "使用 xlsx 帮我完成这个任务。" + "使用 xlsx 处理这份中文文档,并检查最终输出。" ], - "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", - "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", - "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "websiteURL": "https://github.com/anthropics/skills", + "privacyPolicyURL": "https://github.com/anthropics/skills", + "termsOfServiceURL": "https://github.com/anthropics/skills", "brandColor": "#2563EB", "screenshots": [], - "composerIcon": "./assets/market-icon.svg", - "logo": "./assets/market-icon.svg" + "logo": "./assets/market-icon.svg", + "composerIcon": "./assets/market-icon.svg" } } diff --git a/plugins/codex/plugins/xlsx/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/xlsx/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..9bc681fb --- /dev/null +++ b/plugins/codex/plugins/xlsx/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "xlsx", + "repo": "https://github.com/anthropics/skills.git", + "ref": "main", + "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202", + "adapter": "claude-skill", + "sourcePath": "skills/xlsx", + "syncedAt": "2026-10-06T07:26:41Z" +} diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/SKILL.md b/plugins/codex/plugins/xlsx/skills/xlsx/SKILL.md index fe808fdd..eeec9196 100644 --- a/plugins/codex/plugins/xlsx/skills/xlsx/SKILL.md +++ b/plugins/codex/plugins/xlsx/skills/xlsx/SKILL.md @@ -1,390 +1,35 @@ --- name: xlsx -description: "Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. Use when Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc.), especially on Windows and for Chinese/non-English datasets." +description: 创建、读取、清理、编辑和检查 Excel 工作簿或 CSV/TSV,处理公式、图表、中文表头及 Windows Excel 编码,保留现有格式与宏。 license: Proprietary. LICENSE.txt has complete terms --- -# Requirements for Outputs +# Excel 工作簿 -## All Excel files +先读取 [references/platform.md](references/platform.md),确认 Python、中文字体及是否需要公式计算引擎。复杂工作簿先检查公式、外部链接、宏、图表和现有样式,再选择处理方法。 -### Zero Formula Errors -- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) +- `openpyxl` 用于普通 .xlsx 单元格、公式和样式;`.xlsm` 读取时用 `keep_vba=True`,保留扩展名。不保证复杂 Excel 功能无损,必须检查实际需要保留的部分。 +- `pandas` 适合表格数据清理;不要用整表重写处理需要保留样式、公式或图表的原文件。 +- `.xls` 旧格式先使用兼容工具读取或转换;不要直接交给 openpyxl。 -### Preserve Existing Templates (when updating templates) -- Study and EXACTLY match existing format, style, and conventions when modifying files -- Never impose standardized formatting on files with established patterns -- Existing template conventions ALWAYS override these guidelines +## 简体中文与数据类型 -## Financial models +用户未指定语言时用简体中文表头、图例与说明。新文件使用可用的中文字体,表头、行高与列宽按中文实际长度设置;已有工作簿沿用其约定。身份证号、订单号、手机号和前导零存为文本,日期存真实日期值并设置明确格式,金额单位按用户要求标注。 -### Color Coding Standards -Unless otherwise stated by the user or existing template +Windows Excel 打开的新 CSV 可写 `utf-8-sig`;读取旧 CSV 先判断编码和分隔符。CSV 导出不保留多表、公式和样式,转换前明确所需交付格式。 -#### Industry-Standard Color Conventions -- **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios -- **Black text (RGB: 0,0,0)**: ALL formulas and calculations -- **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook -- **Red text (RGB: 255,0,0)**: External links to other files -- **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated +## 公式与缓存 -### Number Formatting Standards +按用户语义写公式,验证引用范围与预期结果;不要用硬编码数值代替可重算公式。跨表引用含空格或中文标点时正确引用工作表名。合并区域只写左上角。 -#### Required Format Rules -- **Years**: Format as text strings (e.g., "2024" not "2,024") -- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)") -- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-") -- **Percentages**: Default to 0.0% format (one decimal) -- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E) -- **Negative numbers**: Use parentheses (123) not minus -123 +openpyxl 不计算公式。检查时分别用 `data_only=False` 读取公式、`data_only=True` 读取缓存;不能保存 `data_only=True` 的工作簿,否则公式会被数值覆盖。 -### Formula Construction Rules - -#### Assumptions Placement -- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells -- Use cell references instead of hardcoded values in formulas -- Example: Use =B5*(1+$B$6) instead of =B5*1.05 - -#### Formula Error Prevention -- Verify all cell references are correct -- Check for off-by-one errors in ranges -- Ensure consistent formulas across all projection periods -- Test with edge cases (zero values, negative numbers) -- Verify no unintended circular references - -#### Documentation Requirements for Hardcodes -- Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]" -- Examples: - - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]" - - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]" - - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity" - - "Source: FactSet, 8/20/2025, Consensus Estimates Screen" - -# XLSX creation, editing, and analysis - -## Overview - -A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks. - -## Important Requirements - -**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `recalc.py` script. The script automatically configures LibreOffice on first run. - -## Windows and Chinese-language defaults - -- Prefer `py -3` on Windows if `python` does not resolve to Python 3 -- Always quote file paths in commands: `"C:\data\Q4 report.xlsx"` -- Before running pandas/openpyxl scripts on Windows PowerShell, set UTF-8 mode: `$env:PYTHONUTF8='1'` -- For CSV round-trips with Chinese text, prefer UTF-8 with BOM: `encoding='utf-8-sig'` -- For output workbooks containing Chinese text, set explicit fonts (`Microsoft YaHei`, `SimSun`) where formatting is required - -## Reading and analyzing data - -### Data analysis with pandas -For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: - -```python -import pandas as pd - -# Read Excel -df = pd.read_excel('file.xlsx') # Default: first sheet -all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict - -# Analyze -df.head() # Preview data -df.info() # Column info -df.describe() # Statistics - -# Write Excel -df.to_excel('output.xlsx', index=False) +```powershell +python "$skill\scripts\recalc.py" ".\输出 中文.xlsx" 120 ``` -### Handling non-English column names -When Excel files contain non-ASCII characters (Chinese, Japanese, etc.), column names may display as garbled text in terminal output on Windows due to encoding issues. This does NOT affect data reading - pandas reads the data correctly. +重算会改写指定工作簿,先保留输入。脚本使用独立 LibreOffice profile,自动识别 Windows 的安装路径。检查 JSON 的 `status`、`total_errors` 和具体单元格;`errors_found` 即使进程退出码为零也未通过。含 `error` 的结果表示没有完成重算。 -**Solutions:** +有外部链接、宏、动态数组或 Excel 专用函数时,优先使用目标 Excel 环境验证,不能盲目用 LibreOffice 往返保存。兼容公式、缓存丢失和链接风险按需读取 [upstream-guide.md](upstream-guide.md)。无法运行计算引擎时说明“尚未重算”,不要声称公式已验证。 -```python -# Option 1: Use openpyxl to read headers directly -from openpyxl import load_workbook -wb = load_workbook('file.xlsx') -sheet = wb.active -headers = [cell.value for cell in sheet[1]] - -# Option 2: Rename columns by position (recommended for analysis) -df = pd.read_excel('file.xlsx') -df.columns = ['user', 'timestamp', 'model', 'latency', 'prompt_tokens', 'completion_tokens'] - -# Option 3: Access columns by position -df.iloc[:, 0] # First column -df.iloc[:, [0, 2, 3]] # Multiple columns by index -``` - -**Note for Windows users:** Terminal print output (print statements) may show encoding errors with non-ASCII characters. This is usually a console encoding issue, not a data issue. Either avoid printing non-ASCII text, use position-based column renaming, or write verification output to UTF-8 files. - -## Excel File Workflows - -## CRITICAL: Use Formulas, Not Hardcoded Values - -**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. - -### ❌ WRONG - Hardcoding Calculated Values -```python -# Bad: Calculating in Python and hardcoding result -total = df['Sales'].sum() -sheet['B10'] = total # Hardcodes 5000 - -# Bad: Computing growth rate in Python -growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] -sheet['C5'] = growth # Hardcodes 0.15 - -# Bad: Python calculation for average -avg = sum(values) / len(values) -sheet['D20'] = avg # Hardcodes 42.5 -``` - -### ✅ CORRECT - Using Excel Formulas -```python -# Good: Let Excel calculate the sum -sheet['B10'] = '=SUM(B2:B9)' - -# Good: Growth rate as Excel formula -sheet['C5'] = '=(C4-C2)/C2' - -# Good: Average using Excel function -sheet['D20'] = '=AVERAGE(D2:D19)' -``` - -This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes. - -**Locale-safe formula rule:** When writing formulas with openpyxl, use English function names and comma-separated arguments (e.g., `=SUM(A1:A10)`). Excel may display localized names in UI, but the stored formula should remain in standard Excel syntax. - -## Common Workflow -1. **Choose tool**: pandas for data, openpyxl for formulas/formatting -2. **Create/Load**: Create new workbook or load existing file -3. **Modify**: Add/edit data, formulas, and formatting -4. **Save**: Write to file -5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the recalc.py script - ```bash - python recalc.py output.xlsx - ``` - Windows PowerShell example: - ```powershell - py -3 .\recalc.py "C:\models\finance-model.xlsx" - ``` -6. **Verify and fix any errors**: - - The script returns JSON with error details - - If `status` is `errors_found`, check `error_summary` for specific error types and locations - - Fix the identified errors and recalculate again - - Common errors to fix: - - `#REF!`: Invalid cell references - - `#DIV/0!`: Division by zero - - `#VALUE!`: Wrong data type in formula - - `#NAME?`: Unrecognized formula name - -### Creating new Excel files - -```python -# Using openpyxl for formulas and formatting -from openpyxl import Workbook -from openpyxl.styles import Font, PatternFill, Alignment - -wb = Workbook() -sheet = wb.active - -# Add data -sheet['A1'] = 'Hello' -sheet['B1'] = 'World' -sheet.append(['Row', 'of', 'data']) - -# Add formula -sheet['B2'] = '=SUM(A1:A10)' - -# Formatting -sheet['A1'].font = Font(name='Microsoft YaHei', bold=True, color='FF0000') -sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') -sheet['A1'].alignment = Alignment(horizontal='center') - -# Column width -sheet.column_dimensions['A'].width = 20 - -wb.save('output.xlsx') -``` - -### Editing existing Excel files - -```python -# Using openpyxl to preserve formulas and formatting -from openpyxl import load_workbook - -# Load existing file -wb = load_workbook('existing.xlsx') -sheet = wb.active # or wb['SheetName'] for specific sheet - -# Working with multiple sheets -for sheet_name in wb.sheetnames: - sheet = wb[sheet_name] - print(f"Sheet: {sheet_name}") - -# Modify cells -sheet['A1'] = 'New Value' -sheet.insert_rows(2) # Insert row at position 2 -sheet.delete_cols(3) # Delete column 3 - -# Add new sheet -new_sheet = wb.create_sheet('NewSheet') -new_sheet['A1'] = 'Data' - -wb.save('modified.xlsx') -``` - -### Reading multiple sheets - -```python -# Using pandas - all sheets as dict -all_sheets = pd.read_excel('file.xlsx', sheet_name=None) -df1 = all_sheets['Sheet1'] -df2 = all_sheets['Sheet2'] - -# Using pandas - specific sheets -df = pd.read_excel('file.xlsx', sheet_name='Sheet1') -multiple = pd.read_excel('file.xlsx', sheet_name=['Sheet1', 'Sheet2']) - -# Using openpyxl - iterate sheets -wb = load_workbook('file.xlsx') -for sheet_name in wb.sheetnames: - sheet = wb[sheet_name] - # Process each sheet -``` - -## Recalculating formulas - -Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `recalc.py` script to recalculate formulas: - -```bash -python recalc.py [timeout_seconds] -``` - -Example: -```bash -python recalc.py output.xlsx 30 -``` - -The script: -- Automatically sets up LibreOffice macro on first run -- Recalculates all formulas in all sheets -- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.) -- Returns JSON with detailed error locations and counts -- Works on Windows, Linux, and macOS - -## Formula Verification Checklist - -Quick checks to ensure formulas work correctly: - -### Essential Verification -- [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model -- [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK) -- [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) - -### Common Pitfalls -- [ ] **NaN handling**: Check for null values with `pd.notna()` -- [ ] **Far-right columns**: FY data often in columns 50+ -- [ ] **Multiple matches**: Search all occurrences, not just first -- [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!) -- [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!) -- [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets - -### Formula Testing Strategy -- [ ] **Start small**: Test formulas on 2-3 cells before applying broadly -- [ ] **Verify dependencies**: Check all cells referenced in formulas exist -- [ ] **Test edge cases**: Include zero, negative, and very large values - -### Interpreting recalc.py Output -The script returns JSON with error details: -```json -{ - "status": "success", // or "errors_found" - "total_errors": 0, // Total error count - "total_formulas": 42, // Number of formulas in file - "error_summary": { // Only present if errors found - "#REF!": { - "count": 2, - "locations": ["Sheet1!B5", "Sheet1!C10"] - } - } -} -``` - -## Best Practices - -### Library Selection -- **pandas**: Best for data analysis, bulk operations, and simple data export -- **openpyxl**: Best for complex formatting, formulas, and Excel-specific features - -### Working with openpyxl -- Cell indices are 1-based (row=1, column=1 refers to cell A1) -- Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)` -- **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost -- For large files: Use `read_only=True` for reading or `write_only=True` for writing -- Formulas are preserved but not evaluated - use recalc.py to update values - -### Working with pandas -- Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})` -- For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` -- Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])` - -### Data visualization and reporting workflow -When analyzing Excel data and generating reports (HTML, dashboards, etc.): - -```python -import pandas as pd -import json - -# 1. Read and prepare data -df = pd.read_excel('data.xlsx') -df.columns = ['col1', 'col2', 'col3'] # Rename if needed -df['date'] = pd.to_datetime(df['date']) - -# 2. Aggregate and analyze -daily_stats = df.groupby('date').agg({'value': ['sum', 'mean']}) -summary = { - 'total': len(df), - 'avg': df['value'].mean(), -} - -# 3. Generate output with f-strings (avoids template formatting conflicts) -html = f''' - - - Report - - - -

Total: {summary['total']:,}

- - -''' - -with open('report.html', 'w', encoding='utf-8') as f: - f.write(html) -``` - -**Key points:** -- Use f-strings for HTML templates to avoid format string conflicts -- Double braces `{{` in JavaScript/CSS to escape them in f-strings -- Use `encoding='utf-8'` when writing HTML files -- Convert data to JSON with `json.dumps()` for embedding in JavaScript - -## Code Style Guidelines -**IMPORTANT**: When generating Python code for Excel operations: -- Write minimal, concise Python code without unnecessary comments -- Avoid verbose variable names and redundant operations -- Avoid unnecessary print statements - -**For Excel files themselves**: -- Add comments to cells with complex formulas or important assumptions -- Document data sources for hardcoded values -- Include notes for key calculations and model sections \ No newline at end of file +交付前核对数值、公式、中文显示、筛选、冻结窗格、打印区域和图表。用新输出文件保留输入;抽查代表行、合计和异常情况,避免只检查文件能够打开。 diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/recalc.py b/plugins/codex/plugins/xlsx/skills/xlsx/recalc.py deleted file mode 100644 index f8b2643e..00000000 --- a/plugins/codex/plugins/xlsx/skills/xlsx/recalc.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env python3 -""" -Excel Formula Recalculation Script -Recalculates all formulas in an Excel file using LibreOffice -""" - -import json -import os -import platform -import shutil -import subprocess -import sys -from pathlib import Path - -from openpyxl import load_workbook - - -def resolve_soffice_binary(): - """Find a working LibreOffice executable.""" - candidates = ["soffice", "soffice.com", "soffice.exe"] - if platform.system() == "Windows": - candidates = ["soffice.com", "soffice.exe", "soffice"] - - for name in candidates: - binary = shutil.which(name) - if binary: - return binary - - if platform.system() == "Windows": - possible_roots = [ - os.environ.get("ProgramFiles"), - os.environ.get("ProgramFiles(x86)"), - ] - for root in possible_roots: - if not root: - continue - for rel in [ - "LibreOffice/program/soffice.com", - "LibreOffice/program/soffice.exe", - ]: - candidate = Path(root) / rel - if candidate.exists(): - return str(candidate) - - return None - - -def get_macro_dir(): - """Return the LibreOffice macro directory for the current OS.""" - system = platform.system() - if system == "Darwin": - return Path.home() / "Library/Application Support/LibreOffice/4/user/basic/Standard" - if system == "Windows": - appdata = os.environ.get("APPDATA") - if appdata: - return Path(appdata) / "LibreOffice/4/user/basic/Standard" - return Path.home() / "AppData/Roaming/LibreOffice/4/user/basic/Standard" - return Path.home() / ".config/libreoffice/4/user/basic/Standard" - - -def setup_libreoffice_macro(soffice_binary): - """Setup LibreOffice macro for recalculation if not already configured.""" - macro_dir = get_macro_dir() - macro_file = macro_dir / "Module1.xba" - - if macro_file.exists(): - existing = macro_file.read_text(encoding="utf-8", errors="ignore") - if "RecalculateAndSave" in existing: - return True - - if not macro_dir.exists(): - try: - subprocess.run( - [soffice_binary, "--headless", "--terminate_after_init"], - capture_output=True, - check=False, - timeout=20, - text=True, - ) - except Exception: - pass - macro_dir.mkdir(parents=True, exist_ok=True) - - macro_content = """ - - - Sub RecalculateAndSave() - ThisComponent.calculateAll() - ThisComponent.store() - ThisComponent.close(True) - End Sub -""" - - try: - macro_file.write_text(macro_content, encoding="utf-8") - return True - except Exception: - return False - - -def recalc(filename, timeout=30): - """ - Recalculate formulas in Excel file and report any errors. - - Args: - filename: Path to Excel file - timeout: Maximum time to wait for recalculation (seconds) - - Returns: - dict with error locations and counts - """ - file_path = Path(filename).expanduser() - if not file_path.exists(): - return {"error": f"File {filename} does not exist"} - - soffice_binary = resolve_soffice_binary() - if not soffice_binary: - return { - "error": ( - "LibreOffice executable not found. Install LibreOffice and ensure " - "`soffice` (or `soffice.com` on Windows) is on PATH." - ) - } - - if not setup_libreoffice_macro(soffice_binary): - return {"error": "Failed to setup LibreOffice macro"} - - cmd = [ - soffice_binary, - "--headless", - "--norestore", - "vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application", - str(file_path.resolve()), - ] - - try: - run_result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - timeout=timeout, - ) - except subprocess.TimeoutExpired: - return {"error": f"Recalculation timed out after {timeout} seconds"} - - if run_result.returncode != 0: - error_msg = (run_result.stderr or run_result.stdout or "Unknown error during recalculation").strip() - if "Module1" in error_msg or "RecalculateAndSave" in error_msg or "Basic" in error_msg: - return { - "error": "LibreOffice macro not configured properly", - "details": error_msg, - } - return {"error": error_msg} - - try: - wb_values = load_workbook(file_path, data_only=True) - - excel_errors = ["#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A"] - error_details = {err: [] for err in excel_errors} - total_errors = 0 - - for sheet_name in wb_values.sheetnames: - ws = wb_values[sheet_name] - for row in ws.iter_rows(): - for cell in row: - if cell.value is None or not isinstance(cell.value, str): - continue - for err in excel_errors: - if err in cell.value: - error_details[err].append(f"{sheet_name}!{cell.coordinate}") - total_errors += 1 - break - - wb_values.close() - - result = { - "status": "success" if total_errors == 0 else "errors_found", - "total_errors": total_errors, - "error_summary": {}, - } - - for err_type, locations in error_details.items(): - if locations: - result["error_summary"][err_type] = { - "count": len(locations), - "locations": locations[:20], - } - - wb_formulas = load_workbook(file_path, data_only=False) - formula_count = 0 - for sheet_name in wb_formulas.sheetnames: - ws = wb_formulas[sheet_name] - for row in ws.iter_rows(): - for cell in row: - if isinstance(cell.value, str) and cell.value.startswith("="): - formula_count += 1 - wb_formulas.close() - - result["total_formulas"] = formula_count - return result - - except Exception as exc: - return {"error": str(exc)} - - -def main(): - if len(sys.argv) < 2: - print("Usage: python recalc.py [timeout_seconds]") - print("\nRecalculates all formulas in an Excel file using LibreOffice") - print("\nReturns JSON with error details:") - print(" - status: 'success' or 'errors_found'") - print(" - total_errors: Total number of Excel errors found") - print(" - total_formulas: Number of formulas in the file") - print(" - error_summary: Breakdown by error type with locations") - print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A") - sys.exit(1) - - filename = sys.argv[1] - timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 - - result = recalc(filename, timeout) - print(json.dumps(result, indent=2, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/references/platform.md b/plugins/codex/plugins/xlsx/skills/xlsx/references/platform.md new file mode 100644 index 00000000..c3bb862f --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/references/platform.md @@ -0,0 +1,41 @@ +# 简体中文与 Windows 环境 + +以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。 + +```powershell +$env:PYTHONUTF8 = "1" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +python --version +node --version +``` + +若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。 + +需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。 + +## 字体与文本 + +- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。 +- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。 +- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。 +- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。 +- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。 + +## 转换与预览 + +Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。 + +```powershell +# 仅在自动检测不到时设置;不要求添加系统 PATH +$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com" +python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx" +python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages" +``` + +按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。 + +使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。 + +## 验证与交付 + +生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。 diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/__init__.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/__init__.py new file mode 100644 index 00000000..d3c5817c --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/__init__.py @@ -0,0 +1,111 @@ +import os +import posixpath +import re +import stat +import tempfile +import urllib.parse +import zipfile +from pathlib import Path + +OOXML_FAMILY = { + ".docx": "docx", + ".dotx": "docx", + ".pptx": "pptx", + ".potx": "pptx", + ".xlsx": "xlsx", + ".xltx": "xlsx", +} + +_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") + +SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" + + +def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: + if not target: + return None + if target_mode.lower() == "external": + return None + if _SCHEME_RE.match(target): + return None + + target = urllib.parse.unquote(target) + + if "\\" in target: + raise ValueError(f"relationship target is not a POSIX part name: {target!r}") + + if target.startswith("/"): + joined = target.lstrip("/") + else: + joined = posixpath.join(posixpath.dirname(source_part), target) + + parts: list[str] = [] + for segment in posixpath.normpath(joined).split("/"): + if segment in ("", "."): + continue + if segment == "..": + if not parts: + raise ValueError(f"relationship target escapes the package: {target!r}") + parts.pop() + else: + parts.append(segment) + + if not parts: + raise ValueError(f"relationship target resolves to nothing: {target!r}") + return "/".join(parts) + + +def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: + owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) + return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") + + +def part_text(data: bytes) -> str: + return data.decode("utf-8", "surrogateescape") + + +XML_SPACE = " \t\r\n" + + +def rendered_text(text: str, preserve: bool) -> str: + return text if preserve else text.strip(XML_SPACE) + + +def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: + dest = dest.resolve() + for m in zf.infolist(): + if stat.S_ISLNK(m.external_attr >> 16): + raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") + target = (dest / m.filename).resolve() + if not target.is_relative_to(dest): + raise ValueError(f"unsafe archive entry: {m.filename!r}") + zf.extract(m, dest) + + +def rezip(src_dir: Path, out_path: Path) -> None: + files = sorted(p for p in src_dir.rglob("*") if p.is_file()) + ct = src_dir / "[Content_Types].xml" + fd, tmp_name = tempfile.mkstemp( + prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent + ) + tmp_out = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: + if ct.exists(): + zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) + for f in files: + if f == ct: + continue + zf.write(f, f.relative_to(src_dir)) + if out_path.exists(): + mode = out_path.stat().st_mode & 0o777 + else: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + os.chmod(tmp_out, mode) + os.replace(tmp_out, out_path) + finally: + if tmp_out.exists(): + tmp_out.unlink() diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_chart.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_chart.py new file mode 100644 index 00000000..209cb7c5 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_chart.py @@ -0,0 +1,170 @@ +"""Find chart XML that PowerPoint refuses but the schema accepts. + +Detection only: for either fault more than one repair is valid, and only the +author knows which was meant. +""" + + +from __future__ import annotations + +import re +from typing import Mapping + +from . import part_text + + +_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") + +_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") +_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") + +def _strip_ext_lst(text: str) -> str: + out, cursor = [], 0 + for lo, hi in _ext_lst_spans(text): + out.append(text[cursor:lo]) + cursor = hi + out.append(text[cursor:]) + return "".join(out) + +_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) +ILLEGAL_ON_STACKED = frozenset({"outEnd"}) +LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") + + +def _check_stacked_label_positions(part: str, xml: str) -> list[str]: + problems: list[str] = [] + for match in _BAR_GROUP_RE.finditer(xml): + block = _strip_ext_lst(match.group(0)) + group = match.group(1) + + grouping = _GROUPING_RE.search(block) + if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: + continue + + bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] + for pos in sorted(set(bad)): + problems.append( + f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' + f"{grouping.group(1)} {group}; PowerPoint allows only " + f"{', '.join(LEGAL_ON_STACKED)} there" + ) + return problems + + + +_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) + +_AXID_RE = re.compile( + r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" +) + +_AXIS_DECL_RE = re.compile( + r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" +) + +AXID_LIMIT = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, + "surfaceChart": 3, "surface3DChart": 3, +} + +AXID_MINIMUM = { + "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, + "bubbleChart": 2, "radarChart": 2, "stockChart": 2, + "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, + "line3DChart": 3, "surface3DChart": 3, +} + + +def _declared_axes(xml: str) -> dict[str, list[str]]: + axes: dict[str, list[str]] = {} + for kind, axid in _AXIS_DECL_RE.findall(xml): + axes.setdefault(kind, []).append(axid) + return axes + + +def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: + category = axes.get("catAx", []) + axes.get("dateAx", []) + value = axes.get("valAx", []) + series = axes.get("serAx", []) + if len(category) != 1 or len(value) != 1 or len(series) > 1: + return None + ids = [category[0], value[0]] + if limit >= 3 and series: + ids.append(series[0]) + return ids + + +def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: + if kind not in AXID_LIMIT: + return None + ids = _AXID_RE.findall(block) + declared = {i for group in axes.values() for i in group} + if len([i for i in ids if i in declared]) >= 2: + return None + return ids + + +def _check_chart_axis_references(part: str, xml: str) -> list[str]: + axes = _declared_axes(xml) + problems: list[str] = [] + declared = {i for group in axes.values() for i in group} + for match in _ANY_CHART_GROUP_RE.finditer(xml): + kind, block = match.group(1), match.group(0) + ids = _undeclared_axes(kind, block, axes) + if ids is None: + continue + if not ids: + problems.append( + f"{part}: declares no this part can resolve; a chart " + f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" + ) + continue + dead = [i for i in ids if i not in declared] + canonical = _canonical_ids(axes, AXID_LIMIT[kind]) + if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: + hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" + else: + hint = ("Fix: the part declares several axes of a kind -- declare the " + "secondary axes the series expects, or drop them") + detail = (f"of which {', '.join(dead)} name no declared axis" + if dead else f"only {len(ids)} of which this part declares") + problems.append( + f"{part}: references axId {', '.join(ids)}, {detail}, " + f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" + ) + return problems + + +def _ext_lst_spans(text: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + depth = 0 + start = 0 + for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): + closing, self_closing = match.group(1), match.group(2) + if self_closing: + continue + if closing: + depth -= 1 + if depth == 0: + spans.append((start, match.end())) + else: + if depth == 0: + start = match.start() + depth += 1 + return spans + + +CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) + + +def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: + problems: list[str] = [] + for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): + xml = part_text(files[part]) + for check in CHART_CHECKS: + problems.extend(check(part, xml)) + return problems diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_slide.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_slide.py new file mode 100644 index 00000000..22f9aee0 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_slide.py @@ -0,0 +1,60 @@ +"""Pick the slide-XML schema errors PowerPoint refuses the file over. + +A denylist over lxml's messages, so an unrecognised error class is a miss rather +than a false alarm. +""" + + +from __future__ import annotations + +import re + +SLIDE_PART_RE = re.compile( + r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" + r"/[^/]+\.xml" +) + +FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"\}tableStyleId': This element is not expected"), + "two in one (the schema allows one)", + ), + ( + re.compile(r"\}srgbClr', attribute 'val'"), + "a colour that is not six hex digits", + ), + ( + re.compile(r"\}txBody': Missing child element"), + "a with no children", + ), + ( + re.compile(r"\}miter', attribute 'lim'"), + 'a line join with lim="NaN"', + ), + ( + re.compile(r"\}uLnTx': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}overrideClrMapping': This element is not expected"), + " in a position the schema forbids", + ), + ( + re.compile(r"\}nvGrpSpPr': Missing child element"), + "a with no children", + ), +) + + +def is_schema_verdict(error: str) -> bool: + return error.startswith("Element ") + + +def fatal_slide_errors(errors: set[str]) -> list[str]: + out = [] + for error in sorted(errors): + for pattern, meaning in FATAL_SLIDE_ERRORS: + if pattern.search(error): + out.append(f"{meaning}: {error}") + break + return out diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_theme.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_theme.py new file mode 100644 index 00000000..84466201 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/helpers/pptx_theme.py @@ -0,0 +1,114 @@ +"""Find masters sharing a theme part in the way PowerPoint refuses to open. + +Reports only; the fix is to move back to directly after + in ppt/presentation.xml. +""" + + +from __future__ import annotations + +import posixpath +import re +from typing import Mapping + +from . import part_text + +THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" + +_MASTER_RE = re.compile( + r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" + r"(?:slide|notes|handout)Master(?P\d+)\.xml$" +) +_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} + +_RELATIONSHIP_RE = re.compile( + r"]*?(?:/>|>.*?)", re.DOTALL +) + + +def _sort_key(name: str) -> tuple[int, int]: + m = _MASTER_RE.match(name) + assert m is not None + return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) + + +def _rels_path(part: str) -> str: + directory, base = posixpath.split(part) + return f"{directory}/_rels/{base}.rels" + + +def _resolve(rels_path: str, target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + part_dir = posixpath.dirname(posixpath.dirname(rels_path)) + return posixpath.normpath(posixpath.join(part_dir, target)) + + +def _theme_rel(files: Mapping[str, bytes], master: str): + rels_path = _rels_path(master) + rels = files.get(rels_path) + if rels is None: + return None + for element in _RELATIONSHIP_RE.findall(part_text(rels)): + if f'Type="{THEME_REL_TYPE}"' not in element: + continue + target = re.search(r'\bTarget="([^"]+)"', element) + if target is None: + continue + return rels_path, element, _resolve(rels_path, target.group(1)) + return None + + +def _masters(files: Mapping[str, bytes]) -> list[str]: + return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) + + +_PRESENTATION = "ppt/presentation.xml" +_NOTES_MASTERS = "ppt/notesMasters/" +_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) +_AFTER_SLDIDLST_RE = re.compile( + r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL +) + + +def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: + data = files.get(_PRESENTATION) + if data is None: + return False + match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) + return match is not None and match.group(1) == " bool: + return inert_notes and master.startswith(_NOTES_MASTERS) + + +def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + ] + + +def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: + inert_notes = _notes_master_share_is_inert(files) + return [ + f"{master} shares {theme} with {first}" + for master, _, _, theme, first in _shares(files) + if not _is_inert(master, inert_notes) + ] diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/mce/mc.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 00000000..f65f7777 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 00000000..6b00755a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 00000000..f321d333 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 00000000..364c6a9b --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 00000000..fed9d15b --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 00000000..680cf154 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 00000000..89ada908 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/soffice.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/soffice.py new file mode 100644 index 00000000..49bfe9f9 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/soffice.py @@ -0,0 +1,61 @@ +"""Portable LibreOffice runner for Windows, macOS and Linux.""" + +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def resolve_soffice_binary(): + explicit = os.environ.get("SOFFICE_PATH") + if explicit: + candidate = Path(explicit).expanduser() + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError(f"SOFFICE_PATH does not exist: {candidate}") + names = ("soffice.com", "soffice.exe", "soffice") if platform.system() == "Windows" else ("soffice",) + for name in names: + binary = shutil.which(name) + if binary: + return binary + candidates = [Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")] + if platform.system() == "Windows": + candidates = [ + Path(root) / "LibreOffice/program" / name + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")) if root + for name in ("soffice.com", "soffice.exe") + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + raise FileNotFoundError("LibreOffice not found. Install it or set SOFFICE_PATH to its executable.") + + +def get_soffice_env(): + env = os.environ.copy() + env["PYTHONUTF8"] = "1" + if platform.system() == "Linux": + env.setdefault("SAL_USE_VCLPLUGIN", "svp") + return env + + +def run_soffice(args, **kwargs): + binary = resolve_soffice_binary() + kwargs.setdefault("env", get_soffice_env()) + kwargs.setdefault("timeout", 120) + if any(str(arg).startswith("-env:UserInstallation=") for arg in args): + return subprocess.run([binary, *args], **kwargs) + with tempfile.TemporaryDirectory(prefix="skill-lo-", ignore_cleanup_errors=True) as profile: + profile_arg = f"-env:UserInstallation={Path(profile).resolve().as_uri()}" + return subprocess.run([binary, profile_arg, *args], **kwargs) + + +if __name__ == "__main__": + try: + raise SystemExit(run_soffice(sys.argv[1:]).returncode) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validate.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validate.py new file mode 100755 index 00000000..29ca186a --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validate.py @@ -0,0 +1,173 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import OOXML_FAMILY, rezip, safe_extract +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def _fail(message: str): + print(f"Error: {message}", file=sys.stderr) + sys.exit(2) + + +def _has_tracked_changes(unpacked_dir: Path) -> bool: + document = unpacked_dir / "word" / "document.xml" + if not document.is_file(): + return False + try: + root = ET.parse(document).getroot() + except (ET.ParseError, DefusedXmlException): + return False + tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} + return any(elem.tag in tracked for elem in root.iter()) + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation). " + "Modifies the input in place: repairs to a packed file are written back to it.", + ) + parser.add_argument( + "--author", + default=None, + help="The name you are redlining under. Passing it turns on the " + "tracked-change check: any text differing from --original without a " + "/ recording it is reported. Untracked edits carry no " + "author, so the check covers them whoever made them — the name marks " + "the run as redlining work and is not used to filter. Requires " + "--original; docx only.", + ) + args = parser.parse_args() + + if args.author is not None and not args.original: + _fail("--author requires --original") + + path = Path(args.path) + if not path.exists(): + _fail(f"{path} does not exist") + + original_file = None + if args.original: + original_file = Path(args.original) + if not original_file.is_file(): + _fail(f"{original_file} is not a file") + if original_file.suffix.lower() not in OOXML_FAMILY: + _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") + + family = OOXML_FAMILY.get((original_file or path).suffix.lower()) + if family is None: + _fail( + f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." + ) + + if args.author is not None and family != "docx": + _fail(f"--author only applies to docx files, not {family}") + + packed_file = None + temp_dir_ctx = None + if path.is_file() and path.suffix.lower() in OOXML_FAMILY: + packed_file = path + temp_dir_ctx = tempfile.TemporaryDirectory() + unpacked_dir = Path(temp_dir_ctx.name) + try: + with zipfile.ZipFile(path, "r") as zf: + safe_extract(zf, unpacked_dir) + except (zipfile.BadZipFile, ValueError, OSError) as e: + _fail(f"cannot unpack {path}: {e}") + else: + if not path.is_dir(): + _fail(f"{path} is not a directory or Office file") + unpacked_dir = path + + match family: + case "docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if args.author is not None: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) + ) + elif original_file and _has_tracked_changes(unpacked_dir): + print( + "Note: this document has tracked changes; they were not " + "checked against the original (pass --author to check)." + ) + case "pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case "xlsx": + exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") + print( + f"No XSD schema validation is performed for xlsx-family files ({exts}). " + "For formula-error checking, use scripts/recalc.py instead." + ) + sys.exit(0) + case _: + print(f"Error: Validation not supported for file type {family}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + if packed_file is not None: + rezip(unpacked_dir, packed_file) + print(f"Wrote repaired file to {packed_file}") + + success = all([v.validate() for v in validators]) + + if temp_dir_ctx is not None: + temp_dir_ctx.cleanup() + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/__init__.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/__init__.py new file mode 100644 index 00000000..db092ece --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/base.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/base.py new file mode 100644 index 00000000..33fc97bb --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/base.py @@ -0,0 +1,875 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +from functools import lru_cache + +import lxml.etree + +from helpers import safe_extract + + +@lru_cache(maxsize=None) +def _load_schema(schema_path: str): + with open(schema_path, "rb") as xsd_file: + xsd_doc = lxml.etree.parse( + xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path + ) + return lxml.etree.XMLSchema(xsd_doc) + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + pending = [] + + for elem in dom.getElementsByTagName("*"): + local_name = elem.tagName.rsplit(":", 1)[-1] + if local_name in ("t", "delText", "instrText", "delInstrText"): + text = "".join( + child.data + for child in elem.childNodes + if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) + ) + ws = (" ", "\t", "\n", "\r") + if text and (text.startswith(ws) or text.endswith(ws)): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + + if pending: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if rel.get("TargetMode") == "External": + continue + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _preprocess_for_schema(self, xml_doc, relative_path): + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): + schema_path = schema_path or self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + schema = _load_schema(str(schema_path)) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + xml_doc = self._preprocess_for_schema(xml_doc, relative_path) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file, schema_path=None): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + safe_extract(zip_ref, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path, schema_path=schema_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/docx.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/docx.py new file mode 100644 index 00000000..b1814994 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/docx.py @@ -0,0 +1,466 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + +from helpers import safe_extract + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + safe_extract(zip_ref, Path(temp_dir)) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + new_count = self.count_paragraphs_in_unpacked() + if self.original_file is None: + print(f"\nParagraphs: {new_count}") + return + + original_count = self.count_paragraphs_in_original() + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + try: + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"paraId={val} is not valid hex" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + try: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} is not valid hex" + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") + repairs = 0 + renames: dict = {} + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + is_numbering = xml_file.name == "numbering.xml" + base = 10 if is_numbering else 16 + pending = [] + seen_in_file = set() + modified = False + + for elem in dom.getElementsByTagName("*"): + for attr_name in DURABLE_ID_ATTRS: + if not elem.hasAttribute(attr_name): + continue + + durable_id = elem.getAttribute(attr_name) + try: + key = self._parse_id_value(durable_id, base=base) + needs_repair = key >= 0x7FFFFFFF + except ValueError: + key = durable_id + needs_repair = True + + if needs_repair: + if key in seen_in_file: + value = random.randint(1, 0x7FFFFFFE) + else: + seen_in_file.add(key) + if key not in renames: + renames[key] = random.randint(1, 0x7FFFFFFE) + value = renames[key] + new_id = str(value) if is_numbering else f"{value:08X}" + + elem.setAttribute(attr_name, new_id) + pending.append( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + for message in pending: + print(message) + repairs += len(pending) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/pptx.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/pptx.py new file mode 100644 index 00000000..318f0e61 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/pptx.py @@ -0,0 +1,441 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re +from pathlib import Path + +from helpers import opc_target, rels_source_part, safe_extract + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + if not self.validate_master_theme_uniqueness(): + all_valid = False + + if not self.validate_charts(): + all_valid = False + + if not self.validate_slides(): + all_valid = False + + return all_valid + + def _package_map(self) -> dict: + wanted = [] + wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) + wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) + wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) + wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) + for group in ("slideMasters", "notesMasters", "handoutMasters"): + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) + wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) + return { + p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() + for p in wanted + if p.is_file() + } + + def validate_master_theme_uniqueness(self): + from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes + + shared = live_shared_master_themes(self._package_map()) + if shared: + print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") + for message in shared: + print(f" {message}") + if any(m.startswith(_NOTES_MASTERS) for m in shared): + print(" Fix: in ppt/presentation.xml, move back to " + "directly after . PowerPoint reads that happily.") + else: + print(" Fix: give each master its own theme part.") + return False + + if self.verbose: + print("PASSED - No master shares a theme part in a way PowerPoint refuses") + return True + + def validate_charts(self): + from helpers.pptx_chart import find_chart_problems + + problems = find_chart_problems(self._package_map()) + if problems: + print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") + for message in problems: + print(f" {message}") + return False + + if self.verbose: + print("PASSED - Charts satisfy the constraints PowerPoint enforces") + return True + + def _original_slide_defects(self, schema) -> set[str]: + import tempfile + import zipfile + + from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors + + if self.original_file is None: + return set() + + found: set[str] = set() + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + try: + with zipfile.ZipFile(self.original_file, "r") as zf: + safe_extract(zf, temp_path) + except (zipfile.BadZipFile, ValueError, OSError): + return set() + + for part in sorted(temp_path.rglob("*.xml")): + relative = part.relative_to(temp_path).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + part.resolve(), temp_path.resolve(), schema_path=schema + ) + if ok is None or ok or not errors: + continue + found |= set(fatal_slide_errors(set(errors))) + return found + + def validate_slides(self): + from helpers.pptx_slide import ( + SLIDE_PART_RE, + fatal_slide_errors, + is_schema_verdict, + ) + + schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] + inherited = self._original_slide_defects(schema) + problems: list[str] = [] + broken: list[str] = [] + + for xml_file in self.xml_files: + relative = xml_file.relative_to(self.unpacked_dir).as_posix() + if not SLIDE_PART_RE.fullmatch(relative): + continue + ok, errors = self._validate_single_file_xsd( + xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema + ) + if ok is None or not errors: + continue + + unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] + if unreadable: + broken.extend(unreadable) + continue + if ok: + continue + + for message in fatal_slide_errors(set(errors)): + if message in inherited: + continue + problems.append(f"{relative}: {message}") + + if broken: + print(f"FAILED - Could not check {len(broken)} slide part(s):") + for message in sorted(broken): + print(f" {message[:240]}") + + if problems: + print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") + for message in sorted(problems): + print(f" {message[:240]}") + + if broken or problems: + return False + + if self.verbose: + print("PASSED - Slide XML has none of the defects PowerPoint refuses") + return True + + def _get_schema_path(self, xml_file): + if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): + return None + return super()._get_schema_path(xml_file) + + def _preprocess_for_schema(self, xml_doc, relative_path): + if relative_path.as_posix() != "ppt/presentation.xml": + return xml_doc + + root = xml_doc.getroot() + ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" + notes = root.find(f"{ns}notesMasterIdLst") + slides = root.find(f"{ns}sldIdLst") + if notes is None or slides is None: + return xml_doc + + children = list(root) + if children.index(notes) < children.index(slides): + return xml_doc + + root.remove(notes) + root.insert(list(root).index(slides), notes) + return xml_doc + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + part = opc_target( + rel.get("Target", ""), + rels_source_part(rels_file, self.unpacked_dir), + rel.get("TargetMode", ""), + ) + if part: + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + notes_slide_references.setdefault(part, []).append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/redlining.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/redlining.py new file mode 100644 index 00000000..4185c51f --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office/validators/redlining.py @@ -0,0 +1,299 @@ +""" +Validator for tracked changes in Word documents. + +Detects untracked edits in word/document.xml: text that differs from the +original without a / wrapper recording it. The tracked changes +that are new relative to the original are undone, and the result is compared +against the original; whatever text still differs was edited without being +tracked. + +Only the document body is compared. Headers, footers, footnotes and endnotes +are separate parts and are not checked. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.ElementTree as ET +from defusedxml.common import DefusedXmlException + +from helpers import rendered_text, safe_extract + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + safe_extract(zip_ref, temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except (ET.ParseError, DefusedXmlException) as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + new_changes = self._new_tracked_changes(original_root, modified_root) + self._remove_tracked_changes(modified_root, new_changes) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print( + f"PASSED - All {len(new_changes)} change(s) against the original " + "are properly tracked" + ) + return True + + def _tracked_change_elements(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] + + def _rendered_text(self, elem): + preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" + return rendered_text(elem.text or "", preserve) + + def _text_elements(self, elem): + w = self.namespaces["w"] + return [ + node + for node in elem.iter() + if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") + ] + + def _tracked_change_key(self, elem): + w = self.namespaces["w"] + text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) + return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) + + def _new_tracked_changes(self, original_root, modified_root): + original = self._tracked_change_elements(original_root) + modified = self._tracked_change_elements(modified_root) + + pool = {} + for elem in original: + pool.setdefault(self._tracked_change_key(elem), []).append(elem) + + matched, leftover = set(), [] + for elem in modified: + bucket = pool.get(self._tracked_change_key(elem)) + if bucket: + matched.add(bucket.pop()) + else: + leftover.append(elem) + + def group(elem): + return self._tracked_change_key(elem)[:3] + + def text_of(elems): + return "".join(self._tracked_change_key(e)[3] for e in elems) + + unmatched_original = {} + for elem in original: + if elem not in matched: + unmatched_original.setdefault(group(elem), []).append(elem) + + by_group = {} + for elem in leftover: + by_group.setdefault(group(elem), []).append(elem) + + new = set() + for key, elems in by_group.items(): + rebuilt = text_of(elems) + if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): + continue + new.update(elems) + return new + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + "FAILED - Document text doesn't match after removing the tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + " 4. Rewrote another author's / and changed its text on", + " the way. A tracked change from the original is recognised by its", + " author, date and text; anything that doesn't reproduce one exactly", + " reads as new, and the text it carried is reported missing.", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To reject PART of one: nest around only the runs you reject.", + " Their may be split around it, so long as the pieces keep", + " their author and date and still spell out the same text.", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_tracked_changes(self, root, targets): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child in targets: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child in targets: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + text_parts.append(self._rendered_text(t_elem)) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office_package.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office_package.py new file mode 100644 index 00000000..39b1f27b --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/office_package.py @@ -0,0 +1,52 @@ +"""Unpack or repack an OOXML document without shell-specific ZIP commands.""" + +import argparse +import os +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +def unpack(source, destination): + root = Path(destination).resolve() + if root.exists() and any(root.iterdir()): + raise ValueError("Unpack destination must be empty") + with zipfile.ZipFile(source) as archive: + for info in archive.infolist(): + name = PurePosixPath(info.filename.replace("\\", "/")) + if name.is_absolute() or ".." in name.parts or any(":" in part for part in name.parts): + raise ValueError(f"Unsafe ZIP entry: {info.filename}") + if (info.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"ZIP symlink is not supported: {info.filename}") + archive.extractall(root) + + +def pack(source, destination): + root = Path(source).resolve() + output = Path(destination).resolve() + if output.is_relative_to(root): + raise ValueError("Output must be outside the unpacked directory") + if not (root / "[Content_Types].xml").is_file(): + raise ValueError("Source must be the package root containing [Content_Types].xml") + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(suffix=output.suffix, dir=output.parent) + os.close(fd) + try: + with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: + for file in sorted(root.rglob("*")): + if file.is_symlink(): + raise ValueError(f"Package symlink is not supported: {file}") + if file.is_file(): + archive.write(file, file.relative_to(root).as_posix()) + os.replace(temporary, output) + finally: + Path(temporary).unlink(missing_ok=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("unpack", "pack")) + parser.add_argument("input") + parser.add_argument("output") + args = parser.parse_args() + {"unpack": unpack, "pack": pack}[args.action](args.input, args.output) diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/recalc.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/recalc.py new file mode 100644 index 00000000..74dc430b --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/recalc.py @@ -0,0 +1,89 @@ +"""Recalculate an XLSX through a temporary LibreOffice import/export, without macros.""" + +import argparse +import json +import os +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from openpyxl import load_workbook +from office.soffice import run_soffice + + +def inspect_workbook(path): + formulas = load_workbook(path, data_only=False) + values = load_workbook(path, data_only=True) + try: + formula_cells = set() + errors = {} + for sheet in formulas: + if not hasattr(sheet, "iter_rows"): + continue + for row in sheet.iter_rows(): + for cell in row: + location = f"{sheet.title}!{cell.coordinate}" + if cell.data_type == "f": + formula_cells.add(location) + cached = values[sheet.title][cell.coordinate] + if cached.data_type == "e": + errors.setdefault(str(cached.value), []).append(location) + return formula_cells, errors + finally: + formulas.close() + values.close() + + +def recalc(filename, timeout=120): + source = Path(filename).expanduser().resolve() + if not source.is_file() or source.suffix.lower() != ".xlsx": + return {"error": "Input must be an existing .xlsx file; use Excel for macro-enabled workbooks"} + try: + with zipfile.ZipFile(source) as archive: + if any(name.startswith("xl/externalLinks/") for name in archive.namelist()): + return {"error": "External workbook links require validation in Excel; original file was not modified"} + original_formulas, _ = inspect_workbook(source) + with tempfile.TemporaryDirectory(prefix="xlsx-recalc-", dir=source.parent, ignore_cleanup_errors=True) as temporary: + root = Path(temporary) + input_dir, output_dir = root / "input", root / "output" + input_dir.mkdir() + output_dir.mkdir() + candidate = input_dir / source.name + shutil.copy2(source, candidate) + result = run_soffice([ + "--headless", "--norestore", "--convert-to", "xlsx:Calc MS Excel 2007 XML", + "--outdir", str(output_dir), str(candidate), + ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) + recalculated = output_dir / source.name + if result.returncode or not recalculated.is_file(): + return {"error": "LibreOffice did not export a recalculated workbook", "details": result.stderr or result.stdout} + formula_cells, errors = inspect_workbook(recalculated) + if original_formulas != formula_cells: + return {"error": "Formula locations changed during conversion; original file was not modified"} + error_count = sum(len(locations) for locations in errors.values()) + report = { + "status": "errors_found" if error_count else "success", + "total_formulas": len(formula_cells), + "total_errors": error_count, + "error_summary": { + error: {"count": len(locations), "locations": locations[:100]} + for error, locations in errors.items() + }, + } + if not errors: + os.replace(recalculated, source) + return report + except (OSError, subprocess.SubprocessError, zipfile.BadZipFile, ValueError) as exc: + return {"error": str(exc)} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("timeout", nargs="?", type=int, default=120) + args = parser.parse_args() + report = recalc(args.input, args.timeout) + print(json.dumps(report, ensure_ascii=False, indent=2)) + raise SystemExit(1 if "error" in report else 0) diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/scripts/render_pdf.py b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/render_pdf.py new file mode 100644 index 00000000..4820bbdc --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/scripts/render_pdf.py @@ -0,0 +1,29 @@ +"""Render PDF pages without Poppler; requires PyMuPDF.""" + +import argparse +from pathlib import Path + +import pymupdf + + +def convert(pdf_path, output_dir, max_dim=1600): + if max_dim <= 0: + raise ValueError("max_dim must be positive") + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + with pymupdf.open(pdf_path) as document: + for index, page in enumerate(document): + scale = min(max_dim / page.rect.width, max_dim / page.rect.height) + pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False) + target = output / f"page_{index + 1}.png" + pixmap.save(str(target)) + print(target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--max-dim", type=int, default=1600) + args = parser.parse_args() + convert(args.input, args.output, args.max_dim) diff --git a/plugins/codex/plugins/xlsx/skills/xlsx/upstream-guide.md b/plugins/codex/plugins/xlsx/skills/xlsx/upstream-guide.md new file mode 100644 index 00000000..9da54804 --- /dev/null +++ b/plugins/codex/plugins/xlsx/skills/xlsx/upstream-guide.md @@ -0,0 +1,99 @@ +--- +name: xlsx +description: "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .xltx, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved." +license: Proprietary. LICENSE.txt has complete terms +--- + +# XLSX creation, editing, and analysis + +| Task | Approach | +|---|---| +| **Create** or **edit** with formulas/formatting | `openpyxl` — see gotchas below | +| **Bulk data** in or out | `pandas` (`read_excel`, `to_excel`) | +| **Quick look** at a sheet | `markitdown file.xlsx` — `## SheetName` per sheet; reads `.xlsm` too. No cell coordinates, so don't plan edits from it | +| **Read** a model (formulas *and* values) | two `load_workbook` passes — see gotchas | + +> `openpyxl`, `pandas`, and `markitdown` are preinstalled — do not run `pip install` first; write the script and import directly. Only if an import fails (or the `markitdown` command is missing): `pip install` the missing package. + +> Script paths below are relative to this skill's directory. + +## Requirements for every output + +- **Professional font** (Arial, Times New Roman) throughout, unless the user says otherwise. +- **Zero formula errors.** Never ship while `recalc.py` reports `errors_found`. If you think an error predates you, prove it: load the *original* with `data_only=True` and look at that cell. An error you introduced looks exactly like one you inherited. +- **Use formulas, never hardcoded results.** Write `sheet['B10'] = '=SUM(B2:B9)'`, not the Python-computed total. The sheet must recalculate when its inputs change. +- **Follow the user's spec literally.** Exact tab names, exact column headers, and the formula they spelled out. A redesign that computes something else fails, however elegant. +- **Document every assumption and hardcoded number** where the reader will see it — a cell comment, or an adjacent cell at a table's end. Cite a real source when one exists (`Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]`); when the number came from the user, say so plainly. +- **A workbook *you create* for someone to fill in** needs a short legend naming which cells to edit, and one example row of realistic values showing the expected format. Never add such a row to a file you were asked to edit. +- **Editing an existing file: match its conventions exactly.** They override every guideline here. Find its designated input cells first — a distinct font color, fill, or shading marks them — write only there, and leave every existing formula untouched. + +## Recalculate (mandatory whenever the file contains formulas) + +openpyxl writes formulas as strings with **no cached values**. Until you recalculate, every +formula cell reads back as `None` to anything reading cached values — `pandas`, +`load_workbook(data_only=True)`, and most previewers. + +```bash +python scripts/recalc.py output.xlsx [timeout_seconds] # default 30 +``` + +LibreOffice computes every formula, the file is **rewritten in place**, and you get JSON: +`status` (`success` | `errors_found`), `total_formulas`, `total_errors`, and an +`error_summary` naming up to 100 cells per error type (`locations_truncated` says how many it +withheld — trust `total_errors`, not the length of the list). Fix what it names and run it +again. **JSON with an `error` key instead of a `status` means nothing was recalculated**, and +only that case exits non-zero — `errors_found` exits 0, so never treat a clean exit as a clean +workbook. + +**A green recalc proves your formulas *evaluate*, not that they are *right*.** An off-by-one +range or a reference to the wrong row yields a clean, error-free file with wrong numbers. +Write 2–3 formulas first and check they pull the values you expect, before building out a grid. + +**A workbook that links to another file loses those links** if you re-save it with openpyxl and +then recalculate. Such a formula reads `='[1]Returns Analysis'!$B$2` — the `[1]` is an index +into the workbook's external-reference list, naming a *separate file on disk*, not a sheet. +That file is rarely present here, so the cell's cached value is the only thing holding its +data. openpyxl strips that value on save; LibreOffice then has to resolve the reference for +real, fails, writes `#NAME?`, and deletes every link. `recalc.py` refuses to run in that state +— copy those cells' values out of the original before you save over them (`--force` overrides, +and accepts the loss). + +## Choosing formulas that survive verification + +LibreOffice implements fewer functions than Excel, and one it cannot evaluate becomes a +literal `#NAME?` baked into the file you deliver. + +- **Prefer Excel-2007-era functions** — `SUMIFS`, `INDEX`, `MATCH`, `IFERROR`, `SUMPRODUCT` — which need no prefix. +- **Six post-2007 functions work, but only with an `_xlfn.` prefix**, because openpyxl writes your formula into the XML verbatim and Excel stores post-2007 names prefixed (its UI hides the prefix): `_xlfn.TEXTJOIN`, `_xlfn.CONCAT`, `_xlfn.IFS`, `_xlfn.SWITCH`, `_xlfn.MAXIFS`, `_xlfn.MINIFS`. Written bare, each yields `#NAME?`. +- **Never use `XLOOKUP`, `XMATCH`, `SORT`, `FILTER`, `UNIQUE`, or `SEQUENCE`.** The runtime's LibreOffice cannot evaluate them under *any* prefix. Newer builds do evaluate them, but they are spilling array functions and an openpyxl-written file has no spill metadata, so only the top-left cell of the range gets a value — and `recalc.py` reports `total_errors: 0` on the truncated result. Use `INDEX`/`MATCH` for lookups, and sort, filter, and de-duplicate in Python before writing the cells. +- A formula LibreOffice could not parse is written back **lowercased** — a quick tell beside a `#NAME?`. + +## openpyxl gotchas + +- **Reading a model takes two loads.** `data_only=True` yields cached values with the formulas gone; the default yields formula strings with no values. One pass cannot give you both. +- **`data_only=True` is destructive if you save.** That workbook has no formulas left, so saving replaces every one with a literal — permanently. +- **`data_only=True` on a file openpyxl just wrote returns `None` everywhere** — run `recalc.py` first. (A formula whose result is `""` also reads back as `None`.) +- **Merged cells: write the top-left anchor only.** Every other cell in the range is a `MergedCell` whose `.value` is read-only. +- **`.xlsm` loses its macros unless you pass `keep_vba=True`** to `load_workbook`. +- **A sheet name containing a space must be quoted** in a cross-sheet reference: `='Assumptions Inputs'!$B$5`. Unquoted, it evaluates to `#VALUE!`. + +## Financial models + +Unless the user says otherwise, or the existing file already does something else. + +**Color:** blue text (`0,0,255`) for hardcoded inputs and scenario levers · black for formulas · +green (`0,128,0`) for links to another sheet · red (`255,0,0`) for links to another file · +yellow fill (`255,255,0`) for key assumptions and cells the user should fill in. + +**Numbers:** currency `$#,##0`, with the unit named in the header (`Revenue ($mm)`) · zeros +render as `-`, including in percentages (`$#,##0;($#,##0);-`) · negatives in parentheses · +percentages `0.0%`, **stored as fractions** (`0.15` renders `15.0%`; storing `15` renders +`1500.0%`) · valuation multiples `0.0x` · years as text (`"2024"`, never `2,024`). + +**Structure:** every assumption in its own labeled cell, referenced by the formulas that use it +(`=B5*(1+$B$6)`, never `=B5*1.05`) · formulas consistent across every projection period, since a +lone edited cell mid-row is the commonest silent error · guard denominators that can be zero. + +## Dependencies + +`openpyxl`, `pandas`, `markitdown` (pip, preinstalled — install only if an import fails or the command is missing) · LibreOffice (`soffice`, auto-configured for sandboxed environments via `scripts/office/soffice.py`) diff --git a/scripts/sync_external_plugins.ps1 b/scripts/sync_external_plugins.ps1 index 46fa3abf..64cb0939 100644 --- a/scripts/sync_external_plugins.ps1 +++ b/scripts/sync_external_plugins.ps1 @@ -975,6 +975,7 @@ function Sync-ClaudeSkill { throw "materializePaths source does not exist: $materializeSource" } Remove-PathIfExists -Path $materializeDestination -Within $RepoRoot + New-Item -ItemType Directory -Force -Path (Split-Path -Path $materializeDestination -Parent) | Out-Null Copy-Item -LiteralPath $materializeSource -Destination $materializeDestination -Recurse -Force } diff --git a/scripts/tests/test_document_portability.py b/scripts/tests/test_document_portability.py new file mode 100644 index 00000000..8f0a64ab --- /dev/null +++ b/scripts/tests/test_document_portability.py @@ -0,0 +1,77 @@ +import importlib.util +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +COMMON = ROOT / "config/skill-overrides/common" + + +def load_module(name): + spec = importlib.util.spec_from_file_location(name, COMMON / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class OfficePortabilityTests(unittest.TestCase): + def test_windows_install_path_and_arguments_with_spaces(self): + office = load_module("soffice") + with tempfile.TemporaryDirectory() as temporary: + program_files = Path(temporary) / "Program Files" + executable = program_files / "LibreOffice/program/soffice.com" + executable.parent.mkdir(parents=True) + executable.touch() + with patch.object(office.platform, "system", return_value="Windows"), \ + patch.object(office.shutil, "which", return_value=None), \ + patch.dict(office.os.environ, {"ProgramFiles": str(program_files)}, clear=True), \ + patch.object(office.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + office.run_soffice(["--headless", "--convert-to", "pdf", "中文 文件.docx"]) + args, kwargs = run.call_args + self.assertEqual(args[0][0], str(executable)) + self.assertEqual(args[0][-1], "中文 文件.docx") + self.assertTrue(args[0][1].startswith("-env:UserInstallation=file:")) + self.assertNotIn("LD_PRELOAD", kwargs["env"]) + + def test_existing_profile_is_preserved(self): + office = load_module("soffice") + with patch.object(office, "resolve_soffice_binary", return_value="soffice"), \ + patch.object(office.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + office.run_soffice(["-env:UserInstallation=file:///profile", "--headless"]) + self.assertEqual(run.call_args.args[0], ["soffice", "-env:UserInstallation=file:///profile", "--headless"]) + + def test_package_roundtrip_and_removed_parts(self): + package = load_module("office_package") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "中文 输入.docx" + with zipfile.ZipFile(source, "w") as archive: + archive.writestr("[Content_Types].xml", "") + archive.writestr("word/document.xml", "<正文/>") + archive.writestr("word/removed.xml", "") + extracted = root / "解包" + package.unpack(source, extracted) + (extracted / "word/removed.xml").unlink() + output = root / "中文 输出.docx" + package.pack(extracted, output) + with zipfile.ZipFile(output) as archive: + self.assertNotIn("word/removed.xml", archive.namelist()) + self.assertEqual(archive.read("word/document.xml").decode(), "<正文/>") + + def test_package_rejects_path_escape(self): + package = load_module("office_package") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "bad.docx" + with zipfile.ZipFile(source, "w") as archive: + archive.writestr("../escape.txt", "bad") + with self.assertRaisesRegex(ValueError, "Unsafe ZIP entry"): + package.unpack(source, root / "unpacked") + self.assertFalse((root / "escape.txt").exists()) + + +if __name__ == "__main__": + unittest.main()