Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
1 parent
be7ce9bda2
commit
36c089fee9
298 files changed
+32935
-11681
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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 目录。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 48 48"><title>docx</title><g fill="none"><path fill="url(#SVGEun4xcUP)" d="M11 43h26a3 3 0 0 0 3-3V18l-10-4l-4-10H11a3 3 0 0 0-3 3v33a3 3 0 0 0 3 3"/><path fill="url(#SVGcM4dxcaR)" fill-opacity=".5" d="M11 43h26a3 3 0 0 0 3-3V18l-10-4l-4-10H11a3 3 0 0 0-3 3v33a3 3 0 0 0 3 3"/><path fill="url(#SVGs75ELblf)" d="M26 15V4l14 14H29a3 3 0 0 1-3-3"/><path fill="url(#SVGdS9JudtG)" fill-opacity=".9" d="M16.25 23a1.25 1.25 0 1 0 0 2.5h15.5a1.25 1.25 0 1 0 0-2.5zM15 30.25c0-.69.56-1.25 1.25-1.25h15.5a1.25 1.25 0 1 1 0 2.5h-15.5c-.69 0-1.25-.56-1.25-1.25M16.25 35a1.25 1.25 0 1 0 0 2.5h15.5a1.25 1.25 0 1 0 0-2.5z"/><defs><linearGradient id="SVGEun4xcUP" x1="30.4" x2="33.484" y1="4" y2="36.911" gradientUnits="userSpaceOnUse"><stop stop-color="#6ce0ff"/><stop offset="1" stop-color="#4894fe"/></linearGradient><linearGradient id="SVGs75ELblf" x1="32.977" x2="29.477" y1="9.833" y2="15.667" gradientUnits="userSpaceOnUse"><stop stop-color="#9ff0f9"/><stop offset="1" stop-color="#b3e0ff"/></linearGradient><linearGradient id="SVGdS9JudtG" x1="33" x2="17.47" y1="43.3" y2="15.805" gradientUnits="userSpaceOnUse"><stop stop-color="#9deaff"/><stop offset="1" stop-color="#fff"/></linearGradient><radialGradient id="SVGcM4dxcaR" cx="0" cy="0" r="1" gradientTransform="rotate(134.348 19.568 11.308)scale(24.7968 14.6539)" gradientUnits="userSpaceOnUse"><stop offset=".362" stop-color="#4a43cb"/><stop offset="1" stop-color="#4a43cb" stop-opacity="0"/></radialGradient></defs></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 24 24"><title>find-skills</title><g fill="none"><path fill="url(#SVG5b4mGcbO)" d="M21 19c0 2.5-4.03 4-9 4s-9-1.5-9-4c0-2.406 4.03-4 9-4s9 1.5 9 4"/><path fill="url(#SVGfOKLrdNQ)" d="M12 2a7.5 7.5 0 0 0-7.5 7.5c0 1.932 1.064 3.8 2.268 5.316c1.22 1.537 2.678 2.829 3.655 3.622c.926.75 2.228.75 3.154 0c.977-.793 2.435-2.085 3.655-3.622C18.436 13.301 19.5 11.432 19.5 9.5A7.5 7.5 0 0 0 12 2"/><path fill="url(#SVGOpCNSdas)" d="M14.5 9.5a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0"/><defs><linearGradient id="SVGfOKLrdNQ" x1="1.219" x2="13.202" y1="-2.857" y2="16.549" gradientUnits="userSpaceOnUse"><stop stop-color="#f97dbd"/><stop offset="1" stop-color="#d7257d"/></linearGradient><linearGradient id="SVGOpCNSdas" x1="9.79" x2="12.394" y1="9.721" y2="12.428" gradientUnits="userSpaceOnUse"><stop stop-color="#fdfdfd"/><stop offset="1" stop-color="#fecbe6"/></linearGradient><radialGradient id="SVG5b4mGcbO" cx="0" cy="0" r="1" gradientTransform="rotate(-10.678 100.2 -51.93)scale(14.3921 6.38107)" gradientUnits="userSpaceOnUse"><stop stop-color="#7b7bff"/><stop offset=".502" stop-color="#a3a3ff"/><stop offset="1" stop-color="#ceb0ff"/></radialGradient></defs></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 32 32"><title>pdf</title><g fill="none"><path fill="url(#SVGbYeP8enY)" d="M8.5 4A3.5 3.5 0 0 0 5 7.5v19A3.5 3.5 0 0 0 8.5 30h8.974c.146-.328.353-.634.621-.903l8.505-8.505a3 3 0 0 1 .4-.336V7.5A3.5 3.5 0 0 0 23.5 4z"/><path fill="url(#SVGjpE8fcaQ)" fill-opacity=".9" d="M10 13a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H11a1 1 0 0 1-1-1"/><path fill="url(#SVGjpE8fcaQ)" fill-opacity=".9" d="M10 18a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1"/><path fill="url(#SVGjpE8fcaQ)" fill-opacity=".9" d="M11 22a1 1 0 1 0 0 2h10a1 1 0 1 0 0-2z"/><path fill="url(#SVG8Zdw7rWn)" fill-opacity=".4" d="M8.5 4A3.5 3.5 0 0 0 5 7.5v19A3.5 3.5 0 0 0 8.5 30h8.974c.146-.328.353-.634.621-.903l8.505-8.505a3 3 0 0 1 .4-.336V7.5A3.5 3.5 0 0 0 23.5 4z"/><path fill="url(#SVGYPBejbdj)" fill-opacity=".4" d="M8.5 4A3.5 3.5 0 0 0 5 7.5v19A3.5 3.5 0 0 0 8.5 30h8.974c.146-.328.353-.634.621-.903l8.505-8.505a3 3 0 0 1 .4-.336V7.5A3.5 3.5 0 0 0 23.5 4z"/><path fill="url(#SVGTGP5Fd9u)" fill-opacity=".4" d="M8.5 4A3.5 3.5 0 0 0 5 7.5v19A3.5 3.5 0 0 0 8.5 30h8.974c.146-.328.353-.634.621-.903l8.505-8.505a3 3 0 0 1 .4-.336V7.5A3.5 3.5 0 0 0 23.5 4z"/><path fill="url(#SVGKHVETdmG)" d="M10 5a3 3 0 0 1 3-3h6a3 3 0 1 1 0 6h-6a3 3 0 0 1-3-3"/><path fill="url(#SVG2Rqg1dqL)" d="m20.539 29.469l7.61-7.543l-4.073-4.074l-7.567 7.64l.308 3.695z"/><path fill="url(#SVGTaD2n6OC)" d="m26.937 23.14l2.211-2.214a2.88 2.88 0 0 0 .072-4.017a2.88 2.88 0 0 0-4.144-.057l-2.238 2.241z"/><path fill="url(#SVGBGLG8drx)" d="M24.094 17.838a5.43 5.43 0 0 0 4.106 4.038l-1.55 1.551a5.43 5.43 0 0 1-4.106-4.04z"/><path fill="url(#SVGxKDc0jMv)" d="m20.539 29.47l.223-.223s-1.726-.661-2.535-1.47c-.809-.81-1.47-2.534-1.47-2.534l-.248.249a2.66 2.66 0 0 0-.686 1.206l-.79 3.051a1 1 0 0 0 1.217 1.22l3.02-.778a2.8 2.8 0 0 0 1.269-.722"/><defs><linearGradient id="SVGbYeP8enY" x1="5" x2="25.632" y1="6.6" y2="31.15" gradientUnits="userSpaceOnUse"><stop stop-color="#36dff1"/><stop offset="1" stop-color="#0094f0"/></linearGradient><linearGradient id="SVGjpE8fcaQ" x1="17.5" x2="8.622" y1="24" y2="13.125" gradientUnits="userSpaceOnUse"><stop stop-color="#9deaff"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="SVGKHVETdmG" x1="16" x2="16" y1="2" y2="8" gradientUnits="userSpaceOnUse"><stop stop-color="#ffe06b"/><stop offset="1" stop-color="#fab500"/></linearGradient><linearGradient id="SVG2Rqg1dqL" x1="19.861" x2="26.044" y1="19.948" y2="26.149" gradientUnits="userSpaceOnUse"><stop stop-color="#ffa43d"/><stop offset="1" stop-color="#fb5937"/></linearGradient><linearGradient id="SVGTaD2n6OC" x1="28.502" x2="25.869" y1="17.485" y2="19.969" gradientUnits="userSpaceOnUse"><stop stop-color="#f97dbd"/><stop offset="1" stop-color="#dd3ce2"/></linearGradient><linearGradient id="SVGBGLG8drx" x1="25.469" x2="21.489" y1="21.664" y2="19.902" gradientUnits="userSpaceOnUse"><stop stop-color="#ff921f"/><stop offset="1" stop-color="#ffe994"/></linearGradient><linearGradient id="SVGxKDc0jMv" x1="14.174" x2="18.325" y1="26.847" y2="30.975" gradientUnits="userSpaceOnUse"><stop offset=".255" stop-color="#ffd394"/><stop offset="1" stop-color="#ff921f"/></linearGradient><radialGradient id="SVG8Zdw7rWn" cx="0" cy="0" r="1" gradientTransform="matrix(8.9375 0 0 7.86963 16 2.556)" gradientUnits="userSpaceOnUse"><stop stop-color="#0a1852"/><stop offset="1" stop-color="#0a1852" stop-opacity="0"/></radialGradient><radialGradient id="SVGYPBejbdj" cx="0" cy="0" r="1" gradientTransform="matrix(8.9375 0 0 8.87743 16 2.556)" gradientUnits="userSpaceOnUse"><stop stop-color="#0a1852"/><stop offset="1" stop-color="#0a1852" stop-opacity="0"/></radialGradient><radialGradient id="SVGTGP5Fd9u" cx="0" cy="0" r="1" gradientTransform="rotate(135.565 7.13 16.2)scale(14.4424 6.48727)" gradientUnits="userSpaceOnUse"><stop stop-color="#0a1852"/><stop offset="1" stop-color="#0a1852" stop-opacity="0"/></radialGradient></defs></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 24 24"><title>pptx</title><g fill="none"><path fill="url(#SVGdFie5cDw)" d="M8 6.25A2.25 2.25 0 0 1 10.25 4h7.5A2.25 2.25 0 0 1 20 6.25v8.5A2.25 2.25 0 0 1 17.75 17h-7.5A2.25 2.25 0 0 1 8 14.75z"/><path fill="url(#SVGXYfMve6h)" d="M8 6.25A2.25 2.25 0 0 1 10.25 4h7.5A2.25 2.25 0 0 1 20 6.25v8.5A2.25 2.25 0 0 1 17.75 17h-7.5A2.25 2.25 0 0 1 8 14.75z"/><path fill="url(#SVGCfFQgeqH)" d="M4 4.25A2.25 2.25 0 0 1 6.25 2h9a2.25 2.25 0 0 1 2.25 2.25v10.5A2.25 2.25 0 0 1 15.25 17h-9A2.25 2.25 0 0 1 4 14.75z"/><path fill="url(#SVGBFc5OckO)" d="M5.25 8A2.25 2.25 0 0 0 3 10.25v8.5A3.25 3.25 0 0 0 6.25 22h11.5A3.25 3.25 0 0 0 21 18.75v-1.5A2.25 2.25 0 0 0 18.75 15h-2.846a.75.75 0 0 1-.55-.24l-5.61-6.04A2.25 2.25 0 0 0 8.097 8z"/><defs><linearGradient id="SVGdFie5cDw" x1="21.8" x2="23.639" y1="19.5" y2="5.773" gradientUnits="userSpaceOnUse"><stop stop-color="#bb45ea"/><stop offset="1" stop-color="#9c6cfe"/></linearGradient><linearGradient id="SVGXYfMve6h" x1="20" x2="17" y1="8.5" y2="8.5" gradientUnits="userSpaceOnUse"><stop offset=".338" stop-color="#5750e2" stop-opacity="0"/><stop offset="1" stop-color="#5750e2"/></linearGradient><linearGradient id="SVGBFc5OckO" x1="6.857" x2="6.857" y1="8" y2="27.091" gradientUnits="userSpaceOnUse"><stop offset=".241" stop-color="#ffd638"/><stop offset=".637" stop-color="#fab500"/><stop offset=".985" stop-color="#ca6407"/></linearGradient><radialGradient id="SVGCfFQgeqH" cx="0" cy="0" r="1" gradientTransform="matrix(8.775 -11.5 18.53666 14.14428 8.05 14)" gradientUnits="userSpaceOnUse"><stop offset=".228" stop-color="#2764e7"/><stop offset=".685" stop-color="#5cd1ff"/><stop offset="1" stop-color="#6ce0ff"/></radialGradient></defs></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="360" viewBox="0 0 48 48"><title>xlsx</title><g fill="none"><rect width="36" height="36" x="6" y="6" fill="url(#SVG1iPhScTz)" rx="8.5"/><path fill="url(#SVGM1bApbhK)" d="M12.25 6A6.25 6.25 0 0 0 6 12.25v4.25h10.5V6zM19 6v10.5h10V6zm-2.5 13H6v10h10.5zM19 29V19h10v10zm-2.5 2.5H6v4.25A6.25 6.25 0 0 0 12.25 42h4.25zM19 42h10V31.5H19zm12.5 0V31.5H42v4.25A6.25 6.25 0 0 1 35.75 42zM42 19v10H31.5V19zm0-2.5v-4.25A6.25 6.25 0 0 0 35.75 6H31.5v10.5z"/><defs><linearGradient id="SVG1iPhScTz" x1="8.571" x2="39.429" y1="8.571" y2="39.429" gradientUnits="userSpaceOnUse"><stop stop-color="#b0f098"/><stop offset="1" stop-color="#98f0b0"/></linearGradient><linearGradient id="SVGM1bApbhK" x1="6" x2="42" y1="6" y2="42" gradientUnits="userSpaceOnUse"><stop stop-color="#42b870"/><stop offset="1" stop-color="#1a7f7c"/></linearGradient></defs></g></svg>
|
||||
|
After Width: | Height: | Size: 900 B |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 文件与用户需要的最终副本。
|
||||
@@ -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 = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
|
||||
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">
|
||||
Sub AcceptAllTrackedChanges()
|
||||
Dim 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
|
||||
</script:module>'''
|
||||
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)
|
||||
@@ -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),旧平台专用命令以本地环境指导为准。
|
||||
@@ -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 逐页预览。
|
||||
@@ -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)。无法运行计算引擎时说明“尚未重算”,不要声称公式已验证。
|
||||
|
||||
交付前核对数值、公式、中文显示、筛选、冻结窗格、打印区域和图表。用新输出文件保留输入;抽查代表行、合计和异常情况,避免只检查文件能够打开。
|
||||
@@ -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)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -0,0 +1,7 @@
|
||||
<!-- Adapt the UI scope and DESIGN.md link to the target project. Merge into the effective AGENTS.md without replacing existing rules. This snippet is not a finished project instruction file. -->
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
---
|
||||
<!-- Scaffold only. Replace each bracketed prompt with project evidence, or omit an irrelevant section. Do not deliver these prompts as a finished guide. Add optional YAML tokens above this comment only when real values are known. No palette, font, radius, or component treatment is a default. -->
|
||||
|
||||
## 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.]
|
||||
@@ -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.
|
||||
+45
-168
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 <office_file> <output_directory>`
|
||||
|
||||
#### 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 `<w:ins>` (insertions) and `<w:del>` (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 <office_file> <output_directory>`
|
||||
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 <input_directory> <office_file>`
|
||||
|
||||
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 `<w:r>` 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
|
||||
'<w:del><w:r><w:delText>The term is 30 days.</w:delText></w:r></w:del><w:ins><w:r><w:t>The term is 60 days.</w:t></w:r></w:ins>'
|
||||
|
||||
# GOOD - Only marks what changed, preserves original <w:r> for unchanged text
|
||||
'<w:r w:rsidR="00AB12CD"><w:t>The term is </w:t></w:r><w:del><w:r><w:delText>30</w:delText></w:r></w:del><w:ins><w:r><w:t>60</w:t></w:r></w:ins><w:r w:rsidR="00AB12CD"><w:t> days.</w:t></w:r>'
|
||||
```
|
||||
|
||||
### 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 <file.docx> <dir>`
|
||||
- **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 `<w:r>` 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)
|
||||
最终转换为 PDF,用 `scripts/render_pdf.py` 生成预览并查看中文字体、表格、页眉页脚、分页和页码。保留可编辑 Word 文件与用户需要的最终副本。
|
||||
@@ -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)
|
||||
@@ -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 `<w:pPr>`**: `<w:pStyle>`, `<w:numPr>`, `<w:spacing>`, `<w:ind>`, `<w:jc>`
|
||||
- **Whitespace**: Add `xml:space='preserve'` to `<w:t>` 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 `<w:del>` and `<w:ins>` tags with `w:author="Claude"` outside `<w:r>` elements
|
||||
- **Critical**: `<w:ins>` closes with `</w:ins>`, `<w:del>` closes with `</w:del>` - never mix
|
||||
- **RSIDs must be 8-digit hex**: Use values like `00AB1234` (only 0-9, A-F characters)
|
||||
- **trackRevisions placement**: Add `<w:trackRevisions/>` after `<w:proofState>` 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
|
||||
<w:p>
|
||||
<w:r><w:t>Text content</w:t></w:r>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
### Headings and Styles
|
||||
```xml
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="Title"/>
|
||||
<w:jc w:val="center"/>
|
||||
</w:pPr>
|
||||
<w:r><w:t>Document Title</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<w:p>
|
||||
<w:pPr><w:pStyle w:val="Heading2"/></w:pPr>
|
||||
<w:r><w:t>Section Heading</w:t></w:r>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
### Text Formatting
|
||||
```xml
|
||||
<!-- Bold -->
|
||||
<w:r><w:rPr><w:b/><w:bCs/></w:rPr><w:t>Bold</w:t></w:r>
|
||||
<!-- Italic -->
|
||||
<w:r><w:rPr><w:i/><w:iCs/></w:rPr><w:t>Italic</w:t></w:r>
|
||||
<!-- Underline -->
|
||||
<w:r><w:rPr><w:u w:val="single"/></w:rPr><w:t>Underlined</w:t></w:r>
|
||||
<!-- Highlight -->
|
||||
<w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>Highlighted</w:t></w:r>
|
||||
```
|
||||
|
||||
### Lists
|
||||
```xml
|
||||
<!-- Numbered list -->
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="ListParagraph"/>
|
||||
<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>
|
||||
<w:spacing w:before="240"/>
|
||||
</w:pPr>
|
||||
<w:r><w:t>First item</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Restart numbered list at 1 - use different numId -->
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="ListParagraph"/>
|
||||
<w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr>
|
||||
<w:spacing w:before="240"/>
|
||||
</w:pPr>
|
||||
<w:r><w:t>New list item 1</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Bullet list (level 2) -->
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="ListParagraph"/>
|
||||
<w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr>
|
||||
<w:spacing w:before="240"/>
|
||||
<w:ind w:left="900"/>
|
||||
</w:pPr>
|
||||
<w:r><w:t>Bullet item</w:t></w:r>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
### Tables
|
||||
```xml
|
||||
<w:tbl>
|
||||
<w:tblPr>
|
||||
<w:tblStyle w:val="TableGrid"/>
|
||||
<w:tblW w:w="0" w:type="auto"/>
|
||||
</w:tblPr>
|
||||
<w:tblGrid>
|
||||
<w:gridCol w:w="4675"/><w:gridCol w:w="4675"/>
|
||||
</w:tblGrid>
|
||||
<w:tr>
|
||||
<w:tc>
|
||||
<w:tcPr><w:tcW w:w="4675" w:type="dxa"/></w:tcPr>
|
||||
<w:p><w:r><w:t>Cell 1</w:t></w:r></w:p>
|
||||
</w:tc>
|
||||
<w:tc>
|
||||
<w:tcPr><w:tcW w:w="4675" w:type="dxa"/></w:tcPr>
|
||||
<w:p><w:r><w:t>Cell 2</w:t></w:r></w:p>
|
||||
</w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
```
|
||||
|
||||
### Layout
|
||||
```xml
|
||||
<!-- Page break before new section (common pattern) -->
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:br w:type="page"/>
|
||||
</w:r>
|
||||
</w:p>
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:pStyle w:val="Heading1"/>
|
||||
</w:pPr>
|
||||
<w:r>
|
||||
<w:t>New Section Title</w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Centered paragraph -->
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:spacing w:before="240" w:after="0"/>
|
||||
<w:jc w:val="center"/>
|
||||
</w:pPr>
|
||||
<w:r><w:t>Centered text</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Font change - paragraph level (applies to all runs) -->
|
||||
<w:p>
|
||||
<w:pPr>
|
||||
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr>
|
||||
</w:pPr>
|
||||
<w:r><w:t>Monospace text</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Font change - run level (specific to this text) -->
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr>
|
||||
<w:t>This text is Courier New</w:t>
|
||||
</w:r>
|
||||
<w:r><w:t> and this text uses default font</w:t></w:r>
|
||||
</w:p>
|
||||
|
||||
<!-- Chinese/CJK-safe run fonts -->
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:rPr>
|
||||
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/>
|
||||
</w:rPr>
|
||||
<w:t>Mixed English and Chinese text</w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
## File Updates
|
||||
|
||||
When adding content, update these files:
|
||||
|
||||
**`word/_rels/document.xml.rels`:**
|
||||
```xml
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
|
||||
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
|
||||
```
|
||||
|
||||
**`[Content_Types].xml`:**
|
||||
```xml
|
||||
<Default Extension="png" ContentType="image/png"/>
|
||||
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
|
||||
```
|
||||
|
||||
### Images
|
||||
**CRITICAL**: Calculate dimensions to prevent page overflow and maintain aspect ratio.
|
||||
|
||||
```xml
|
||||
<!-- Minimal required structure -->
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:drawing>
|
||||
<wp:inline>
|
||||
<wp:extent cx="2743200" cy="1828800"/>
|
||||
<wp:docPr id="1" name="Picture 1"/>
|
||||
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
||||
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
||||
<pic:nvPicPr>
|
||||
<pic:cNvPr id="0" name="image1.png"/>
|
||||
<pic:cNvPicPr/>
|
||||
</pic:nvPicPr>
|
||||
<pic:blipFill>
|
||||
<a:blip r:embed="rId5"/>
|
||||
<!-- Add for stretch fill with aspect ratio preservation -->
|
||||
<a:stretch>
|
||||
<a:fillRect/>
|
||||
</a:stretch>
|
||||
</pic:blipFill>
|
||||
<pic:spPr>
|
||||
<a:xfrm>
|
||||
<a:ext cx="2743200" cy="1828800"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"/>
|
||||
</pic:spPr>
|
||||
</pic:pic>
|
||||
</a:graphicData>
|
||||
</a:graphic>
|
||||
</wp:inline>
|
||||
</w:drawing>
|
||||
</w:r>
|
||||
</w:p>
|
||||
```
|
||||
|
||||
### 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
|
||||
<!-- In document.xml -->
|
||||
<w:hyperlink r:id="rId5">
|
||||
<w:r>
|
||||
<w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
|
||||
<w:t>Link Text</w:t>
|
||||
</w:r>
|
||||
</w:hyperlink>
|
||||
|
||||
<!-- In word/_rels/document.xml.rels -->
|
||||
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
Target="https://www.example.com/" TargetMode="External"/>
|
||||
```
|
||||
|
||||
**Internal Links:**
|
||||
|
||||
```xml
|
||||
<!-- Link to bookmark -->
|
||||
<w:hyperlink w:anchor="myBookmark">
|
||||
<w:r>
|
||||
<w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
|
||||
<w:t>Link Text</w:t>
|
||||
</w:r>
|
||||
</w:hyperlink>
|
||||
|
||||
<!-- Bookmark target -->
|
||||
<w:bookmarkStart w:id="0" w:name="myBookmark"/>
|
||||
<w:r><w:t>Target content</w:t></w:r>
|
||||
<w:bookmarkEnd w:id="0"/>
|
||||
```
|
||||
|
||||
**Hyperlink Style (required in styles.xml):**
|
||||
```xml
|
||||
<w:style w:type="character" w:styleId="Hyperlink">
|
||||
<w:name w:val="Hyperlink"/>
|
||||
<w:basedOn w:val="DefaultParagraphFont"/>
|
||||
<w:uiPriority w:val="99"/>
|
||||
<w:unhideWhenUsed/>
|
||||
<w:rPr>
|
||||
<w:color w:val="467886" w:themeColor="hyperlink"/>
|
||||
<w:u w:val="single"/>
|
||||
</w:rPr>
|
||||
</w:style>
|
||||
```
|
||||
|
||||
## 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 `<w:del>`/`<w:ins>` 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 `<w:r>` element with its existing attributes to maintain document integrity.
|
||||
|
||||
**Method Selection Guide**:
|
||||
- **Adding your own changes to regular text**: Use `replace_node()` with `<w:del>`/`<w:ins>` tags, or `suggest_deletion()` for removing entire `<w:r>` or `<w:p>` elements
|
||||
- **Partially modifying another author's tracked change**: Use `replace_node()` to nest your changes inside their `<w:ins>`/`<w:del>`
|
||||
- **Completely rejecting another author's insertion**: Use `revert_insertion()` on the `<w:ins>` element (NOT `suggest_deletion()`)
|
||||
- **Completely rejecting another author's deletion**: Use `revert_deletion()` on the `<w:del>` element to restore deleted content using tracked changes
|
||||
|
||||
```python
|
||||
# Minimal edit - change one word: "The report is monthly" → "The report is quarterly"
|
||||
# Original: <w:r w:rsidR="00AB12CD"><w:rPr><w:rFonts w:ascii="Calibri"/></w:rPr><w:t>The report is monthly</w:t></w:r>
|
||||
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'<w:r w:rsidR="00AB12CD">{rpr}<w:t>The report is </w:t></w:r><w:del><w:r>{rpr}<w:delText>monthly</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>quarterly</w:t></w:r></w:ins>'
|
||||
doc["word/document.xml"].replace_node(node, replacement)
|
||||
|
||||
# Minimal edit - change number: "within 30 days" → "within 45 days"
|
||||
# Original: <w:r w:rsidR="00XYZ789"><w:rPr><w:rFonts w:ascii="Calibri"/></w:rPr><w:t>within 30 days</w:t></w:r>
|
||||
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'<w:r w:rsidR="00XYZ789">{rpr}<w:t>within </w:t></w:r><w:del><w:r>{rpr}<w:delText>30</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>45</w:t></w:r></w:ins><w:r w:rsidR="00XYZ789">{rpr}<w:t> days</w:t></w:r>'
|
||||
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'<w:del><w:r>{rpr}<w:delText>apple</w:delText></w:r></w:del><w:ins><w:r>{rpr}<w:t>banana orange</w:t></w:r></w:ins>'
|
||||
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, '<w:ins><w:r><w:t>new text</w:t></w:r></w:ins>')
|
||||
|
||||
# Partially delete another author's insertion
|
||||
# Original: <w:ins w:author="Jane Smith" w:date="..."><w:r><w:t>quarterly financial report</w:t></w:r></w:ins>
|
||||
# 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 <w:ins> to maintain authorship
|
||||
replacement = '''<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
|
||||
<w:r><w:t>quarterly </w:t></w:r>
|
||||
<w:del><w:r><w:delText>financial </w:delText></w:r></w:del>
|
||||
<w:r><w:t>report</w:t></w:r>
|
||||
</w:ins>'''
|
||||
doc["word/document.xml"].replace_node(node, replacement)
|
||||
|
||||
# Change part of another author's insertion
|
||||
# Original: <w:ins w:author="Jane Smith"><w:r><w:t>in silence, safe and sound</w:t></w:r></w:ins>
|
||||
# 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'''<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
|
||||
<w:r><w:t>in silence, </w:t></w:r>
|
||||
</w:ins>
|
||||
<w:ins>
|
||||
<w:r><w:t>soft and unbound</w:t></w:r>
|
||||
</w:ins>
|
||||
<w:ins w:author="Jane Smith" w:date="2025-01-15T10:00:00Z">
|
||||
<w:del><w:r><w:delText>safe and sound</w:delText></w:r></w:del>
|
||||
</w:ins>'''
|
||||
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'<w:p>{pPr}<w:r><w:t>New item</w:t></w:r></w:p>'
|
||||
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('<w:p><w:pPr><w:pStyle w:val="ListParagraph"/></w:pPr></w:p>')
|
||||
# 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,
|
||||
'<w:del><w:r><w:delText>old</w:delText></w:r></w:del><w:ins><w:r><w:t>new</w:t></w:r></w:ins>'
|
||||
)
|
||||
# Then add comment on the newly created elements
|
||||
# new_nodes[0] is the <w:del>, new_nodes[1] is the <w:ins>
|
||||
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'<Relationship Id="{next_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>')
|
||||
doc['[Content_Types].xml'].append_to(doc['[Content_Types].xml'].dom.documentElement,
|
||||
'<Default Extension="png" ContentType="image/png"/>')
|
||||
|
||||
# Insert image
|
||||
node = doc["word/document.xml"].get_node(tag="w:p", line_number=100)
|
||||
doc["word/document.xml"].insert_after(node, f'''<w:p>
|
||||
<w:r>
|
||||
<w:drawing>
|
||||
<wp:inline distT="0" distB="0" distL="0" distR="0">
|
||||
<wp:extent cx="{width_emus}" cy="{height_emus}"/>
|
||||
<wp:docPr id="1" name="Picture 1"/>
|
||||
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
||||
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
||||
<pic:nvPicPr><pic:cNvPr id="1" name="image1.png"/><pic:cNvPicPr/></pic:nvPicPr>
|
||||
<pic:blipFill><a:blip r:embed="{next_rid}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
|
||||
<pic:spPr><a:xfrm><a:ext cx="{width_emus}" cy="{height_emus}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
|
||||
</pic:pic>
|
||||
</a:graphicData>
|
||||
</a:graphic>
|
||||
</wp:inline>
|
||||
</w:drawing>
|
||||
</w:r>
|
||||
</w:p>''')
|
||||
```
|
||||
|
||||
### 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, "<w:p><w:r><w:t>replacement text</w:t></w:r></w:p>")
|
||||
|
||||
# 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, "<w:r><w:t>A</w:t></w:r>")
|
||||
nodes = doc["word/document.xml"].insert_after(nodes[-1], "<w:r><w:t>B</w:t></w:r>")
|
||||
nodes = doc["word/document.xml"].insert_after(nodes[-1], "<w:r><w:t>C</w:t></w:r>")
|
||||
# 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 `<w:ins>` or `<w:del>` tags**
|
||||
- **ALWAYS use nested deletions** to remove another author's insertions
|
||||
- **Every edit must be properly tracked** with `<w:ins>` or `<w:del>` 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 `<w:del>` and `<w:ins>` at paragraph level containing complete `<w:r>` elements. Never nest inside `<w:r>` elements - this creates invalid XML that breaks document processing.
|
||||
|
||||
**Text Insertion:**
|
||||
```xml
|
||||
<w:ins w:id="1" w:author="Claude" w:date="2025-07-30T23:05:00Z" w16du:dateUtc="2025-07-31T06:05:00Z">
|
||||
<w:r w:rsidR="00792858">
|
||||
<w:t>inserted text</w:t>
|
||||
</w:r>
|
||||
</w:ins>
|
||||
```
|
||||
|
||||
**Text Deletion:**
|
||||
```xml
|
||||
<w:del w:id="2" w:author="Claude" w:date="2025-07-30T23:05:00Z" w16du:dateUtc="2025-07-31T06:05:00Z">
|
||||
<w:r w:rsidDel="00792858">
|
||||
<w:delText>deleted text</w:delText>
|
||||
</w:r>
|
||||
</w:del>
|
||||
```
|
||||
|
||||
**Deleting Another Author's Insertion (MUST use nested structure):**
|
||||
```xml
|
||||
<!-- Nest deletion inside the original insertion -->
|
||||
<w:ins w:author="Jane Smith" w:id="16">
|
||||
<w:del w:author="Claude" w:id="40">
|
||||
<w:r><w:delText>monthly</w:delText></w:r>
|
||||
</w:del>
|
||||
</w:ins>
|
||||
<w:ins w:author="Claude" w:id="41">
|
||||
<w:r><w:t>weekly</w:t></w:r>
|
||||
</w:ins>
|
||||
```
|
||||
|
||||
**Restoring Another Author's Deletion:**
|
||||
```xml
|
||||
<!-- Leave their deletion unchanged, add new insertion after it -->
|
||||
<w:del w:author="Jane Smith" w:id="50">
|
||||
<w:r><w:delText>within 30 days</w:delText></w:r>
|
||||
</w:del>
|
||||
<w:ins w:author="Claude" w:id="51">
|
||||
<w:r><w:t>within 30 days</w:t></w:r>
|
||||
</w:ins>
|
||||
```
|
||||
@@ -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 <input_directory> <office_file> [--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()
|
||||
@@ -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())
|
||||
@@ -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 <dir> --original <original_file>
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -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}: <w:t> found within <w:del>: {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}: <w:delText> within <w:ins>: {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.")
|
||||
@@ -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 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。
|
||||
Regular → Executable
+1
-1
@@ -1 +1 @@
|
||||
# Make scripts directory a package for relative imports in tests
|
||||
|
||||
@@ -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 = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
|
||||
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">
|
||||
Sub AcceptAllTrackedChanges()
|
||||
Dim 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
|
||||
</script:module>'''
|
||||
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)
|
||||
+368
@@ -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:
|
||||
<w:commentRangeStart w:id="N"/>
|
||||
... commented content ...
|
||||
<w:commentRangeEnd w:id="N"/>
|
||||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="N"/></w:r>
|
||||
"""
|
||||
|
||||
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 = """\
|
||||
<w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
|
||||
<w:p w14:paraId="{para_id}" w14:textId="77777777">
|
||||
<w:r>
|
||||
<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
|
||||
<w:annotationRef/>
|
||||
</w:r>
|
||||
<w:r>
|
||||
<w:rPr>
|
||||
<w:color w:val="000000"/>
|
||||
<w:sz w:val="20"/>
|
||||
<w:szCs w:val="20"/>
|
||||
</w:rPr>
|
||||
<w:t xml:space="preserve">{text}</w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
</w:comment>"""
|
||||
|
||||
COMMENT_MARKER_TEMPLATE = """
|
||||
Add to word/document.xml (markers must be direct children of w:p, never inside w:r):
|
||||
<w:commentRangeStart w:id="{cid}"/>
|
||||
<w:r>...</w:r>
|
||||
<w:commentRangeEnd w:id="{cid}"/>
|
||||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
|
||||
|
||||
REPLY_MARKER_TEMPLATE = """
|
||||
Nest markers inside parent {pid}'s markers (direct children of w:p, never inside w:r):
|
||||
<w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
|
||||
<w:r>...</w:r>
|
||||
<w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
|
||||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></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"<root {ns_attrs}>{content}</root>")
|
||||
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'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',
|
||||
)
|
||||
else:
|
||||
_append_xml(
|
||||
ext, "w15:commentsEx",
|
||||
f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
|
||||
)
|
||||
|
||||
ids = word / "commentsIds.xml"
|
||||
if not ids.exists():
|
||||
shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
|
||||
_append_xml(
|
||||
ids, "w16cid:commentsIds",
|
||||
f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
|
||||
)
|
||||
|
||||
extensible = word / "commentsExtensible.xml"
|
||||
if not extensible.exists():
|
||||
shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
|
||||
_append_xml(
|
||||
extensible, "w16cex:commentsExtensible",
|
||||
f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',
|
||||
)
|
||||
|
||||
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()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,310 @@
|
||||
"""Merge adjacent identically-formatted runs in a DOCX.
|
||||
|
||||
Word fragments paragraph text across many <w:r> 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 (<w:rPr>) is
|
||||
identical, strips rsid attributes and proofErr markers, and consolidates the
|
||||
text elements — <w:t>, and <w:delText> 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 `<w:t>Hello </w:t>`
|
||||
followed by `<w:t>world</w:t>` reads "Helloworld" and merges to exactly that.
|
||||
|
||||
Runs in two different <w:ins>/<w:del> 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()
|
||||
@@ -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()
|
||||
@@ -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"""<c:grouping\b[^>]*?\bval=["'](\w+)["']""")
|
||||
_DLBL_POS_RE = re.compile(r"""<c:dLblPos\b[^>]*?\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"<c:(bar3DChart|barChart)\b[^>]*(?<!/)>.*?</c:\1\s*>", 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"<c:(\w+Chart)\b[^>]*(?<!/)>.*?</c:\1\s*>", re.DOTALL)
|
||||
|
||||
_AXID_RE = re.compile(
|
||||
r"""\s*<c:axId\b[^>]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*</c:axId\s*>)"""
|
||||
)
|
||||
|
||||
_AXIS_DECL_RE = re.compile(
|
||||
r"""<c:(catAx|valAx|serAx|dateAx)\b[^>]*(?<!/)>\s*<c:axId\b[^>]*?\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}: <c:{kind}> declares no <c:axId> 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}: <c:{kind}> 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
|
||||
@@ -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 <a:tableStyleId> in one <a:tblPr> (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 <p:txBody> with no children",
|
||||
),
|
||||
(
|
||||
re.compile(r"\}miter', attribute 'lim'"),
|
||||
'a line join with lim="NaN"',
|
||||
),
|
||||
(
|
||||
re.compile(r"\}uLnTx': This element is not expected"),
|
||||
"<a:uLnTx> in a position the schema forbids",
|
||||
),
|
||||
(
|
||||
re.compile(r"\}overrideClrMapping': This element is not expected"),
|
||||
"<p:overrideClrMapping> in a position the schema forbids",
|
||||
),
|
||||
(
|
||||
re.compile(r"\}nvGrpSpPr': Missing child element"),
|
||||
"a <p:nvGrpSpPr> 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
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Find masters sharing a theme part in the way PowerPoint refuses to open.
|
||||
|
||||
Reports only; the fix is to move <p:notesMasterIdLst> back to directly after
|
||||
<p:sldIdLst> 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/(?P<group>slideMasters|notesMasters|handoutMasters)/"
|
||||
r"(?:slide|notes|handout)Master(?P<num>\d+)\.xml$"
|
||||
)
|
||||
_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2}
|
||||
|
||||
_RELATIONSHIP_RE = re.compile(
|
||||
r"<Relationship\b[^>]*?(?:/>|>.*?</Relationship\s*>)", 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"<p:sldIdLst\b(?:[^>]*/>|[^>]*>.*?</p:sldIdLst\s*>)\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) == "<p:notesMasterIdLst"
|
||||
|
||||
|
||||
def _shares(files: Mapping[str, bytes]):
|
||||
owner: dict[str, str] = {}
|
||||
for master in _masters(files):
|
||||
found = _theme_rel(files, master)
|
||||
if found is None:
|
||||
continue
|
||||
rels_path, element, theme = found
|
||||
if theme not in files:
|
||||
continue
|
||||
if theme in owner:
|
||||
yield master, rels_path, element, theme, owner[theme]
|
||||
else:
|
||||
owner[theme] = master
|
||||
|
||||
|
||||
def _is_inert(master: str, inert_notes: bool) -> 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)
|
||||
]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Command line tool to validate Office document XML files against XSD schemas and tracked changes.
|
||||
|
||||
Usage:
|
||||
python validate.py <path> [--original <original_file>] [--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 "
|
||||
"<w:ins>/<w:del> 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()
|
||||
File renamed without changes.
+143
-219
@@ -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)
|
||||
@@ -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}: <w:t> found within <w:del>: {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}: <w:instrText> found within <w:del> (use <w:delInstrText>): {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}: <w:delText> within <w:ins>: {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.")
|
||||
+175
-49
@@ -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 <p:notesMasterIdLst> back to "
|
||||
"directly after <p:sldIdLst>. 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]
|
||||
+101
-81
@@ -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 <w:ins>/<w:del> 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 <w:ins> or <w:del> tags",
|
||||
" 2. Made edits without proper tracked changes",
|
||||
" 3. Didn't nest <w:del> inside <w:ins> when deleting another's insertion",
|
||||
" 4. Rewrote another author's <w:ins>/<w:del> 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 <w:del> inside their <w:ins>",
|
||||
" - To reject PART of one: nest <w:del> around only the runs you reject.",
|
||||
" Their <w:ins> 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 <w:ins> AFTER their <w:del>",
|
||||
"",
|
||||
]
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,3 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?xml version="1.0" ?>
|
||||
<w:comments xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
|
||||
</w:comments>
|
||||
</w:comments>
|
||||
@@ -1,3 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?xml version="1.0" ?>
|
||||
<w15:commentsEx xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
|
||||
</w15:commentsEx>
|
||||
</w15:commentsEx>
|
||||
@@ -1,3 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?xml version="1.0" ?>
|
||||
<w16cex:commentsExtensible xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:cr="http://schemas.microsoft.com/office/comments/2020/reactions" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl cr w16du wp14">
|
||||
</w16cex:commentsExtensible>
|
||||
</w16cex:commentsExtensible>
|
||||
@@ -1,3 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?xml version="1.0" ?>
|
||||
<w16cid:commentsIds xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:oel="http://schemas.microsoft.com/office/2019/extlst" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid" xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml" xmlns:w16du="http://schemas.microsoft.com/office/word/2023/wordml/word16du" xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" xmlns:w16sdtfl="http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14">
|
||||
</w16cid:commentsIds>
|
||||
</w16cid:commentsIds>
|
||||
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user