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
5322b1447d
commit
6e0dfc4b44
36 files changed
+2538
-103
No files matched your search
@@ -24,8 +24,8 @@
|
|||||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "codex-plugin",
|
"adapter": "codex-plugin",
|
||||||
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
|
"commit": "f5d729488caa8f6a5b6c8086fe2cccd3e8a63f91",
|
||||||
"syncedAt": "2026-09-22T16:08:55Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "taste-skill",
|
"id": "taste-skill",
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "db2db460a26fa84fb65c8d903b213925fbdee9ed",
|
"commit": "d75a96ab781f3d659be1ad287347d5887ce9f2fc",
|
||||||
"syncedAt": "2026-09-28T16:00:01Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "frontend-slides",
|
"id": "frontend-slides",
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "680de11f1bef4628b68d5daad9dffec569fbd51f",
|
"commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
|
||||||
"syncedAt": "2026-09-27T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grill-me",
|
"id": "grill-me",
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
"id": "artemis-automation",
|
"id": "artemis-automation",
|
||||||
"pluginName": "artemis-automation",
|
"pluginName": "artemis-automation",
|
||||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||||
"ref": "v1.2.3",
|
"ref": "v1.3.0",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "59f458205d7f5ec041a9dc11acc1122f128e827b",
|
"commit": "606ed941607c9e5482a4acd3f782d2b14acc9d06",
|
||||||
"syncedAt": "2026-09-29T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "embedded-app-pc-debug",
|
"id": "embedded-app-pc-debug",
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "bc8bf60a350071dc192c454674eab3574db8b50c",
|
"commit": "60cb5b3efc1d65767d8f746dc12003fbd4f4bbcd",
|
||||||
"syncedAt": "2026-09-29T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
@@ -177,8 +177,8 @@
|
|||||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "9e6400d5e2cc050485c38fbf0dda682bce66c180",
|
"commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c",
|
||||||
"syncedAt": "2026-09-27T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-automation",
|
"name": "artemis-automation",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "chenmingxuan",
|
"name": "chenmingxuan",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"sourceId": "artemis-automation",
|
"sourceId": "artemis-automation",
|
||||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||||
"ref": "v1.2.3",
|
"ref": "v1.3.0",
|
||||||
"commit": "59f458205d7f5ec041a9dc11acc1122f128e827b",
|
"commit": "606ed941607c9e5482a4acd3f782d2b14acc9d06",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/artemis-automation",
|
"sourcePath": "skills/artemis-automation",
|
||||||
"syncedAt": "2026-09-29T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -27,10 +27,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 配置和恢复边界
|
## 配置和恢复边界
|
||||||
|
|
||||||
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
||||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
- 429 等待和兜底参数可在网页模型配置或经用户明确授权的 `mobile_config(section="model", persist_defaults=true)` 保存;当前任务不热切换。录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||||
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -60,6 +60,6 @@
|
|||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总、各项报告和有序步骤截图;任务保存了录屏时,还包含对应录屏片段。报告格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||||
|
|
||||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "caveman",
|
"name": "caveman",
|
||||||
"version": "2.7.0",
|
"version": "3.0.0",
|
||||||
"description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
|
"description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Julius Brussee",
|
"name": "Julius Brussee",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/JuliusBrussee/caveman",
|
"homepage": "https://github.com/JuliusBrussee/caveman",
|
||||||
"repository": "https://github.com/JuliusBrussee/caveman",
|
"repository": "https://github.com/JuliusBrussee/caveman",
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"productivity",
|
"productivity",
|
||||||
"communication",
|
"communication",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "caveman",
|
"sourceId": "caveman",
|
||||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
|
"commit": "f5d729488caa8f6a5b6c8086fe2cccd3e8a63f91",
|
||||||
"adapter": "codex-plugin",
|
"adapter": "codex-plugin",
|
||||||
"sourcePath": "plugins/caveman",
|
"sourcePath": "plugins/caveman",
|
||||||
"syncedAt": "2026-09-22T16:08:55Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-09-29T16:02:00Z"
|
"syncedAt": "2026-10-01T16:02:08Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "bc8bf60a350071dc192c454674eab3574db8b50c",
|
"commit": "60cb5b3efc1d65767d8f746dc12003fbd4f4bbcd",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-09-29T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ppt-master",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "680de11f1bef4628b68d5daad9dffec569fbd51f",
|
"commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-09-27T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ Global artifact ownership rules for PPT Master projects. A selected route or pro
|
|||||||
| SVG source and closure | `svg_output/` is the only author source; every visible exported object exists in its page SVG or an explicitly referenced asset. The only supported export route reads `svg_output/` through the project converter; diagnostic `-s final` creates no release route; PowerPoint's Convert-to-Shape on `svg_final/` is outside the contract |
|
| SVG source and closure | `svg_output/` is the only author source; every visible exported object exists in its page SVG or an explicitly referenced asset. The only supported export route reads `svg_output/` through the project converter; diagnostic `-s final` creates no release route; PowerPoint's Convert-to-Shape on `svg_final/` is outside the contract |
|
||||||
| Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts, never forced into SVG metadata |
|
| Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts, never forced into SVG metadata |
|
||||||
| Workflow audit log | Records command envelopes, error/failure and receipt/report lines, bounded samples, summary context, omission counts, and selected manual entries; it does not capture direct file authoring, host-native tools, pre-project conversion, binary writes, hidden child output, or detached services — absence of a line proves nothing. Automatic recording failure is advisory; a failed explicit manual append reports failure |
|
| Workflow audit log | Records command envelopes, error/failure and receipt/report lines, bounded samples, summary context, omission counts, and selected manual entries; it does not capture direct file authoring, host-native tools, pre-project conversion, binary writes, hidden child output, or detached services — absence of a line proves nothing. Automatic recording failure is advisory; a failed explicit manual append reports failure |
|
||||||
| Confirmation and template selection | Final UI/chat confirmation overrides recommendations and is consumed once; enabled refinement requires approval before lock authoring. Step 3 prepares candidates without reads; `template_options.default_mode` is `free_design` for ordinary requests and `templates` for explicit intent or any exact root; exactly one root may be preselected; template mode requires one selected candidate; every applied selection becomes project-local `templates/` state before Stage 2. Quick applies at most one exact root per kind directly |
|
| Confirmation and template selection | Final UI/chat confirmation overrides recommendations and is consumed once; enabled refinement requires approval before lock authoring. Step 3 prepares candidates, reading only an exact root's kind and canvas frontmatter; `template_options.default_mode` is `free_design` for ordinary requests and `templates` for explicit intent or any exact root; exactly one root may be preselected; template mode requires one selected candidate; every applied selection becomes project-local `templates/` state before Stage 2. Quick applies at most one exact root per kind directly |
|
||||||
| Proactive production outcomes | Resolve notes/animation/narration as explicit instruction → final Stage 2 → defaults; persist outcomes/provenance only in Design Spec §I. Explicit notes-off/audio-on applies Generate's dependency gate; a Stage-2 animation `false` does not suppress an existing sidecar |
|
| Proactive production outcomes | Resolve notes/animation/narration as explicit instruction → final Stage 2 → defaults; persist outcomes/provenance only in Design Spec §I. Explicit notes-off/audio-on applies Generate's dependency gate; a Stage-2 animation `false` does not suppress an existing sidecar |
|
||||||
|
|
||||||
**Forbidden — mixed ownership**: copying chart values from Markdown into `analysis/` by hand, editing `svg_final/` as the source of a fix, editing imported lossless SVGs instead of their authoring IR, treating `design_spec.md` prose as a replacement for `spec_lock.md`.
|
**Forbidden — mixed ownership**: copying chart values from Markdown into `analysis/` by hand, editing `svg_final/` as the source of a fix, editing imported lossless SVGs instead of their authoring IR, treating `design_spec.md` prose as a replacement for `spec_lock.md`.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Interpret the instruction semantically — "confirm here", "use the chat window"
|
|||||||
|
|
||||||
## 2. `recommendations.stage1.json`
|
## 2. `recommendations.stage1.json`
|
||||||
|
|
||||||
Author before reading any candidate index, spec, prototype, asset, or template canvas. All seven prose values may be blank; `primary_language` is canonical BCP-47 (`und` and Chinese without script/region are rejected); `lang` is the UI language only. The intent paths (inform / explain / persuade / decide / align / teach / report and account / mobilize / record and hand off) are help text, never a `primary_job` field.
|
Author before reading any candidate index, spec, prototype, or asset; template facts already in context inform only the canvas and page range, per [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4. All seven prose values may be blank; `primary_language` is canonical BCP-47 (`und` and Chinese without script/region are rejected); `lang` is the UI language only. The intent paths (inform / explain / persuade / decide / align / teach / report and account / mobilize / record and hand off) are help text, never a `primary_job` field.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Missing material returns upstream, never invented or substituted.
|
|||||||
|
|
||||||
### a. Canvas Format Confirmation
|
### a. Canvas Format Confirmation
|
||||||
|
|
||||||
Recommend from the scenario and project initialization ([`canvas-formats.md`](canvas-formats.md)). A template canvas is not Stage-1 evidence; Stage 2 later checks whether selected structure serves the confirmed canvas.
|
Recommend from the scenario and project initialization ([`canvas-formats.md`](canvas-formats.md)); a template canvas informs it only as [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4 allows. Stage 2 later checks whether selected structure serves the confirmed canvas.
|
||||||
|
|
||||||
### b. Page Count Confirmation
|
### b. Page Count Confirmation
|
||||||
|
|
||||||
|
|||||||
+23
-11
@@ -791,6 +791,15 @@
|
|||||||
return dict[key] != null ? dict[key] : key;
|
return dict[key] != null ? dict[key] : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zh / zh-TW / ja text takes a full-width colon and no space between sentences.
|
||||||
|
function isCjkUi() {
|
||||||
|
return LANG === "zh" || LANG === "zh-TW" || LANG === "ja";
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelSep() {
|
||||||
|
return isCjkUi() ? ":" : ": ";
|
||||||
|
}
|
||||||
|
|
||||||
// Preserve the existing locale order, then accept zh_tw-only candidate
|
// Preserve the existing locale order, then accept zh_tw-only candidate
|
||||||
// prose from any persisted UI language so browser and server validation agree.
|
// prose from any persisted UI language so browser and server validation agree.
|
||||||
// Entries are FIELD SUFFIXES, not BCP-47 tags: "zh-TW" data lives in
|
// Entries are FIELD SUFFIXES, not BCP-47 tags: "zh-TW" data lives in
|
||||||
@@ -1534,7 +1543,7 @@
|
|||||||
label = label + (o.dim ? " · " + o.dim : "");
|
label = label + (o.dim ? " · " + o.dim : "");
|
||||||
} else {
|
} else {
|
||||||
if (o.dim) label += " · " + o.dim;
|
if (o.dim) label += " · " + o.dim;
|
||||||
if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc;
|
if (desc) label += (isCjkUi() ? ":" : " — ") + desc;
|
||||||
if (spec && spec.note) label += " · " + spec.note;
|
if (spec && spec.note) label += " · " + spec.note;
|
||||||
}
|
}
|
||||||
copy.appendChild(el("span", "chip-text", label));
|
copy.appendChild(el("span", "chip-text", label));
|
||||||
@@ -3303,15 +3312,16 @@
|
|||||||
// Everything is px — lo/hi are only a sanity envelope for the OOR flag.
|
// Everything is px — lo/hi are only a sanity envelope for the OOR flag.
|
||||||
refreshBodySizeHint = function () {
|
refreshBodySizeHint = function () {
|
||||||
var txt = t("font_body_size_hint");
|
var txt = t("font_body_size_hint");
|
||||||
|
var sep = isCjkUi() ? "" : " ";
|
||||||
var lo, hi;
|
var lo, hi;
|
||||||
if (isPptCanvas(STATE.canvas)) {
|
if (isPptCanvas(STATE.canvas)) {
|
||||||
var pb = deliveryBodyPx(STATE.delivery_purpose);
|
var pb = deliveryBodyPx(STATE.delivery_purpose);
|
||||||
lo = pb.lo; hi = pb.hi;
|
lo = pb.lo; hi = pb.hi;
|
||||||
txt += " " + t("body_size_hint_purpose").replace("{def}", pb.def);
|
txt += sep + t("body_size_hint_purpose").replace("{def}", pb.def);
|
||||||
} else {
|
} else {
|
||||||
var band = bodySizeBandForCanvas(STATE.canvas, STATE.delivery_purpose);
|
var band = bodySizeBandForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||||||
lo = band.lo; hi = band.hi;
|
lo = band.lo; hi = band.hi;
|
||||||
txt += " " + t("body_size_hint_canvas")
|
txt += sep + t("body_size_hint_canvas")
|
||||||
.replace("{lo}", lo).replace("{hi}", hi);
|
.replace("{lo}", lo).replace("{hi}", hi);
|
||||||
}
|
}
|
||||||
// Flag (hint only) a value far outside the
|
// Flag (hint only) a value far outside the
|
||||||
@@ -3322,7 +3332,7 @@
|
|||||||
? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur))
|
? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur))
|
||||||
: "";
|
: "";
|
||||||
if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
|
if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
|
||||||
txt += " " + t("body_size_hint_oor");
|
txt += sep + t("body_size_hint_oor");
|
||||||
}
|
}
|
||||||
sizeHint.textContent = txt;
|
sizeHint.textContent = txt;
|
||||||
};
|
};
|
||||||
@@ -3552,11 +3562,11 @@
|
|||||||
t("image_strategy_select_placeholder")));
|
t("image_strategy_select_placeholder")));
|
||||||
var parts = [];
|
var parts = [];
|
||||||
if (strategy.rendering) {
|
if (strategy.rendering) {
|
||||||
parts.push(t("image_strategy_rendering") + ": " +
|
parts.push(t("image_strategy_rendering") + labelSep() +
|
||||||
comparisonValueLabel("rendering", strategy.rendering));
|
comparisonValueLabel("rendering", strategy.rendering));
|
||||||
}
|
}
|
||||||
if (strategy.visual) parts.push(t("image_strategy_visual") + ": " + strategy.visual);
|
if (strategy.visual) parts.push(t("image_strategy_visual") + labelSep() + strategy.visual);
|
||||||
if (strategy.mood) parts.push(t("image_strategy_mood") + ": " + strategy.mood);
|
if (strategy.mood) parts.push(t("image_strategy_mood") + labelSep() + strategy.mood);
|
||||||
if (strategy.behavior) parts.push(strategy.behavior);
|
if (strategy.behavior) parts.push(strategy.behavior);
|
||||||
desc.textContent = parts.join(" · ") || t("image_strategy_reference_hint");
|
desc.textContent = parts.join(" · ") || t("image_strategy_reference_hint");
|
||||||
}
|
}
|
||||||
@@ -3714,7 +3724,7 @@
|
|||||||
["image_strategy_visual", customStrategy.visual],
|
["image_strategy_visual", customStrategy.visual],
|
||||||
["image_strategy_mood", customStrategy.mood]
|
["image_strategy_mood", customStrategy.mood]
|
||||||
].forEach(function (row) {
|
].forEach(function (row) {
|
||||||
if (row[1]) customCard.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1]));
|
if (row[1]) customCard.appendChild(el("div", "color-note", t(row[0]) + labelSep() + row[1]));
|
||||||
});
|
});
|
||||||
var customCopy = el("div", "ai-custom-candidate-copy",
|
var customCopy = el("div", "ai-custom-candidate-copy",
|
||||||
customStrategy.behavior || t("image_strategy_custom_placeholder"));
|
customStrategy.behavior || t("image_strategy_custom_placeholder"));
|
||||||
@@ -3812,7 +3822,7 @@
|
|||||||
(CAT.image_usage || []).forEach(function (option) {
|
(CAT.image_usage || []).forEach(function (option) {
|
||||||
var label = optionLabel(option);
|
var label = optionLabel(option);
|
||||||
var desc = optionDesc(option);
|
var desc = optionDesc(option);
|
||||||
if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc;
|
if (desc) label += (isCjkUi() ? ":" : " — ") + desc;
|
||||||
var chip = el("div", "chip");
|
var chip = el("div", "chip");
|
||||||
chip.appendChild(el("span", "chip-text", label));
|
chip.appendChild(el("span", "chip-text", label));
|
||||||
if (recommendedIds.indexOf(option.id) >= 0) {
|
if (recommendedIds.indexOf(option.id) >= 0) {
|
||||||
@@ -4407,6 +4417,7 @@
|
|||||||
document.getElementById("sections").style.display = "none";
|
document.getElementById("sections").style.display = "none";
|
||||||
document.getElementById("actionbar").style.display = "none";
|
document.getElementById("actionbar").style.display = "none";
|
||||||
var l = document.getElementById("loading");
|
var l = document.getElementById("loading");
|
||||||
|
l.setAttribute("data-i18n", "deriving");
|
||||||
l.textContent = t("deriving");
|
l.textContent = t("deriving");
|
||||||
l.style.display = "block";
|
l.style.display = "block";
|
||||||
}
|
}
|
||||||
@@ -4536,7 +4547,7 @@
|
|||||||
if (requested !== "zh" && requested !== "en" && requested !== "ja" &&
|
if (requested !== "zh" && requested !== "en" && requested !== "ja" &&
|
||||||
requested !== "zh-TW") return;
|
requested !== "zh-TW") return;
|
||||||
var hasStored = false;
|
var hasStored = false;
|
||||||
try { hasStored = !!window.localStorage.getItem("ppt_lang"); } catch (e) { /* ignore */ }
|
try { hasStored = ["zh", "en", "ja", "zh-TW"].indexOf(window.localStorage.getItem("ppt_lang")) >= 0; } catch (e) { /* ignore */ }
|
||||||
if (hasStored) return;
|
if (hasStored) return;
|
||||||
LANG = requested;
|
LANG = requested;
|
||||||
applyStaticTranslations();
|
applyStaticTranslations();
|
||||||
@@ -4587,11 +4598,12 @@
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
toggleBtn.focus();
|
toggleBtn.focus();
|
||||||
if (v !== "ja" && v !== "en" && v !== "zh" && v !== "zh-TW") return;
|
if (v !== "ja" && v !== "en" && v !== "zh" && v !== "zh-TW") return;
|
||||||
|
try { window.localStorage.setItem("ppt_lang", v); } catch (e2) { /* ignore */ }
|
||||||
if (v === LANG) return;
|
if (v === LANG) return;
|
||||||
LANG = v;
|
LANG = v;
|
||||||
try { window.localStorage.setItem("ppt_lang", LANG); } catch (e2) { /* ignore */ }
|
|
||||||
applyStaticTranslations();
|
applyStaticTranslations();
|
||||||
refreshLangToggle(toggleBtn);
|
refreshLangToggle(toggleBtn);
|
||||||
|
document.getElementById("confirm-status").textContent = "";
|
||||||
if (REC && CAT) {
|
if (REC && CAT) {
|
||||||
renderAll(); // STATE persists → selections preserved
|
renderAll(); // STATE persists → selections preserved
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,8 +312,10 @@ binding. Completed results stay closed until a new round starts.
|
|||||||
| `recommendations.stage1.json` (embedded `template_options`) | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs and the server verifies the selected roots |
|
| `recommendations.stage1.json` (embedded `template_options`) | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs and the server verifies the selected roots |
|
||||||
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, proactive notes/custom-animation/narration-audio toggles, generation mode, Design Spec review toggle, and Design Spec depth | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, proactive notes/custom-animation/narration-audio toggles, generation mode, Design Spec review toggle, and Design Spec depth | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||||
|
|
||||||
In the UI branch, the AI authors Stage 1 without reading template candidates,
|
In the UI branch, the AI authors Stage 1 without reading candidate indexes or
|
||||||
then launches the combined page. In chat/delegated confirmation it authors the
|
workspaces — template facts already in context inform only the canvas and page
|
||||||
|
range ([`generate-pptx.md`](../../workflows/generate-pptx.md) Step 4) — then
|
||||||
|
launches the combined page. In chat/delegated confirmation it authors the
|
||||||
same communication recommendation before listing template candidates. After
|
same communication recommendation before listing template candidates. After
|
||||||
the one Stage-1 confirmation, the AI installs any selection, completes the
|
the one Stage-1 confirmation, the AI installs any selection, completes the
|
||||||
handoff/equivalent state, then inspects only the project-local template and
|
handoff/equivalent state, then inspects only the project-local template and
|
||||||
|
|||||||
@@ -1067,6 +1067,39 @@ Requirements:
|
|||||||
|
|
||||||
## Measuring, wrapping, and calibrating text before authoring
|
## Measuring, wrapping, and calibrating text before authoring
|
||||||
|
|
||||||
|
Ordinary generated text frames preserve the SVG's first-line baseline using
|
||||||
|
`svg_to_pptx/drawingml/text_baseline.py` and the bundled `font_metrics.json`.
|
||||||
|
The metric table records numeric OpenType fields and source-font versions;
|
||||||
|
export never reads installed fonts. The selected style uses `typo` metrics
|
||||||
|
when `USE_TYPO_METRICS` is set, otherwise `win` metrics. Missing italic styles
|
||||||
|
keep the matching bold face when present, then fall back to regular metrics;
|
||||||
|
missing faces use an ascent ratio of 0.8. Font aliases resolve through the
|
||||||
|
same mapping as emitted text runs.
|
||||||
|
|
||||||
|
The first visual line combines the largest size-adjusted ascent and descent
|
||||||
|
of its active Latin/CJK faces. Default spacing uses a 1.2-times-font-size
|
||||||
|
line height; explicit `spcPts` spacing has a separate first-baseline model,
|
||||||
|
including its transition at the natural line height. The renderer rounds
|
||||||
|
explicit spacing and the final offset to whole points; the authored `spcPts`
|
||||||
|
value stays intact. Preserved line breaks carry the preceding run's font
|
||||||
|
properties so presentation defaults do not change line metrics. This behavior
|
||||||
|
was measured in PowerPoint for Mac 16.113.2; other renderers and font versions
|
||||||
|
can differ. SVG glyph bounds and checker measurements are not PPT frame offsets.
|
||||||
|
|
||||||
|
Ordinary horizontal PPTX text imports use the same baseline model with font
|
||||||
|
roles resolved from the source DrawingML, including theme fonts, and carry
|
||||||
|
`data-pptx-text-baseline="metrics-v1"` on the SVG text element.
|
||||||
|
Fixed line advances remain absolute; percentage spacing scales the natural
|
||||||
|
line height, with 100% equivalent to the default spacing. Baseline coordinates
|
||||||
|
retain enough precision to avoid accumulating inset drift on repeated edits.
|
||||||
|
The marker survives line splitting and semantic-shape projection. When marked text is
|
||||||
|
rebuilt inside an exact `data-pptx-frame`, export subtracts the shared baseline
|
||||||
|
offset from the SVG y coordinate before reconstructing the top inset. Unmarked
|
||||||
|
exact-frame text retains the earlier `0.85 * font-size` convention, so existing
|
||||||
|
round-trip workspaces do not require migration. Unchanged source text bodies
|
||||||
|
still restore their original XML. Native math, baseline-shifted runs, and
|
||||||
|
vertical text retain their separate positioning models.
|
||||||
|
|
||||||
`text_measure.py` imports the same single-line DrawingML width estimator used by
|
`text_measure.py` imports the same single-line DrawingML width estimator used by
|
||||||
the SVG quality checker.
|
the SVG quality checker.
|
||||||
Use Arial, Times New Roman, Georgia, Verdana, Calibri, Cambria, Trebuchet MS, or
|
Use Arial, Times New Roman, Georgia, Verdana, Calibri, Cambria, Trebuchet MS, or
|
||||||
|
|||||||
+79
-16
@@ -28,6 +28,7 @@ from unicodedata import east_asian_width
|
|||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from hyperlink_contract import SOURCE_HREF_ATTR
|
from hyperlink_contract import SOURCE_HREF_ATTR
|
||||||
|
from svg_to_pptx.drawingml.text_baseline import drawingml_text_baseline_offset
|
||||||
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
|
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
|
||||||
|
|
||||||
from .color_resolver import ColorPalette, find_color_elem, resolve_color
|
from .color_resolver import ColorPalette, find_color_elem, resolve_color
|
||||||
@@ -69,6 +70,8 @@ class TextRun:
|
|||||||
is_break: bool = False # marks an a:br within a paragraph
|
is_break: bool = False # marks an a:br within a paragraph
|
||||||
hyperlink_href: str | None = None
|
hyperlink_href: str | None = None
|
||||||
formula_latex: str | None = None
|
formula_latex: str | None = None
|
||||||
|
font_faces: dict[str, str] = field(default_factory=dict)
|
||||||
|
baseline_shifted: bool = False
|
||||||
|
|
||||||
|
|
||||||
HyperlinkResolver = Callable[[str, str], str | None]
|
HyperlinkResolver = Callable[[str, str], str | None]
|
||||||
@@ -90,6 +93,7 @@ class TextParagraph:
|
|||||||
indent_px: float = 0.0
|
indent_px: float = 0.0
|
||||||
margin_left_px: float = 0.0
|
margin_left_px: float = 0.0
|
||||||
line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO
|
line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO
|
||||||
|
line_spacing_px: float | None = None
|
||||||
space_before_px: float = 0.0
|
space_before_px: float = 0.0
|
||||||
space_after_px: float = 0.0
|
space_after_px: float = 0.0
|
||||||
empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX
|
empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX
|
||||||
@@ -163,6 +167,11 @@ def convert_txbody(
|
|||||||
bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"])
|
bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"])
|
||||||
anchor = body_pr.attrib.get("anchor", "t") if body_pr is not None else "t"
|
anchor = body_pr.attrib.get("anchor", "t") if body_pr is not None else "t"
|
||||||
wrap_mode = body_pr.attrib.get("wrap", "square") if body_pr is not None else "square"
|
wrap_mode = body_pr.attrib.get("wrap", "square") if body_pr is not None else "square"
|
||||||
|
# DrawingML defaults to overflow: a fixed frame does not hide later lines.
|
||||||
|
clip_vertical = (
|
||||||
|
body_pr is not None
|
||||||
|
and body_pr.attrib.get("vertOverflow") in {"clip", "ellipsis"}
|
||||||
|
)
|
||||||
respect_edge_spacing = (
|
respect_edge_spacing = (
|
||||||
body_pr is not None
|
body_pr is not None
|
||||||
and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"}
|
and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"}
|
||||||
@@ -197,15 +206,27 @@ def convert_txbody(
|
|||||||
space_after,
|
space_after,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
anchor_space = inner_h - total_h
|
||||||
|
# Overflow stays anchored: centered text grows both ways, bottom text up.
|
||||||
|
# Explicit clipping keeps the leading lines inside the frame instead.
|
||||||
|
if clip_vertical:
|
||||||
|
anchor_space = max(0.0, anchor_space)
|
||||||
if anchor == "ctr":
|
if anchor == "ctr":
|
||||||
cursor_y = inner_y + max(0.0, (inner_h - total_h) / 2.0)
|
cursor_y = inner_y + anchor_space / 2.0
|
||||||
elif anchor == "b":
|
elif anchor == "b":
|
||||||
cursor_y = inner_y + max(0.0, inner_h - total_h)
|
cursor_y = inner_y + anchor_space
|
||||||
else:
|
else:
|
||||||
cursor_y = inner_y
|
cursor_y = inner_y
|
||||||
|
|
||||||
bottom_y = inner_y + inner_h
|
bottom_y = inner_y + inner_h
|
||||||
text_blocks: list[str] = []
|
text_blocks: list[str] = []
|
||||||
|
# Semantic rebuilding can merge paragraphs back into one native body.
|
||||||
|
# Keep one baseline convention throughout that body when math or shifted
|
||||||
|
# runs require the exporter's separate vertical-extent model.
|
||||||
|
metrics_baseline = not any(
|
||||||
|
run.formula_latex is not None or run.baseline_shifted
|
||||||
|
for para in paragraphs for run in para.runs
|
||||||
|
)
|
||||||
for para, lines, height, before, after in zip(
|
for para, lines, height, before, after in zip(
|
||||||
paragraphs,
|
paragraphs,
|
||||||
para_lines,
|
para_lines,
|
||||||
@@ -214,13 +235,19 @@ def convert_txbody(
|
|||||||
space_after,
|
space_after,
|
||||||
):
|
):
|
||||||
cursor_y += before
|
cursor_y += before
|
||||||
visible_lines = _clip_lines_to_bottom(para, lines, cursor_y, bottom_y)
|
visible_lines = (
|
||||||
|
_clip_lines_to_bottom(para, lines, cursor_y, bottom_y)
|
||||||
|
if clip_vertical else lines
|
||||||
|
)
|
||||||
if visible_lines:
|
if visible_lines:
|
||||||
text_blocks.append(
|
text_blocks.append(
|
||||||
_emit_paragraph(para, visible_lines, inner_x, inner_w, cursor_y)
|
_emit_paragraph(
|
||||||
|
para, visible_lines, inner_x, inner_w, cursor_y,
|
||||||
|
metrics_baseline=metrics_baseline,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
cursor_y += height + after
|
cursor_y += height + after
|
||||||
if cursor_y >= bottom_y:
|
if clip_vertical and cursor_y >= bottom_y:
|
||||||
break
|
break
|
||||||
|
|
||||||
svg = "\n".join(text_blocks)
|
svg = "\n".join(text_blocks)
|
||||||
@@ -668,6 +695,9 @@ def _parse_paragraph(
|
|||||||
para.margin_left_px = _emu_px_attr_chain(para_style_chain, "marL", 0.0)
|
para.margin_left_px = _emu_px_attr_chain(para_style_chain, "marL", 0.0)
|
||||||
para.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0)
|
para.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0)
|
||||||
para.line_height_ratio = _line_height_ratio(para_style_chain)
|
para.line_height_ratio = _line_height_ratio(para_style_chain)
|
||||||
|
line_spacing = _child_chain(para_style_chain, "a:lnSpc")
|
||||||
|
if line_spacing is not None and line_spacing.find("a:spcPts", NS) is not None:
|
||||||
|
para.line_spacing_px = _spacing_points_px((line_spacing,), "a:spcPts")
|
||||||
para.space_before_px = _spacing_points_px(para_style_chain, "a:spcBef/a:spcPts")
|
para.space_before_px = _spacing_points_px(para_style_chain, "a:spcBef/a:spcPts")
|
||||||
para.space_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts")
|
para.space_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts")
|
||||||
para.bullet_prefix = _resolve_bullet_prefix(
|
para.bullet_prefix = _resolve_bullet_prefix(
|
||||||
@@ -788,6 +818,8 @@ def _apply_norm_autofit(
|
|||||||
for para in paragraphs:
|
for para in paragraphs:
|
||||||
para.empty_line_font_size_px *= font_scale
|
para.empty_line_font_size_px *= font_scale
|
||||||
para.line_height_ratio *= line_scale
|
para.line_height_ratio *= line_scale
|
||||||
|
if para.line_spacing_px is not None:
|
||||||
|
para.line_spacing_px *= line_scale
|
||||||
for run in para.runs:
|
for run in para.runs:
|
||||||
run.font_size_px *= font_scale
|
run.font_size_px *= font_scale
|
||||||
|
|
||||||
@@ -944,6 +976,11 @@ def _build_run(
|
|||||||
strikethrough=strikethrough,
|
strikethrough=strikethrough,
|
||||||
letter_spacing_px=letter_spacing_px,
|
letter_spacing_px=letter_spacing_px,
|
||||||
hyperlink_href=hyperlink_href,
|
hyperlink_href=hyperlink_href,
|
||||||
|
font_faces={
|
||||||
|
"latin": latin_face or "Segoe UI",
|
||||||
|
"ea": ea_face or latin_face or "Microsoft YaHei",
|
||||||
|
},
|
||||||
|
baseline_shifted=_attr_chain(style_chain, "baseline") not in {None, "0"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -996,7 +1033,7 @@ def _line_height_ratio(sources: tuple[ET.Element | None, ...]) -> float:
|
|||||||
if spc_pct is None:
|
if spc_pct is None:
|
||||||
return DEFAULT_LINE_HEIGHT_RATIO
|
return DEFAULT_LINE_HEIGHT_RATIO
|
||||||
try:
|
try:
|
||||||
return float(spc_pct.attrib.get("val", "100000")) / 100000.0
|
return DEFAULT_LINE_HEIGHT_RATIO * float(spc_pct.attrib.get("val", "100000")) / 100000.0
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return DEFAULT_LINE_HEIGHT_RATIO
|
return DEFAULT_LINE_HEIGHT_RATIO
|
||||||
|
|
||||||
@@ -1492,6 +1529,8 @@ def _copy_run(run: TextRun, *, text: str) -> TextRun:
|
|||||||
letter_spacing_px=run.letter_spacing_px,
|
letter_spacing_px=run.letter_spacing_px,
|
||||||
hyperlink_href=run.hyperlink_href,
|
hyperlink_href=run.hyperlink_href,
|
||||||
formula_latex=run.formula_latex,
|
formula_latex=run.formula_latex,
|
||||||
|
font_faces=dict(run.font_faces),
|
||||||
|
baseline_shifted=run.baseline_shifted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1499,7 +1538,7 @@ def _paragraph_height_from_lines(p: TextParagraph,
|
|||||||
lines: list[list[TextRun]]) -> float:
|
lines: list[list[TextRun]]) -> float:
|
||||||
"""Total px height after wrapping. Each line uses its own max font size."""
|
"""Total px height after wrapping. Each line uses its own max font size."""
|
||||||
if not lines:
|
if not lines:
|
||||||
return p.empty_line_font_size_px * p.line_height_ratio
|
return _line_height(p, [])
|
||||||
height = 0.0
|
height = 0.0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
height += _line_height(p, line)
|
height += _line_height(p, line)
|
||||||
@@ -1507,6 +1546,8 @@ def _paragraph_height_from_lines(p: TextParagraph,
|
|||||||
|
|
||||||
|
|
||||||
def _line_height(p: TextParagraph, line: list[TextRun]) -> float:
|
def _line_height(p: TextParagraph, line: list[TextRun]) -> float:
|
||||||
|
if p.line_spacing_px is not None:
|
||||||
|
return p.line_spacing_px
|
||||||
return _line_font_size(p, line) * p.line_height_ratio
|
return _line_font_size(p, line) * p.line_height_ratio
|
||||||
|
|
||||||
|
|
||||||
@@ -1533,9 +1574,8 @@ def _clip_lines_to_bottom(
|
|||||||
cursor_y = top_y
|
cursor_y = top_y
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line_h = _line_height(para, line)
|
line_h = _line_height(para, line)
|
||||||
# PowerPoint lets the first line that starts within the box render even
|
# Approximate explicit clip/ellipsis at line boundaries. Default
|
||||||
# when it slightly exceeds the bottom — only suppress lines whose top
|
# overflow bypasses this filter; it must not discard source text.
|
||||||
# is already at/below the bottom edge.
|
|
||||||
if cursor_y >= bottom_y:
|
if cursor_y >= bottom_y:
|
||||||
break
|
break
|
||||||
visible.append(line)
|
visible.append(line)
|
||||||
@@ -1554,6 +1594,8 @@ def _emit_paragraph(
|
|||||||
lines: list[list[TextRun]],
|
lines: list[list[TextRun]],
|
||||||
inner_x: float, inner_w: float,
|
inner_x: float, inner_w: float,
|
||||||
top_y: float,
|
top_y: float,
|
||||||
|
*,
|
||||||
|
metrics_baseline: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a paragraph (already split into lines) as one <text> element.
|
"""Render a paragraph (already split into lines) as one <text> element.
|
||||||
|
|
||||||
@@ -1587,7 +1629,26 @@ def _emit_paragraph(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
first_run = visible_lines[first_line_idx][0]
|
first_run = visible_lines[first_line_idx][0]
|
||||||
first_baseline = top_y + 0.85 * first_run.font_size_px
|
baseline_offset = 0.85 * first_run.font_size_px
|
||||||
|
if metrics_baseline:
|
||||||
|
baseline_offset = drawingml_text_baseline_offset(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'text': run.text,
|
||||||
|
'font_size': run.font_size_px,
|
||||||
|
'font_family': run.font_family,
|
||||||
|
'font_faces': run.font_faces,
|
||||||
|
'font_weight': '700' if run.bold else '400',
|
||||||
|
'font_style': 'italic' if run.italic else 'normal',
|
||||||
|
}
|
||||||
|
for run in visible_lines[first_line_idx]
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
default_size=first_run.font_size_px,
|
||||||
|
line_spacing_px=para.line_spacing_px,
|
||||||
|
line_spacing_ratio=para.line_height_ratio / DEFAULT_LINE_HEIGHT_RATIO,
|
||||||
|
)
|
||||||
|
first_baseline = top_y + baseline_offset
|
||||||
|
|
||||||
spans: list[str] = []
|
spans: list[str] = []
|
||||||
for line_idx, line in enumerate(visible_lines):
|
for line_idx, line in enumerate(visible_lines):
|
||||||
@@ -1602,7 +1663,7 @@ def _emit_paragraph(
|
|||||||
if line_advance is not None:
|
if line_advance is not None:
|
||||||
spans.append(
|
spans.append(
|
||||||
f'<tspan x="{fmt_num(anchor_x)}" '
|
f'<tspan x="{fmt_num(anchor_x)}" '
|
||||||
f'dy="{fmt_num(line_advance)}"></tspan>'
|
f'dy="{fmt_num(line_advance, 8)}"></tspan>'
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
line_has_hyperlink = any(run.hyperlink_href for run in line)
|
line_has_hyperlink = any(run.hyperlink_href for run in line)
|
||||||
@@ -1615,7 +1676,7 @@ def _emit_paragraph(
|
|||||||
for run in line
|
for run in line
|
||||||
)
|
)
|
||||||
position_attrs = (
|
position_attrs = (
|
||||||
f' x="{fmt_num(anchor_x)}" dy="{fmt_num(line_advance)}"'
|
f' x="{fmt_num(anchor_x)}" dy="{fmt_num(line_advance, 8)}"'
|
||||||
if line_advance is not None
|
if line_advance is not None
|
||||||
else ''
|
else ''
|
||||||
)
|
)
|
||||||
@@ -1627,19 +1688,21 @@ def _emit_paragraph(
|
|||||||
if run.formula_latex is not None:
|
if run.formula_latex is not None:
|
||||||
spans.append(
|
spans.append(
|
||||||
f'<tspan x="{fmt_num(anchor_x)}" '
|
f'<tspan x="{fmt_num(anchor_x)}" '
|
||||||
f'dy="{fmt_num(line_advance)}">'
|
f'dy="{fmt_num(line_advance, 8)}">'
|
||||||
f'{_run_tspan_markup(run)}</tspan>'
|
f'{_run_tspan_markup(run)}</tspan>'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
spans.append(
|
spans.append(
|
||||||
f'<tspan x="{fmt_num(anchor_x)}" '
|
f'<tspan x="{fmt_num(anchor_x)}" '
|
||||||
f'dy="{fmt_num(line_advance)}"'
|
f'dy="{fmt_num(line_advance, 8)}"'
|
||||||
f'{attrs}>{_xml_escape(run.text)}</tspan>'
|
f'{attrs}>{_xml_escape(run.text)}</tspan>'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
spans.append(_run_tspan_markup(run))
|
spans.append(_run_tspan_markup(run))
|
||||||
|
|
||||||
base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
|
base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
|
||||||
|
if metrics_baseline:
|
||||||
|
base_attrs += ' data-pptx-text-baseline="metrics-v1"'
|
||||||
return f"<text{base_attrs}>{''.join(spans)}</text>"
|
return f"<text{base_attrs}>{''.join(spans)}</text>"
|
||||||
|
|
||||||
|
|
||||||
@@ -1647,7 +1710,7 @@ def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
|||||||
text_anchor: str) -> str:
|
text_anchor: str) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
f'x="{fmt_num(x)}"',
|
f'x="{fmt_num(x)}"',
|
||||||
f'y="{fmt_num(y)}"',
|
f'y="{fmt_num(y, 8)}"',
|
||||||
f'text-anchor="{text_anchor}"',
|
f'text-anchor="{text_anchor}"',
|
||||||
'xml:space="preserve"',
|
'xml:space="preserve"',
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-7
@@ -17,6 +17,7 @@ const strings = {
|
|||||||
blockUnknown: 'Block baseline unknown', blockMissing: 'This block no longer exists',
|
blockUnknown: 'Block baseline unknown', blockMissing: 'This block no longer exists',
|
||||||
exit: 'Exit review', exitConfirm: 'There are unapplied drafts or unsaved comments. Stop review and discard pending work?',
|
exit: 'Exit review', exitConfirm: 'There are unapplied drafts or unsaved comments. Stop review and discard pending work?',
|
||||||
stopped: 'Spec review has stopped. You can close this tab.',
|
stopped: 'Spec review has stopped. You can close this tab.',
|
||||||
|
pageTitle: 'PPT Master · Spec Review', languageLabel: 'Language', blocksLabel: 'Spec blocks', scopeLabel: 'Annotation scope',
|
||||||
disconnected: 'Cannot reach spec review. Local edits are retained.', rebaseConfirm: 'Stage this text against the current disk version, replacing any current staged draft? Review the source first; Apply will replace this block.',
|
disconnected: 'Cannot reach spec review. Local edits are retained.', rebaseConfirm: 'Stage this text against the current disk version, replacing any current staged draft? Review the source first; Apply will replace this block.',
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
@@ -34,6 +35,7 @@ const strings = {
|
|||||||
blockUnknown: '该块的意见基准未知', blockMissing: '该块已不存在',
|
blockUnknown: '该块的意见基准未知', blockMissing: '该块已不存在',
|
||||||
exit: '退出评审', exitConfirm: '存在未应用草稿或未保存意见。是否停止评审并放弃待保存内容?',
|
exit: '退出评审', exitConfirm: '存在未应用草稿或未保存意见。是否停止评审并放弃待保存内容?',
|
||||||
stopped: '评审服务已停止,可以关闭此标签页。',
|
stopped: '评审服务已停止,可以关闭此标签页。',
|
||||||
|
pageTitle: 'PPT Master · 设计规范评审', languageLabel: '语言', blocksLabel: '设计规范块', scopeLabel: '意见范围',
|
||||||
disconnected: '无法连接评审服务,本地编辑已保留。', rebaseConfirm: '以当前磁盘版本为基准暂存此内容,替换服务端的当前草稿?请先核对最新原文;应用时将替换该块。',
|
disconnected: '无法连接评审服务,本地编辑已保留。', rebaseConfirm: '以当前磁盘版本为基准暂存此内容,替换服务端的当前草稿?请先核对最新原文;应用时将替换该块。',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
@@ -51,6 +53,7 @@ const strings = {
|
|||||||
blockUnknown: '該區塊的意見基準未知', blockMissing: '該區塊已不存在',
|
blockUnknown: '該區塊的意見基準未知', blockMissing: '該區塊已不存在',
|
||||||
exit: '退出評審', exitConfirm: '存在未套用草稿或未儲存意見。是否停止評審並放棄待儲存內容?',
|
exit: '退出評審', exitConfirm: '存在未套用草稿或未儲存意見。是否停止評審並放棄待儲存內容?',
|
||||||
stopped: '評審服務已停止,可以關閉此分頁。',
|
stopped: '評審服務已停止,可以關閉此分頁。',
|
||||||
|
pageTitle: 'PPT Master · 設計規範審閱', languageLabel: '語言', blocksLabel: '設計規範區塊', scopeLabel: '意見範圍',
|
||||||
disconnected: '無法連線評審服務,本機編輯已保留。', rebaseConfirm: '以目前磁碟版本為基準暫存此內容,取代伺服器上的目前草稿?請先核對最新原文;套用時將取代該區塊。',
|
disconnected: '無法連線評審服務,本機編輯已保留。', rebaseConfirm: '以目前磁碟版本為基準暫存此內容,取代伺服器上的目前草稿?請先核對最新原文;套用時將取代該區塊。',
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
@@ -69,6 +72,7 @@ const strings = {
|
|||||||
blockUnknown: 'ブロックの基準は不明です', blockMissing: 'このブロックは存在しません',
|
blockUnknown: 'ブロックの基準は不明です', blockMissing: 'このブロックは存在しません',
|
||||||
exit: 'レビューを終了', exitConfirm: '未適用の下書きまたは未保存のコメントがあります。レビューを終了して未保存の内容を破棄しますか?',
|
exit: 'レビューを終了', exitConfirm: '未適用の下書きまたは未保存のコメントがあります。レビューを終了して未保存の内容を破棄しますか?',
|
||||||
stopped: 'レビューサービスは停止しました。このタブを閉じてください。',
|
stopped: 'レビューサービスは停止しました。このタブを閉じてください。',
|
||||||
|
pageTitle: 'PPT Master · 設計仕様レビュー', languageLabel: '言語', blocksLabel: '設計仕様のブロック', scopeLabel: 'コメントの対象',
|
||||||
disconnected: 'サービスに接続できません。編集内容は保持されています。', rebaseConfirm: '現在の原文を基準にこの内容を一時保存し、サーバーの下書きを置き換えますか?原文を先に確認してください。適用でこのブロックを置き換えます。',
|
disconnected: 'サービスに接続できません。編集内容は保持されています。', rebaseConfirm: '現在の原文を基準にこの内容を一時保存し、サーバーの下書きを置き換えますか?原文を先に確認してください。適用でこのブロックを置き換えます。',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -79,10 +83,15 @@ const node = (tag, text) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
const detected = navigator.language.toLowerCase();
|
const detected = navigator.language.toLowerCase();
|
||||||
let lang = detected.startsWith('zh') ? (/tw|hk|hant/.test(detected) ? 'zh-TW' : 'zh') :
|
let lang = detected.startsWith('zh') ? (/\b(tw|hk|mo|hant)\b/.test(detected) && !/\bhans\b/.test(detected) ? 'zh-TW' : 'zh') :
|
||||||
(detected.startsWith('ja') ? 'ja' : 'en');
|
(detected.startsWith('ja') ? 'ja' : 'en');
|
||||||
try { lang = localStorage.getItem('spec-review-language') || lang; } catch (_) { /* Optional browser storage. */ }
|
// `ppt_lang` is the key the other local UIs use; the old key is a read-only fallback.
|
||||||
if (!strings[lang]) lang = 'en';
|
// Each key is validated on its own so an invalid new value cannot hide a valid old one.
|
||||||
|
try {
|
||||||
|
const stored = ['ppt_lang', 'spec-review-language'].map(key => localStorage.getItem(key))
|
||||||
|
.find(value => Object.hasOwn(strings, value));
|
||||||
|
if (stored) lang = stored;
|
||||||
|
} catch (_) { /* Optional browser storage. */ }
|
||||||
const t = key => strings[lang][key];
|
const t = key => strings[lang][key];
|
||||||
|
|
||||||
// User Markdown never crosses an HTML parser boundary. Unsupported constructs
|
// User Markdown never crosses an HTML parser boundary. Unsupported constructs
|
||||||
@@ -304,7 +313,7 @@ function renderComments() {
|
|||||||
$('comments').replaceChildren();
|
$('comments').replaceChildren();
|
||||||
for (const item of comments.filter(item => item.key === annotationKey())) {
|
for (const item of comments.filter(item => item.key === annotationKey())) {
|
||||||
const card = node('div'); card.className = 'comment';
|
const card = node('div'); card.className = 'comment';
|
||||||
card.append(node('small', `${item.id} · ${item.title}`));
|
card.append(node('small', `${item.id} · ${item.key === 'global' ? t('global') : item.title}`));
|
||||||
const status = item.key === 'global' ? (item.base_current ? 'current' : 'stale') :
|
const status = item.key === 'global' ? (item.base_current ? 'current' : 'stale') :
|
||||||
(item.block_missing ? 'blockMissing' : (item.block_changed === true ? 'blockChanged' :
|
(item.block_missing ? 'blockMissing' : (item.block_changed === true ? 'blockChanged' :
|
||||||
(item.block_changed === false ? 'blockUnchanged' : 'blockUnknown')));
|
(item.block_changed === false ? 'blockUnchanged' : 'blockUnknown')));
|
||||||
@@ -372,16 +381,17 @@ async function reload() {
|
|||||||
if ([...drafts.values()].some(draft => draft.sha256 !== metadata.sha256)) showConflict(true);
|
if ([...drafts.values()].some(draft => draft.sha256 !== metadata.sha256)) showConflict(true);
|
||||||
}
|
}
|
||||||
function localize() {
|
function localize() {
|
||||||
document.documentElement.lang = lang; $('language').value = lang;
|
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : lang; $('language').value = lang;
|
||||||
document.querySelectorAll('[data-label]').forEach(element => { element.textContent = t(element.dataset.label); });
|
document.querySelectorAll('[data-label]').forEach(element => { element.textContent = t(element.dataset.label); });
|
||||||
|
document.querySelectorAll('[data-aria]').forEach(element => { element.setAttribute('aria-label', t(element.dataset.aria)); });
|
||||||
if (stopped) { $('stopped').textContent = t('stopped'); return; }
|
if (stopped) { $('stopped').textContent = t('stopped'); return; }
|
||||||
renderStatus(); renderNavigation(); renderPreview(); renderComments();
|
renderStatus(); renderNavigation(); renderPreview(); renderComments();
|
||||||
if (!$('conflict').hidden) showConflict();
|
if (!$('conflict').hidden) { if (current?.missing) $('conflict').textContent = t('missing'); else showConflict(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
$('language').addEventListener('change', () => {
|
$('language').addEventListener('change', () => {
|
||||||
lang = $('language').value;
|
lang = $('language').value;
|
||||||
try { localStorage.setItem('spec-review-language', lang); } catch (_) { /* Optional browser storage. */ }
|
try { localStorage.setItem('ppt_lang', lang); } catch (_) { /* Optional browser storage. */ }
|
||||||
localize();
|
localize();
|
||||||
});
|
});
|
||||||
$('source').addEventListener('click', () => { source = !source; renderPreview(); });
|
$('source').addEventListener('click', () => { source = !source; renderPreview(); });
|
||||||
|
|||||||
+5
-5
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>PPT Master · Spec Review</title>
|
<title data-label="pageTitle">PPT Master · Spec Review</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header><strong>PPT Master · Spec Review</strong><span id="fingerprint"></span>
|
<header><strong data-label="pageTitle">PPT Master · Spec Review</strong><span id="fingerprint"></span>
|
||||||
<select id="language" aria-label="Language">
|
<select id="language" aria-label="Language" data-aria="languageLabel">
|
||||||
<option value="zh">中文</option><option value="zh-TW">繁體中文</option>
|
<option value="zh">中文</option><option value="zh-TW">繁體中文</option>
|
||||||
<option value="en">English</option><option value="ja">日本語</option>
|
<option value="en">English</option><option value="ja">日本語</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<button id="first" data-label="first"></button><button id="prev" data-label="prev"></button>
|
<button id="first" data-label="first"></button><button id="prev" data-label="prev"></button>
|
||||||
<button id="next" data-label="next"></button><button id="last" data-label="last"></button>
|
<button id="next" data-label="next"></button><button id="last" data-label="last"></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="position"></div><nav id="blocks" aria-label="Spec blocks"></nav>
|
<div id="position"></div><nav id="blocks" aria-label="Spec blocks" data-aria="blocksLabel"></nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
<div class="toolbar"><h1 id="title"></h1>
|
<div class="toolbar"><h1 id="title"></h1>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<aside class="annotations">
|
<aside class="annotations">
|
||||||
<h2 data-label="annotations"></h2>
|
<h2 data-label="annotations"></h2>
|
||||||
<select id="scope" aria-label="Annotation scope"><option value="block" data-label="thisBlock"></option>
|
<select id="scope" aria-label="Annotation scope" data-aria="scopeLabel"><option value="block" data-label="thisBlock"></option>
|
||||||
<option value="global" data-label="global"></option></select>
|
<option value="global" data-label="global"></option></select>
|
||||||
<div id="comments"></div>
|
<div id="comments"></div>
|
||||||
<label for="annotation" data-label="newAnnotation"></label>
|
<label for="annotation" data-label="newAnnotation"></label>
|
||||||
|
|||||||
+12
-6
@@ -375,7 +375,9 @@
|
|||||||
applyI18n();
|
applyI18n();
|
||||||
refreshLangUI(lang);
|
refreshLangUI(lang);
|
||||||
// Re-render dynamic regions so they pick up the new language
|
// Re-render dynamic regions so they pick up the new language
|
||||||
|
var annotationDraft = annotationText.value;
|
||||||
updateSelectionPanel();
|
updateSelectionPanel();
|
||||||
|
annotationText.value = annotationDraft;
|
||||||
updateAnnotationList();
|
updateAnnotationList();
|
||||||
updateUndoButton();
|
updateUndoButton();
|
||||||
updatePendingStatus();
|
updatePendingStatus();
|
||||||
@@ -503,7 +505,7 @@
|
|||||||
btn.textContent = t(action.key);
|
btn.textContent = t(action.key);
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
var label = t(action.key);
|
var label = t(action.key);
|
||||||
var prefix = (LANG === "zh" || LANG === "ja") ? label + ":" : label + ": ";
|
var prefix = (LANG === "zh" || LANG === "zh-TW" || LANG === "ja") ? label + ":" : label + ": ";
|
||||||
if (!annotationText.value.trim()) {
|
if (!annotationText.value.trim()) {
|
||||||
annotationText.value = prefix;
|
annotationText.value = prefix;
|
||||||
} else if (annotationText.value.indexOf(prefix) === -1) {
|
} else if (annotationText.value.indexOf(prefix) === -1) {
|
||||||
@@ -590,6 +592,12 @@
|
|||||||
if (navLastBtn) navLastBtn.disabled = !hasCurrent || idx2 >= total - 1;
|
if (navLastBtn) navLastBtn.disabled = !hasCurrent || idx2 >= total - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep data-i18n on the shown key so applyI18n() re-translates it instead of resetting it.
|
||||||
|
function setPlaceholder(key) {
|
||||||
|
svgPlaceholder.setAttribute("data-i18n", key);
|
||||||
|
svgPlaceholder.textContent = t(key);
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// 1. loadSlides -- GET /api/slides
|
// 1. loadSlides -- GET /api/slides
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -610,9 +618,7 @@
|
|||||||
slideListEl.appendChild(empty);
|
slideListEl.appendChild(empty);
|
||||||
if (!currentSlide) {
|
if (!currentSlide) {
|
||||||
svgPlaceholder.style.display = "block";
|
svgPlaceholder.style.display = "block";
|
||||||
svgPlaceholder.textContent = liveMode
|
setPlaceholder(liveMode ? "placeholder_live_ready" : "empty_no_slides");
|
||||||
? t("placeholder_live_ready")
|
|
||||||
: t("empty_no_slides");
|
|
||||||
svgContent.style.display = "none";
|
svgContent.style.display = "none";
|
||||||
}
|
}
|
||||||
updateNavLabel();
|
updateNavLabel();
|
||||||
@@ -694,7 +700,7 @@
|
|||||||
if (!liveMode || name !== currentSlide) return;
|
if (!liveMode || name !== currentSlide) return;
|
||||||
waitingForSlide = name;
|
waitingForSlide = name;
|
||||||
svgPlaceholder.style.display = "block";
|
svgPlaceholder.style.display = "block";
|
||||||
svgPlaceholder.textContent = t("placeholder_slide_writing");
|
setPlaceholder("placeholder_slide_writing");
|
||||||
svgContent.style.display = "none";
|
svgContent.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +780,7 @@
|
|||||||
if (!rootSvg || !hasContent) {
|
if (!rootSvg || !hasContent) {
|
||||||
showError(t("err_empty_svg"));
|
showError(t("err_empty_svg"));
|
||||||
svgPlaceholder.style.display = "block";
|
svgPlaceholder.style.display = "block";
|
||||||
svgPlaceholder.textContent = t("err_empty_svg");
|
setPlaceholder("err_empty_svg");
|
||||||
svgContent.style.display = "none";
|
svgContent.style.display = "none";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -175,6 +175,11 @@ def copy_text_attrs(
|
|||||||
v = src_el.get(k)
|
v = src_el.get(k)
|
||||||
if v is not None:
|
if v is not None:
|
||||||
dst_el.set(k, v)
|
dst_el.set(k, v)
|
||||||
|
# A split row must retain the imported baseline/inset convention, even
|
||||||
|
# though the version marker is not an inherited SVG presentation property.
|
||||||
|
baseline_attr = "data-pptx-text-baseline"
|
||||||
|
if baseline_attr not in exclude and src_el.get(baseline_attr) is not None:
|
||||||
|
dst_el.set(baseline_attr, src_el.get(baseline_attr))
|
||||||
# xml:space preservation
|
# xml:space preservation
|
||||||
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
|
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
|
||||||
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
|
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
|
||||||
|
|||||||
+51
@@ -51,6 +51,7 @@ from .hyperlinks import (
|
|||||||
)
|
)
|
||||||
from .theme_colors import color_node_xml
|
from .theme_colors import color_node_xml
|
||||||
from .theme_fonts import theme_font_tokens
|
from .theme_fonts import theme_font_tokens
|
||||||
|
from .text_baseline import drawingml_text_baseline_offset
|
||||||
from .text_properties import (
|
from .text_properties import (
|
||||||
drawingml_letter_spacing,
|
drawingml_letter_spacing,
|
||||||
normalize_project_text_segments,
|
normalize_project_text_segments,
|
||||||
@@ -3091,6 +3092,12 @@ def _build_run_xml(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
||||||
if run.get('_line_break'):
|
if run.get('_line_break'):
|
||||||
|
break_run = run.get('_line_break_run')
|
||||||
|
if break_run is not None:
|
||||||
|
properties = dict(break_run)
|
||||||
|
properties.pop(HYPERLINK_RID_KEY, None)
|
||||||
|
properties.pop(HYPERLINK_ACTION_KEY, None)
|
||||||
|
return f'<a:br>{_build_run_properties_xml(properties, default_fonts, ctx)}</a:br>'
|
||||||
return '<a:br/>'
|
return '<a:br/>'
|
||||||
text = str(run['text'])
|
text = str(run['text'])
|
||||||
inline_formula = run.get(_INLINE_FORMULA_KEY)
|
inline_formula = run.get(_INLINE_FORMULA_KEY)
|
||||||
@@ -3382,6 +3389,50 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
font_size,
|
font_size,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Legacy imported frames retain their original baseline/inset convention.
|
||||||
|
# Native math and shifted runs retain their own vertical-extent model.
|
||||||
|
# Ordinary text instead needs PowerPoint's first-line baseline, which is
|
||||||
|
# distinct from the glyph bounds used above and by SVG quality checks.
|
||||||
|
metrics_baseline = (
|
||||||
|
elem.get('data-pptx-frame') is None
|
||||||
|
or elem.get('data-pptx-text-baseline') == 'metrics-v1'
|
||||||
|
)
|
||||||
|
if metrics_baseline and not any(
|
||||||
|
run.get(_INLINE_FORMULA_KEY) is not None or run.get('baseline_shift')
|
||||||
|
for run in runs
|
||||||
|
):
|
||||||
|
first_line_runs = runs
|
||||||
|
last_line_runs = runs
|
||||||
|
if paragraph_runs is not None:
|
||||||
|
# Paragraph extraction replaces a typed marker with a native bullet
|
||||||
|
# that inherits the body font; its discarded run is not line ink.
|
||||||
|
first_line_runs, _bullet = _extract_text_bullet(visual_line_runs[0])
|
||||||
|
last_line_runs = paragraph_runs[-1]
|
||||||
|
for index, run in enumerate(last_line_runs):
|
||||||
|
if run.get('_line_break'):
|
||||||
|
last_line_runs = paragraph_runs[-1][index + 1:]
|
||||||
|
first_line_ascent = drawingml_text_baseline_offset(
|
||||||
|
first_line_runs,
|
||||||
|
fonts,
|
||||||
|
default_size=font_size,
|
||||||
|
line_spacing_px=line_height_px if paragraph_runs is not None else None,
|
||||||
|
language=ctx.primary_language,
|
||||||
|
)
|
||||||
|
largest_last_size = max(
|
||||||
|
(font_px_to_hpt(run.get('font_size', font_size)) / FONT_PX_TO_HUNDREDTHS_PT
|
||||||
|
for run in last_line_runs if run.get('text')),
|
||||||
|
default=font_size,
|
||||||
|
)
|
||||||
|
text_height += max(0.0, (largest_last_size - font_size) * 1.5)
|
||||||
|
# An unformatted break can inherit the presentation's default size
|
||||||
|
# instead of the preceding run, changing first-line ascent and advance.
|
||||||
|
for paragraph in paragraph_runs or []:
|
||||||
|
previous_run = None
|
||||||
|
for run in paragraph:
|
||||||
|
if run.get('_line_break') and previous_run is not None:
|
||||||
|
run['_line_break_run'] = previous_run
|
||||||
|
elif not run.get('_line_break'):
|
||||||
|
previous_run = run
|
||||||
padding = _textbox_padding(font_size)
|
padding = _textbox_padding(font_size)
|
||||||
|
|
||||||
# Adjust position based on text-anchor. This first box follows the visible
|
# Adjust position based on text-anchor. This first box follows the visible
|
||||||
|
|||||||
+1148
File diff suppressed because it is too large.
Load diff
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - DrawingML Text Baseline
|
||||||
|
|
||||||
|
Estimate the first baseline of ordinary top-anchored DrawingML text from
|
||||||
|
bundled font metrics. See scripts/docs/svg-pipeline.md for the rendering scope.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
Import drawingml_text_baseline_offset from the DrawingML converter.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
offset = drawingml_text_baseline_offset(runs, fonts, default_size=40)
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
None (only uses standard library and sibling modules)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from fractions import Fraction
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .text_properties import parse_project_font_weight
|
||||||
|
from .utils import (
|
||||||
|
FONT_PX_TO_HUNDREDTHS_PT,
|
||||||
|
font_px_to_hpt,
|
||||||
|
is_cjk_char,
|
||||||
|
parse_font_family,
|
||||||
|
primary_font_family,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _font_metrics() -> dict[str, Any]:
|
||||||
|
with Path(__file__).with_name('font_metrics.json').open(encoding='utf-8') as handle:
|
||||||
|
return json.load(handle)['families']
|
||||||
|
|
||||||
|
|
||||||
|
def _ascent_ratio(family: str, weight: str, style: str) -> Fraction:
|
||||||
|
faces = _font_metrics().get(primary_font_family(family), {})
|
||||||
|
bold = parse_project_font_weight(weight).value
|
||||||
|
face_style = 'bold' if bold else 'regular'
|
||||||
|
if style == 'italic':
|
||||||
|
face_style = 'bold-italic' if bold else 'italic'
|
||||||
|
# A synthesized italic keeps its real bold face when one is available.
|
||||||
|
# Unknown fonts use a fixed ratio, independent of installed fonts.
|
||||||
|
metrics = (
|
||||||
|
faces.get(face_style)
|
||||||
|
or (faces.get('bold') if bold else None)
|
||||||
|
or faces.get('regular')
|
||||||
|
)
|
||||||
|
if metrics is None:
|
||||||
|
return Fraction(4, 5)
|
||||||
|
source = 'typo' if metrics['fsSelection'] & 128 else 'win'
|
||||||
|
ascent, descent = metrics[source][:2]
|
||||||
|
return Fraction(ascent, ascent + abs(descent))
|
||||||
|
|
||||||
|
|
||||||
|
def _whole_points(value: Fraction) -> int:
|
||||||
|
rounded = value + Fraction(1, 2)
|
||||||
|
return rounded.numerator // rounded.denominator
|
||||||
|
|
||||||
|
|
||||||
|
def drawingml_text_baseline_offset(
|
||||||
|
runs: list[dict[str, Any]],
|
||||||
|
default_fonts: dict[str, str],
|
||||||
|
*,
|
||||||
|
default_size: float,
|
||||||
|
line_spacing_px: float | None = None,
|
||||||
|
line_spacing_ratio: float = 1.0,
|
||||||
|
language: str | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Return the first baseline's distance from a zero-inset frame top in px.
|
||||||
|
|
||||||
|
Call only for ordinary text, without native math or baseline shifts.
|
||||||
|
Runs describe the first visual line. Importers may supply resolved
|
||||||
|
``font_faces`` to retain the original DrawingML script slots. Font sizes
|
||||||
|
and explicit line spacing use the same quantization as the emitted XML.
|
||||||
|
"""
|
||||||
|
ascent = descent = largest_size = Fraction(0)
|
||||||
|
for run in runs:
|
||||||
|
if run.get('_line_break'):
|
||||||
|
break
|
||||||
|
text = str(run.get('text', ''))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
size = Fraction(font_px_to_hpt(run.get('font_size', default_size)), 100)
|
||||||
|
fonts = run.get('font_faces') or (
|
||||||
|
parse_font_family(str(run['font_family']), language)
|
||||||
|
if run.get('font_family') else default_fonts
|
||||||
|
)
|
||||||
|
families = {fonts['ea'] if is_cjk_char(ch) else fonts['latin'] for ch in text}
|
||||||
|
for family in families:
|
||||||
|
ratio = _ascent_ratio(
|
||||||
|
family,
|
||||||
|
str(run.get('font_weight', '400')),
|
||||||
|
str(run.get('font_style', 'normal')),
|
||||||
|
)
|
||||||
|
ascent = max(ascent, size * ratio)
|
||||||
|
descent = max(descent, size * (1 - ratio))
|
||||||
|
largest_size = max(largest_size, size)
|
||||||
|
|
||||||
|
if not largest_size:
|
||||||
|
return 0.0
|
||||||
|
natural_height = Fraction(6, 5) * largest_size
|
||||||
|
baseline = natural_height * ascent / (ascent + descent)
|
||||||
|
if line_spacing_px is not None or line_spacing_ratio != 1.0:
|
||||||
|
spacing = natural_height * Fraction(str(line_spacing_ratio))
|
||||||
|
if line_spacing_px is not None:
|
||||||
|
spacing = Fraction(round(line_spacing_px * FONT_PX_TO_HUNDREDTHS_PT), 100)
|
||||||
|
# The compatible layout rounds fixed spacing before choosing its
|
||||||
|
# branch: 57.6pt becomes 58pt even when natural height is 57.6pt.
|
||||||
|
# Percentage spacing keeps its natural-height semantics.
|
||||||
|
spacing = Fraction(_whole_points(spacing))
|
||||||
|
minimum_baseline = Fraction(3, 4) * spacing
|
||||||
|
baseline = (
|
||||||
|
max(minimum_baseline, spacing - (natural_height - baseline))
|
||||||
|
if spacing <= natural_height else minimum_baseline
|
||||||
|
)
|
||||||
|
|
||||||
|
# PowerPoint's compatible layout rounds the first-line offset to whole
|
||||||
|
# points. Rational arithmetic preserves half-point ties (e.g. SimSun 64px)
|
||||||
|
# without float epsilons or Python round()'s ties-to-even behavior.
|
||||||
|
return float(_whole_points(baseline) * Fraction(4, 3))
|
||||||
+1
-1
@@ -628,7 +628,7 @@ class EditNativeBatchBTests(unittest.TestCase):
|
|||||||
def test_b7_retained_diagram_and_replaced_chart_coexist(self) -> None:
|
def test_b7_retained_diagram_and_replaced_chart_coexist(self) -> None:
|
||||||
self.import_source(chart=True, mutate=_diagram_fixture)
|
self.import_source(chart=True, mutate=_diagram_fixture)
|
||||||
path, root = self.page()
|
path, root = self.page()
|
||||||
summary = json.loads((path.parent / 'authoring_summary.json').read_text())
|
summary = json.loads((path.parent / 'authoring_summary.json').read_text(encoding="utf-8"))
|
||||||
self.assertIn('"source_proxies": 1', json.dumps(summary))
|
self.assertIn('"source_proxies": 1', json.dumps(summary))
|
||||||
marker = next(node for node in root.iter() if node.get('data-pptx-replace-with') == 'chart')
|
marker = next(node for node in root.iter() if node.get('data-pptx-replace-with') == 'chart')
|
||||||
metadata = marker.find('s:metadata[@type="application/json"]', NS)
|
metadata = marker.find('s:metadata[@type="application/json"]', NS)
|
||||||
|
|||||||
+4
-2
@@ -435,8 +435,10 @@ class NativeExportGuardTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(group.findall('p:sp', NS)), 2)
|
self.assertEqual(len(group.findall('p:sp', NS)), 2)
|
||||||
frame = group.find('p:grpSpPr/a:xfrm', NS)
|
frame = group.find('p:grpSpPr/a:xfrm', NS)
|
||||||
self.assertEqual(frame.get('rot'), '5400000')
|
self.assertEqual(frame.get('rot'), '5400000')
|
||||||
self.assertEqual(frame.find('a:off', NS).attrib, {'x': '487680', 'y': '1468755'})
|
# Arial 20px has a native baseline offset of 15pt (20px), so the
|
||||||
self.assertEqual(frame.find('a:chOff', NS).attrib, {'x': '944880', 'y': '981075'})
|
# text child starts at y=100 before the group rotates around (100,120).
|
||||||
|
self.assertEqual(frame.find('a:off', NS).attrib, {'x': '516255', 'y': '1468755'})
|
||||||
|
self.assertEqual(frame.find('a:chOff', NS).attrib, {'x': '944880', 'y': '952500'})
|
||||||
self.assertFalse(group.findall('p:sp/p:spPr/a:xfrm[@rot]', NS))
|
self.assertFalse(group.findall('p:sp/p:spPr/a:xfrm[@rot]', NS))
|
||||||
|
|
||||||
def test_single_rect_matrix_matches_direct_geometry_and_skew_stays_rejected(self) -> None:
|
def test_single_rect_matrix_matches_direct_geometry_and_skew_stays_rejected(self) -> None:
|
||||||
|
|||||||
+6
-6
@@ -253,7 +253,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.status_code, 200, result.get_json())
|
self.assertEqual(result.status_code, 200, result.get_json())
|
||||||
self.assertEqual(result.get_json()["validation_errors"], [])
|
self.assertEqual(result.get_json()["validation_errors"], [])
|
||||||
self.assertNotIn(b"\n", self.path.read_bytes().replace(b"\r\n", b""))
|
self.assertNotIn(b"\n", self.path.read_bytes().replace(b"\r\n", b""))
|
||||||
log = json.loads((self.root / "spec_review/edits.jsonl").read_text())
|
log = json.loads((self.root / "spec_review/edits.jsonl").read_text(encoding="utf-8"))
|
||||||
self.assertEqual(log["before_sha256"], draft["sha256"])
|
self.assertEqual(log["before_sha256"], draft["sha256"])
|
||||||
self.assertEqual(log["after_sha256"], sha256(self.path.read_bytes()))
|
self.assertEqual(log["after_sha256"], sha256(self.path.read_bytes()))
|
||||||
self.assertIn("Edited", log["after"])
|
self.assertIn("Edited", log["after"])
|
||||||
@@ -355,7 +355,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
json={"revision": updated["revision"]}).status_code, 200)
|
json={"revision": updated["revision"]}).status_code, 200)
|
||||||
self.assertEqual(self.path.read_bytes(), before)
|
self.assertEqual(self.path.read_bytes(), before)
|
||||||
events = [json.loads(line)["event"] for line in
|
events = [json.loads(line)["event"] for line in
|
||||||
(self.root / "spec_review/annotations.jsonl").read_text().splitlines()]
|
(self.root / "spec_review/annotations.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||||
self.assertEqual(events, ["saved", "updated", "removed"])
|
self.assertEqual(events, ["saved", "updated", "removed"])
|
||||||
|
|
||||||
def test_applied_unchanged_comment_is_removed_and_logged(self):
|
def test_applied_unchanged_comment_is_removed_and_logged(self):
|
||||||
@@ -363,7 +363,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.check()[0], 0)
|
self.assertEqual(self.check()[0], 0)
|
||||||
self.assertEqual(self.check("--applied", item["id"])[0], 0)
|
self.assertEqual(self.check("--applied", item["id"])[0], 0)
|
||||||
self.assertEqual(self.store.annotations(), [])
|
self.assertEqual(self.store.annotations(), [])
|
||||||
self.assertIn('"event":"annotation_applied"', (self.root / "spec_review/annotations.jsonl").read_text())
|
self.assertIn('"event":"annotation_applied"', (self.root / "spec_review/annotations.jsonl").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
def test_applied_changed_comment_is_retained(self):
|
def test_applied_changed_comment_is_retained(self):
|
||||||
item = self.store.save_annotation("slide:01", "Original")
|
item = self.store.save_annotation("slide:01", "Original")
|
||||||
@@ -386,7 +386,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
item = self.store.save_annotation("global", "Original")
|
item = self.store.save_annotation("global", "Original")
|
||||||
self.check()
|
self.check()
|
||||||
path = self.root / "spec_review/annotations.json"
|
path = self.root / "spec_review/annotations.json"
|
||||||
data = json.loads(path.read_text())
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
data["items"][0]["body"] = "Manually changed"
|
data["items"][0]["body"] = "Manually changed"
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
self.assertEqual(self.check("--applied", item["id"])[0], 1)
|
self.assertEqual(self.check("--applied", item["id"])[0], 1)
|
||||||
@@ -460,7 +460,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
self.store.save_annotation("slide:01", "Legacy feedback")
|
self.store.save_annotation("slide:01", "Legacy feedback")
|
||||||
self.store.save_annotation("slide:02", "Missing block feedback")
|
self.store.save_annotation("slide:02", "Missing block feedback")
|
||||||
sidecar = self.root / "spec_review/annotations.json"
|
sidecar = self.root / "spec_review/annotations.json"
|
||||||
data = json.loads(sidecar.read_text())
|
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||||
del data["items"][0]["block_sha256"]
|
del data["items"][0]["block_sha256"]
|
||||||
sidecar.write_text(json.dumps(data), encoding="utf-8")
|
sidecar.write_text(json.dumps(data), encoding="utf-8")
|
||||||
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
|
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
|
||||||
@@ -508,7 +508,7 @@ class ReviewServiceTests(unittest.TestCase):
|
|||||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
results = list(pool.map(lambda _: apply(), range(2)))
|
results = list(pool.map(lambda _: apply(), range(2)))
|
||||||
self.assertEqual(sorted(results), [200, 409])
|
self.assertEqual(sorted(results), [200, 409])
|
||||||
self.assertEqual(len((self.root / "spec_review/edits.jsonl").read_text().splitlines()), 1)
|
self.assertEqual(len((self.root / "spec_review/edits.jsonl").read_text(encoding="utf-8").splitlines()), 1)
|
||||||
|
|
||||||
def test_other_block_draft_rebases_only_after_own_edit(self):
|
def test_other_block_draft_rebases_only_after_own_edit(self):
|
||||||
first = self.draft("slide:01")
|
first = self.draft("slide:01")
|
||||||
|
|||||||
+4
-4
@@ -160,7 +160,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
|||||||
for path in confirm.iterdir():
|
for path in confirm.iterdir():
|
||||||
os.utime(path, ns=(1_000_000_000, 1_000_000_000))
|
os.utime(path, ns=(1_000_000_000, 1_000_000_000))
|
||||||
self.assertIsNone(server._stage2_ready_error(self.root, confirm))
|
self.assertIsNone(server._stage2_ready_error(self.root, confirm))
|
||||||
previous = json.loads((confirm / 'template_selection.json').read_text())
|
previous = json.loads((confirm / 'template_selection.json').read_text(encoding="utf-8"))
|
||||||
data = _stage1()
|
data = _stage1()
|
||||||
data['revision'] = 2
|
data['revision'] = 2
|
||||||
_write_json(rec, data)
|
_write_json(rec, data)
|
||||||
@@ -176,7 +176,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
|||||||
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200, response.get_json())
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
current = json.loads((confirm / 'template_selection.json').read_text())
|
current = json.loads((confirm / 'template_selection.json').read_text(encoding="utf-8"))
|
||||||
self.assertNotEqual(previous['selection_sha256'], current['selection_sha256'])
|
self.assertNotEqual(previous['selection_sha256'], current['selection_sha256'])
|
||||||
|
|
||||||
def test_b_real_endpoints_require_install_and_selection_bound_stage2(self) -> None:
|
def test_b_real_endpoints_require_install_and_selection_bound_stage2(self) -> None:
|
||||||
@@ -198,7 +198,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
self.assertEqual(response.status_code, 200, response.get_json())
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
selection = json.loads((confirm / 'template_selection.json').read_text())
|
selection = json.loads((confirm / 'template_selection.json').read_text(encoding="utf-8"))
|
||||||
rec2, final = _stage2(selection['selection_sha256'], template)
|
rec2, final = _stage2(selection['selection_sha256'], template)
|
||||||
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||||
if template:
|
if template:
|
||||||
@@ -221,7 +221,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
|||||||
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||||
response = client.post('/api/confirm', json=final)
|
response = client.post('/api/confirm', json=final)
|
||||||
self.assertEqual(response.status_code, 200, response.get_json())
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
result = json.loads((confirm / 'result.json').read_text())
|
result = json.loads((confirm / 'result.json').read_text(encoding="utf-8"))
|
||||||
self.assertEqual(result['status'], 'confirmed')
|
self.assertEqual(result['status'], 'confirmed')
|
||||||
self.assertEqual(result['selection_sha256'], selection['selection_sha256'])
|
self.assertEqual(result['selection_sha256'], selection['selection_sha256'])
|
||||||
self.assertFalse((confirm / 'template_handoff.json').exists())
|
self.assertFalse((confirm / 'template_handoff.json').exists())
|
||||||
|
|||||||
+5
-5
@@ -121,7 +121,7 @@ class TemplateFlowBatch4Tests(unittest.TestCase):
|
|||||||
'text_slots': [{'selector': '#missing'}],
|
'text_slots': [{'selector': '#missing'}],
|
||||||
}))
|
}))
|
||||||
self.assertIn('unavailable', slot_capacity_report([path])['slots'][0])
|
self.assertIn('unavailable', slot_capacity_report([path])['slots'][0])
|
||||||
sidecar.write_text('[]')
|
sidecar.write_text('[]', encoding="utf-8")
|
||||||
with self.assertRaisesRegex(ValueError, 'text_slots array'):
|
with self.assertRaisesRegex(ValueError, 'text_slots array'):
|
||||||
slot_capacity_report([path])
|
slot_capacity_report([path])
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ class TemplateFlowBatch4Tests(unittest.TestCase):
|
|||||||
self.assertFalse(any('paint order' in issue for issue in result['errors']), result['errors'])
|
self.assertFalse(any('paint order' in issue for issue in result['errors']), result['errors'])
|
||||||
parse_template_slide(path, 1)
|
parse_template_slide(path, 1)
|
||||||
# Export an attribute-based case and inspect the native shape alpha.
|
# Export an attribute-based case and inspect the native shape alpha.
|
||||||
path.write_text(path.read_text().replace('style="opacity:80%"', 'fill-opacity="0.8"'))
|
path.write_text(path.read_text(encoding="utf-8").replace('style="opacity:80%"', 'fill-opacity="0.8"'), encoding="utf-8")
|
||||||
pptx = self.root / 'scrim.pptx'
|
pptx = self.root / 'scrim.pptx'
|
||||||
with contextlib.redirect_stdout(io.StringIO()):
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
self.assertTrue(create_pptx_with_native_svg(
|
self.assertTrue(create_pptx_with_native_svg(
|
||||||
@@ -150,14 +150,14 @@ class TemplateFlowBatch4Tests(unittest.TestCase):
|
|||||||
self.assertEqual(len(master.findall('./p:cSld/p:bg', NS)), 1)
|
self.assertEqual(len(master.findall('./p:cSld/p:bg', NS)), 1)
|
||||||
self.assertIsNone(master.find('./p:cSld/p:bg//a:alpha', NS))
|
self.assertIsNone(master.find('./p:cSld/p:bg//a:alpha', NS))
|
||||||
self.assertEqual(master.find('.//p:sp/p:spPr/a:solidFill/a:srgbClr/a:alpha', NS).get('val'), '80000')
|
self.assertEqual(master.find('.//p:sp/p:spPr/a:solidFill/a:srgbClr/a:alpha', NS).get('val'), '80000')
|
||||||
opaque = path.read_text().replace('fill-opacity="0.8"', 'fill-opacity="1"')
|
opaque = path.read_text(encoding="utf-8").replace('fill-opacity="0.8"', 'fill-opacity="1"')
|
||||||
path.write_text(opaque)
|
path.write_text(opaque, encoding="utf-8")
|
||||||
self.assertTrue(any('paint order' in issue for issue in
|
self.assertTrue(any('paint order' in issue for issue in
|
||||||
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||||
for inherited in ('fill-opacity="0.8"', 'style="opacity:0.8"'):
|
for inherited in ('fill-opacity="0.8"', 'style="opacity:0.8"'):
|
||||||
with self.subTest(inherited=inherited):
|
with self.subTest(inherited=inherited):
|
||||||
path.write_text(opaque.replace('fill-opacity="1"', '').replace(
|
path.write_text(opaque.replace('fill-opacity="1"', '').replace(
|
||||||
'data-pptx-layout="l"', f'data-pptx-layout="l" {inherited}'))
|
'data-pptx-layout="l"', f'data-pptx-layout="l" {inherited}'), encoding="utf-8")
|
||||||
self.assertFalse(any('paint order' in issue for issue in
|
self.assertFalse(any('paint order' in issue for issue in
|
||||||
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||||
parse_template_slide(path, 1)
|
parse_template_slide(path, 1)
|
||||||
|
|||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Native Text Baseline Regression Tests
|
||||||
|
|
||||||
|
Check exported frame positions against PowerPoint-rendered baseline samples.
|
||||||
|
Fixtures are self-contained and never read installed fonts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_text_baseline
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Run the module from the scripts directory.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the local SVG-to-PPTX converter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
NS = {
|
||||||
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||||
|
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
||||||
|
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||||
|
}
|
||||||
|
EMU_PER_PX = 9525
|
||||||
|
|
||||||
|
|
||||||
|
class TextBaselineTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
self.svg_path = self.root / '01_baseline.svg'
|
||||||
|
|
||||||
|
def _export(self, body: str, *, text_flow: str = 'split') -> ET.Element:
|
||||||
|
self.svg_path.write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||||
|
f'{body}</svg>',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
xml, *_rest = convert_svg_to_slide_shapes(
|
||||||
|
self.svg_path, resource_root=self.root, text_flow=text_flow,
|
||||||
|
)
|
||||||
|
return ET.fromstring(xml)
|
||||||
|
|
||||||
|
def _assert_top(self, shape: ET.Element, expected_px: float) -> None:
|
||||||
|
offset = shape.find('p:spPr/a:xfrm/a:off', NS)
|
||||||
|
self.assertIsNotNone(offset)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
int(offset.get('y')), expected_px * EMU_PER_PX, delta=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _single_shape(self, slide: ET.Element) -> ET.Element:
|
||||||
|
shapes = slide.findall('.//p:sp', NS)
|
||||||
|
self.assertEqual(len(shapes), 1)
|
||||||
|
return shapes[0]
|
||||||
|
|
||||||
|
def test_default_spacing_uses_each_fonts_rendered_baseline(self) -> None:
|
||||||
|
# Mac PowerPoint 16.113.2: observed offsets rounded to the native pt grid.
|
||||||
|
samples = (
|
||||||
|
('Arial', 38.6666666667),
|
||||||
|
('Calibri', 37.3333333333),
|
||||||
|
('SimSun', 41.3333333333),
|
||||||
|
('SimHei', 41.3333333333),
|
||||||
|
('Microsoft YaHei', 38.6666666667),
|
||||||
|
)
|
||||||
|
for family, baseline_offset in samples:
|
||||||
|
with self.subTest(family=family):
|
||||||
|
slide = self._export(
|
||||||
|
f'<text x="100" y="200" font-size="40" '
|
||||||
|
f'font-family="{family}">Hgpq</text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 200 - baseline_offset)
|
||||||
|
self.assertIsNone(shape.find('.//a:lnSpc', NS))
|
||||||
|
body = shape.find('p:txBody/a:bodyPr', NS)
|
||||||
|
self.assertEqual(body.get('tIns'), '0')
|
||||||
|
self.assertEqual(body.get('anchor'), 't')
|
||||||
|
|
||||||
|
def test_font_metric_fields_distinguish_windows_and_typographic_metrics(self) -> None:
|
||||||
|
for family, size, expected_offset in (
|
||||||
|
('Candara', 40, 37.3333333333),
|
||||||
|
('Aptos', 80, 73.3333333333),
|
||||||
|
('Segoe UI', 24, 24.0),
|
||||||
|
):
|
||||||
|
with self.subTest(family=family):
|
||||||
|
slide = self._export(
|
||||||
|
f'<text x="100" y="200" font-size="{size}" '
|
||||||
|
f'font-family="{family}">Hgpq</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 200 - expected_offset)
|
||||||
|
|
||||||
|
def test_half_point_baseline_rounds_up(self) -> None:
|
||||||
|
# The rendered 64 px SimSun sample resolves a 49.5 pt offset to 50 pt.
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="64" font-family="SimSun">Hgpq</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 133.3333333333)
|
||||||
|
|
||||||
|
def test_family_alias_and_unknown_family_have_deterministic_positions(self) -> None:
|
||||||
|
for family in ('微软雅黑', 'Noto Sans SC', 'Unlisted Baseline Test Font'):
|
||||||
|
with self.subTest(family=family):
|
||||||
|
slide = self._export(
|
||||||
|
f'<text x="100" y="200" font-size="40" '
|
||||||
|
f'font-family="{family}">Hgpq</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 161.3333333333)
|
||||||
|
|
||||||
|
def test_larger_font_size_controls_mixed_size_line_in_either_order(self) -> None:
|
||||||
|
for sizes, expected_top in (
|
||||||
|
((20, 40), 161.3333333333),
|
||||||
|
((40, 20), 161.3333333333),
|
||||||
|
((24, 64), 138.6666666667),
|
||||||
|
((64, 24), 138.6666666667),
|
||||||
|
):
|
||||||
|
with self.subTest(sizes=sizes):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="20" font-family="Microsoft YaHei">'
|
||||||
|
f'<tspan font-size="{sizes[0]}">Hg</tspan>'
|
||||||
|
f'<tspan font-size="{sizes[1]}">pq</tspan></text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, expected_top)
|
||||||
|
self.assertEqual(len(shape.findall('.//a:r', NS)), 2)
|
||||||
|
|
||||||
|
def test_mixed_scripts_use_both_active_font_faces(self) -> None:
|
||||||
|
for text in ('Hg中文pq', '中文Hgpq'):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" '
|
||||||
|
f'font-family="Calibri, SimSun">{text}</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 161.3333333333)
|
||||||
|
|
||||||
|
def test_unused_east_asian_font_does_not_affect_latin_baseline(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" '
|
||||||
|
'font-family="Calibri, SimSun">Hgpq</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 162.6666666667)
|
||||||
|
|
||||||
|
def test_mixed_font_runs_share_position_regardless_of_order(self) -> None:
|
||||||
|
runs = (
|
||||||
|
'<tspan font-family="Calibri">Hg</tspan>',
|
||||||
|
'<tspan font-family="SimSun">中文</tspan>',
|
||||||
|
)
|
||||||
|
for ordered_runs in (runs, runs[::-1]):
|
||||||
|
with self.subTest(runs=ordered_runs):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40">'
|
||||||
|
+ ''.join(ordered_runs) + '</text>'
|
||||||
|
)
|
||||||
|
self._assert_top(self._single_shape(slide), 161.3333333333)
|
||||||
|
|
||||||
|
def test_preserved_lines_follow_fixed_spacing_baseline_transition(self) -> None:
|
||||||
|
samples = (
|
||||||
|
(40, '45.3333333333333', '3400', 164.0),
|
||||||
|
(40, '48', '3600', 161.3333333333),
|
||||||
|
(40, '49.3333333333333', '3700', 162.6666666667),
|
||||||
|
# PowerPoint rounds 57.6 pt fixed spacing to 58 pt before choosing
|
||||||
|
# the baseline branch, while the stored OOXML retains 5760.
|
||||||
|
(64, '76.8', '5760', 141.3333333333),
|
||||||
|
)
|
||||||
|
for size, dy, spacing, expected_top in samples:
|
||||||
|
with self.subTest(size=size, dy=dy):
|
||||||
|
slide = self._export(
|
||||||
|
f'<text x="100" y="200" font-size="{size}" font-family="Microsoft YaHei">'
|
||||||
|
'<tspan x="100" dy="0">第一行</tspan>'
|
||||||
|
f'<tspan x="100" dy="{dy}">第二行</tspan></text>',
|
||||||
|
text_flow='preserve',
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, expected_top)
|
||||||
|
self.assertEqual(
|
||||||
|
[node.get('val') for node in shape.findall('.//a:lnSpc/a:spcPts', NS)],
|
||||||
|
[spacing],
|
||||||
|
)
|
||||||
|
self.assertEqual(len(shape.findall('.//a:br', NS)), 1)
|
||||||
|
break_properties = shape.find('.//a:br/a:rPr', NS)
|
||||||
|
self.assertIsNotNone(break_properties)
|
||||||
|
self.assertEqual(break_properties.get('sz'), '4800' if size == 64 else '3000')
|
||||||
|
self.assertEqual(
|
||||||
|
break_properties.find('a:latin', NS).get('typeface'), 'Microsoft YaHei',
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
break_properties.find('a:ea', NS).get('typeface'), 'Microsoft YaHei',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_split_lines_each_use_their_own_default_spacing_baseline(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
'<tspan x="100" dy="0">第一行</tspan>'
|
||||||
|
'<tspan x="100" dy="48">第二行</tspan></text>'
|
||||||
|
)
|
||||||
|
shapes = slide.findall('.//p:sp', NS)
|
||||||
|
self.assertEqual(len(shapes), 2)
|
||||||
|
for shape, expected_top in zip(shapes, (161.3333333333, 209.3333333333)):
|
||||||
|
self._assert_top(shape, expected_top)
|
||||||
|
self.assertIsNone(shape.find('.//a:lnSpc', NS))
|
||||||
|
|
||||||
|
def test_larger_later_line_does_not_move_first_visual_line(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
'<tspan x="100" dy="0">第一行</tspan>'
|
||||||
|
'<tspan x="100" dy="48" font-size="64">第二行</tspan></text>',
|
||||||
|
text_flow='preserve',
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 161.3333333333)
|
||||||
|
self.assertEqual(
|
||||||
|
[run.get('sz') for run in shape.findall('.//a:rPr', NS)],
|
||||||
|
['3000', '4800'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[spacing.get('val') for spacing in shape.findall('.//a:lnSpc/a:spcPts', NS)],
|
||||||
|
['3600', '3600'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_relative_group_font_size_is_resolved_once_for_baseline(self) -> None:
|
||||||
|
# Text-bearing group transforms reject scale; inherited em sizes are
|
||||||
|
# the supported public converter path for this effective-size check.
|
||||||
|
slide = self._export(
|
||||||
|
'<g font-size="20">'
|
||||||
|
'<text x="100" y="200" font-size="2em" font-family="Microsoft YaHei">'
|
||||||
|
'Hgpq</text></g>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 161.3333333333)
|
||||||
|
self.assertEqual(shape.find('.//a:rPr', NS).get('sz'), '3000')
|
||||||
|
|
||||||
|
def test_extracted_bullet_size_does_not_move_the_text_baseline(self) -> None:
|
||||||
|
for marker_size in (40, 80):
|
||||||
|
with self.subTest(marker_size=marker_size):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Calibri">'
|
||||||
|
'<tspan x="100" dy="0">'
|
||||||
|
f'<tspan font-size="{marker_size}">• </tspan>First</tspan>'
|
||||||
|
'<tspan x="100" dy="48">Second</tspan></text>',
|
||||||
|
text_flow='preserve',
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 162.6666666667)
|
||||||
|
paragraph = shape.find('.//a:pPr', NS)
|
||||||
|
self.assertIsNotNone(paragraph.find('a:buSzTx', NS))
|
||||||
|
self.assertIsNotNone(paragraph.find('a:buFontTx', NS))
|
||||||
|
self.assertEqual(paragraph.find('a:buChar', NS).get('char'), '•')
|
||||||
|
self.assertEqual(
|
||||||
|
[run.get('sz') for run in shape.findall('.//a:r/a:rPr', NS)],
|
||||||
|
['3000', '3000'],
|
||||||
|
)
|
||||||
|
self.assertEqual(shape.find('.//a:br/a:rPr', NS).get('sz'), '3000')
|
||||||
|
self.assertEqual(
|
||||||
|
[text.text for text in shape.findall('.//a:t', NS)],
|
||||||
|
['First', 'Second'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_bold_italic_metrics_fall_back_to_the_bold_face(self) -> None:
|
||||||
|
# Yu Gothic bundles regular and bold, but no italic metrics; synthetic
|
||||||
|
# italic must preserve the selected weight's baseline rather than reset it.
|
||||||
|
offsets = {}
|
||||||
|
for weight, style in (('400', 'normal'), ('bold', 'normal'), ('bold', 'italic')):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="140" font-family="Yu Gothic" '
|
||||||
|
f'font-weight="{weight}" font-style="{style}">Hgpq</text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
offsets[weight, style] = shape.find('p:spPr/a:xfrm/a:off', NS).get('y')
|
||||||
|
run = shape.find('.//a:rPr', NS)
|
||||||
|
self.assertEqual(run.get('b'), '1' if weight == 'bold' else None)
|
||||||
|
self.assertEqual(run.get('i'), '1' if style == 'italic' else None)
|
||||||
|
self.assertNotEqual(offsets['400', 'normal'], offsets['bold', 'normal'])
|
||||||
|
self.assertEqual(offsets['bold', 'normal'], offsets['bold', 'italic'])
|
||||||
|
|
||||||
|
def test_mixed_size_frame_contains_the_larger_fonts_line_height(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="20" font-family="Microsoft YaHei">'
|
||||||
|
'<tspan font-size="20">Hg</tspan><tspan font-size="64">pq</tspan></text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 138.6666666667)
|
||||||
|
height_px = int(shape.find('p:spPr/a:xfrm/a:ext', NS).get('cy')) / EMU_PER_PX
|
||||||
|
# A 64 px run needs its 76.8 px natural line, even when its parent is 20 px.
|
||||||
|
self.assertGreaterEqual(height_px, 76.8)
|
||||||
|
self.assertEqual(
|
||||||
|
[run.get('sz') for run in shape.findall('.//a:rPr', NS)],
|
||||||
|
['1500', '4800'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_super_and_subscript_keep_existing_shifted_run_positioning(self) -> None:
|
||||||
|
for shift, drawingml_shift in (('super', '30000'), ('sub', '-25000')):
|
||||||
|
with self.subTest(shift=shift):
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
f'Hg<tspan baseline-shift="{shift}">2</tspan>pq</text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 166)
|
||||||
|
self.assertEqual(
|
||||||
|
[run.get('baseline') for run in shape.findall('.//a:rPr', NS)
|
||||||
|
if run.get('baseline') is not None],
|
||||||
|
[drawingml_shift],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_imported_frame_preserves_frame_and_inset_reconstruction(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei" '
|
||||||
|
'data-pptx-frame="80 100 500 200">Hgpq</text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 100)
|
||||||
|
self.assertEqual(
|
||||||
|
shape.find('p:spPr/a:xfrm/a:ext', NS).attrib,
|
||||||
|
{'cx': '4762500', 'cy': '1905000'},
|
||||||
|
)
|
||||||
|
self.assertEqual(shape.find('p:txBody/a:bodyPr', NS).get('tIns'), '628650')
|
||||||
|
|
||||||
|
def test_inline_formula_retains_native_math_positioning(self) -> None:
|
||||||
|
slide = self._export(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
'Hg<tspan data-pptx-inline-formula="x">x</tspan>pq</text>'
|
||||||
|
)
|
||||||
|
shape = self._single_shape(slide)
|
||||||
|
self._assert_top(shape, 166)
|
||||||
|
self.assertIsNotNone(shape.find('.//m:oMath', NS))
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Imported Text Baseline Metadata Regression Tests
|
||||||
|
|
||||||
|
Keep the imported baseline convention through text flattening and the
|
||||||
|
authoring projection used when rebuilding native shapes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_text_baseline_metadata
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Run this module from the scripts directory.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the local SVG authoring tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from svg_authoring_view import project_svg_batch # noqa: E402
|
||||||
|
from svg_finalize.flatten_tspan import flatten_text_with_tspans # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
SVG = "http://www.w3.org/2000/svg"
|
||||||
|
BASELINE_ATTR = "data-pptx-text-baseline"
|
||||||
|
|
||||||
|
|
||||||
|
class TextBaselineMetadataTests(unittest.TestCase):
|
||||||
|
def test_split_and_preserved_rows_keep_the_imported_baseline_version(self) -> None:
|
||||||
|
for merge_paragraphs in (False, True):
|
||||||
|
with self.subTest(merge_paragraphs=merge_paragraphs):
|
||||||
|
tree = ET.ElementTree(ET.fromstring(
|
||||||
|
f'<svg xmlns="{SVG}" viewBox="0 0 1280 720">'
|
||||||
|
'<text x="100" y="200" font-family="Calibri" font-size="40" '
|
||||||
|
f'{BASELINE_ATTR}="metrics-v1">'
|
||||||
|
'<tspan x="100" dy="0">First</tspan>'
|
||||||
|
'<tspan x="100" dy="48">Second</tspan></text></svg>'
|
||||||
|
))
|
||||||
|
flatten_text_with_tspans(
|
||||||
|
tree,
|
||||||
|
merge_paragraphs=merge_paragraphs,
|
||||||
|
preserve_line_breaks=merge_paragraphs,
|
||||||
|
)
|
||||||
|
texts = list(tree.getroot().iter(f"{{{SVG}}}text"))
|
||||||
|
self.assertEqual(len(texts), 1 if merge_paragraphs else 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[text.get(BASELINE_ATTR) for text in texts],
|
||||||
|
["metrics-v1"] * len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_split_legacy_rows_do_not_acquire_a_new_baseline_version(self) -> None:
|
||||||
|
tree = ET.ElementTree(ET.fromstring(
|
||||||
|
f'<svg xmlns="{SVG}" viewBox="0 0 1280 720">'
|
||||||
|
'<text x="100" y="200" font-size="40">'
|
||||||
|
'<tspan x="100" dy="0">First</tspan>'
|
||||||
|
'<tspan x="100" dy="48">Second</tspan></text></svg>'
|
||||||
|
))
|
||||||
|
flatten_text_with_tspans(tree)
|
||||||
|
texts = list(tree.getroot().iter(f"{{{SVG}}}text"))
|
||||||
|
self.assertEqual(len(texts), 2)
|
||||||
|
self.assertTrue(all(text.get(BASELINE_ATTR) is None for text in texts))
|
||||||
|
|
||||||
|
def test_semantic_projection_keeps_the_first_paragraphs_baseline_version(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "source.svg"
|
||||||
|
output_dir = root / "authoring"
|
||||||
|
output = output_dir / "source.svg"
|
||||||
|
source.write_text(
|
||||||
|
f'<svg xmlns="{SVG}" viewBox="0 0 1280 720">'
|
||||||
|
'<g id="shape-1" data-pptx-object="shape" data-pptx-prst="rect" '
|
||||||
|
'data-pptx-frame="80 100 500 200">'
|
||||||
|
'<rect data-pptx-part="geometry" data-pptx-object="shape" '
|
||||||
|
'data-pptx-prst="rect" data-pptx-frame="80 100 500 200" '
|
||||||
|
'x="80" y="100" width="500" height="200" fill="none"/>'
|
||||||
|
'<text x="100" y="140" font-family="Calibri" font-size="20" '
|
||||||
|
f'{BASELINE_ATTR}="metrics-v1">First</text>'
|
||||||
|
'<text x="100" y="180" font-family="Calibri" font-size="20" '
|
||||||
|
f'{BASELINE_ATTR}="metrics-v1">Second</text></g></svg>',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
project_svg_batch(
|
||||||
|
[(source, output)], root, output_dir,
|
||||||
|
force=False, projection_kind="flat",
|
||||||
|
)
|
||||||
|
projected = ET.parse(output).getroot()
|
||||||
|
texts = list(projected.iter(f"{{{SVG}}}text"))
|
||||||
|
self.assertEqual(len(texts), 1)
|
||||||
|
self.assertEqual(texts[0].get(BASELINE_ATTR), "metrics-v1")
|
||||||
|
self.assertEqual(texts[0].get("data-pptx-text-model"), "paragraphs")
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Text Baseline Round-trip Regression Tests
|
||||||
|
|
||||||
|
Exercise the public DrawingML importer and SVG exporter with self-contained
|
||||||
|
text fixtures, including edited native shapes with preserved frames.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_text_baseline_roundtrip
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Run the module from the scripts directory.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the local PPT Master conversion modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from authoring_roundtrip import materialize_flat_authoring_roundtrip # noqa: E402
|
||||||
|
from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg # noqa: E402
|
||||||
|
from pptx_to_svg.emu_units import Xfrm # noqa: E402
|
||||||
|
from pptx_to_svg.txbody_to_svg import convert_txbody # noqa: E402
|
||||||
|
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
NS = {
|
||||||
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||||
|
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||||
|
's': 'http://www.w3.org/2000/svg',
|
||||||
|
}
|
||||||
|
EMU_PER_PX = 9525
|
||||||
|
|
||||||
|
|
||||||
|
def _run(text: str, family: str = 'Microsoft YaHei', size_hpt: int = 3000) -> str:
|
||||||
|
return (
|
||||||
|
f'<a:r><a:rPr sz="{size_hpt}"><a:latin typeface="{family}"/>'
|
||||||
|
f'<a:ea typeface="{family}"/><a:cs typeface="{family}"/>'
|
||||||
|
f'</a:rPr><a:t>{escape(text)}</a:t></a:r>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _txbody(
|
||||||
|
runs: str,
|
||||||
|
*,
|
||||||
|
spacing_hpt: int | None = None,
|
||||||
|
spacing_pct: int | None = None,
|
||||||
|
top_inset: int = 0,
|
||||||
|
) -> ET.Element:
|
||||||
|
spacing = (
|
||||||
|
f'<a:lnSpc><a:spcPts val="{spacing_hpt}"/></a:lnSpc>'
|
||||||
|
if spacing_hpt is not None else ''
|
||||||
|
)
|
||||||
|
if spacing_pct is not None:
|
||||||
|
spacing = f'<a:lnSpc><a:spcPct val="{spacing_pct}"/></a:lnSpc>'
|
||||||
|
return ET.fromstring(
|
||||||
|
f'<p:txBody xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
|
||||||
|
f'<a:bodyPr anchor="t" wrap="none" lIns="0" tIns="{top_inset}" rIns="0" bIns="0"/>'
|
||||||
|
f'<a:lstStyle/><a:p><a:pPr>{spacing}</a:pPr>{runs}</a:p></p:txBody>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TextBaselineRoundtripTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
self.svg_path = self.root / '01_source.svg'
|
||||||
|
|
||||||
|
def _write_svg(self, body: str) -> None:
|
||||||
|
self.svg_path.write_text(
|
||||||
|
f'<svg xmlns="{NS["s"]}" viewBox="0 0 1280 720">{body}</svg>',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _export(self, body: str, *, text_flow: str = 'split') -> ET.Element:
|
||||||
|
self._write_svg(body)
|
||||||
|
xml, *_rest = convert_svg_to_slide_shapes(
|
||||||
|
self.svg_path, resource_root=self.root, text_flow=text_flow,
|
||||||
|
)
|
||||||
|
return ET.fromstring(xml)
|
||||||
|
|
||||||
|
def _text_shape(self, slide: ET.Element) -> ET.Element:
|
||||||
|
shapes = [shape for shape in slide.findall('.//p:sp', NS)
|
||||||
|
if shape.find('p:txBody', NS) is not None]
|
||||||
|
self.assertEqual(len(shapes), 1)
|
||||||
|
return shapes[0]
|
||||||
|
|
||||||
|
def _import(self, body: ET.Element, frame: Xfrm | None = None) -> ET.Element:
|
||||||
|
result = convert_txbody(
|
||||||
|
body, frame or Xfrm(x=80, y=100, w=600, h=300), None,
|
||||||
|
)
|
||||||
|
root = ET.fromstring(f'<svg xmlns="{NS["s"]}">{result.svg}</svg>')
|
||||||
|
self.assertTrue(root.findall('s:text', NS))
|
||||||
|
return root
|
||||||
|
|
||||||
|
def _import_shape(self, shape: ET.Element) -> ET.Element:
|
||||||
|
offset = shape.find('p:spPr/a:xfrm/a:off', NS)
|
||||||
|
extent = shape.find('p:spPr/a:xfrm/a:ext', NS)
|
||||||
|
frame = Xfrm(
|
||||||
|
x=int(offset.get('x')) / EMU_PER_PX,
|
||||||
|
y=int(offset.get('y')) / EMU_PER_PX,
|
||||||
|
w=int(extent.get('cx')) / EMU_PER_PX,
|
||||||
|
h=int(extent.get('cy')) / EMU_PER_PX,
|
||||||
|
)
|
||||||
|
return self._import(shape.find('p:txBody', NS), frame)
|
||||||
|
|
||||||
|
def _assert_baseline(self, imported: ET.Element, expected: float) -> ET.Element:
|
||||||
|
text = imported.find('s:text', NS)
|
||||||
|
self.assertAlmostEqual(float(text.get('y')), expected, delta=0.000001)
|
||||||
|
self.assertEqual(text.get('data-pptx-text-baseline'), 'metrics-v1')
|
||||||
|
return text
|
||||||
|
|
||||||
|
def test_native_single_lines_use_their_fonts_baseline_and_top_inset(self) -> None:
|
||||||
|
for family, size_hpt, offset in (
|
||||||
|
('Arial', 3000, 38.6666666667),
|
||||||
|
('Calibri', 3000, 37.3333333333),
|
||||||
|
('SimSun', 3000, 41.3333333333),
|
||||||
|
('SimHei', 3000, 41.3333333333),
|
||||||
|
('Microsoft YaHei', 3000, 38.6666666667),
|
||||||
|
('Segoe UI', 1800, 24.0),
|
||||||
|
):
|
||||||
|
with self.subTest(family=family):
|
||||||
|
body = _txbody(_run('Hgpq', family, size_hpt), top_inset=7 * EMU_PER_PX)
|
||||||
|
self._assert_baseline(self._import(body), 107 + offset)
|
||||||
|
|
||||||
|
def test_native_fixed_spacing_uses_the_first_visual_lines_size(self) -> None:
|
||||||
|
samples = (
|
||||||
|
(3000, 3400, 36.0, 45.3333333333),
|
||||||
|
(3000, 3600, 38.6666666667, 48.0),
|
||||||
|
(3000, 3700, 37.3333333333, 49.3333333333),
|
||||||
|
(4800, 5760, 58.6666666667, 76.8),
|
||||||
|
)
|
||||||
|
for size_hpt, spacing, offset, advance in samples:
|
||||||
|
with self.subTest(size=size_hpt, spacing=spacing):
|
||||||
|
runs = (
|
||||||
|
_run('First', size_hpt=size_hpt)
|
||||||
|
+ f'<a:br><a:rPr sz="{size_hpt}"/></a:br>'
|
||||||
|
+ _run('Second', size_hpt=4800)
|
||||||
|
)
|
||||||
|
text = self._assert_baseline(
|
||||||
|
self._import(_txbody(runs, spacing_hpt=spacing)), 100 + offset,
|
||||||
|
)
|
||||||
|
advances = [float(span.get('dy')) for span in text.iter(f'{{{NS["s"]}}}tspan')
|
||||||
|
if span.get('dy') is not None]
|
||||||
|
self.assertEqual(len(advances), 1)
|
||||||
|
self.assertAlmostEqual(advances[0], advance, delta=0.000001)
|
||||||
|
|
||||||
|
def test_native_mixed_fonts_and_sizes_share_the_entire_lines_baseline(self) -> None:
|
||||||
|
samples = (
|
||||||
|
_run('Hg', 'Calibri') + _run('中文', 'SimSun'),
|
||||||
|
_run('中文', 'SimSun') + _run('Hg', 'Calibri'),
|
||||||
|
_run('Hg', size_hpt=1500) + _run('pq', size_hpt=3000),
|
||||||
|
_run('Hg', size_hpt=3000) + _run('pq', size_hpt=1500),
|
||||||
|
)
|
||||||
|
for runs in samples:
|
||||||
|
with self.subTest(runs=runs):
|
||||||
|
self._assert_baseline(self._import(_txbody(runs)), 138.6666666667)
|
||||||
|
|
||||||
|
def test_percentage_spacing_remains_relative_to_the_natural_line_height(self) -> None:
|
||||||
|
samples = (
|
||||||
|
('Microsoft YaHei', 3000, 50000, 18.6666666667, 24.0),
|
||||||
|
('Arial', 3000, 100000, 38.6666666667, 48.0),
|
||||||
|
('Calibri', 3000, 100000, 37.3333333333, 48.0),
|
||||||
|
('SimSun', 3000, 100000, 41.3333333333, 48.0),
|
||||||
|
('Microsoft YaHei', 3000, 100000, 38.6666666667, 48.0),
|
||||||
|
('Microsoft YaHei', 3000, 150000, 54.6666666667, 72.0),
|
||||||
|
# 100% retains the natural 57.6 pt line height. Unlike spcPts=5760,
|
||||||
|
# it does not first snap to 58 pt and choose the fixed-spacing branch.
|
||||||
|
('Microsoft YaHei', 4800, 100000, 61.3333333333, 76.8),
|
||||||
|
)
|
||||||
|
for family, size, percent, offset, advance in samples:
|
||||||
|
with self.subTest(family=family, size=size, percent=percent):
|
||||||
|
runs = (
|
||||||
|
_run('First', family, size)
|
||||||
|
+ f'<a:br><a:rPr sz="{size}"/></a:br>'
|
||||||
|
+ _run('Second', family, size)
|
||||||
|
)
|
||||||
|
text = self._assert_baseline(
|
||||||
|
self._import(_txbody(runs, spacing_pct=percent)), 100 + offset,
|
||||||
|
)
|
||||||
|
spans = [span for span in text.iter(f'{{{NS["s"]}}}tspan')
|
||||||
|
if span.get('dy') is not None]
|
||||||
|
self.assertEqual(len(spans), 1)
|
||||||
|
self.assertAlmostEqual(float(spans[0].get('dy')), advance, delta=0.000001)
|
||||||
|
|
||||||
|
def test_local_line_spacing_choice_overrides_the_inherited_choice(self) -> None:
|
||||||
|
samples = (
|
||||||
|
('spcPct', 100000, 'spcPts', 2000, 138.6666666667, 48.0),
|
||||||
|
('spcPts', 3400, 'spcPct', 150000, 136.0, 45.3333333333),
|
||||||
|
('spcPts', 0, 'spcPct', 150000, 100.0, 0.0),
|
||||||
|
)
|
||||||
|
for local_kind, local_value, inherited_kind, inherited_value, baseline, advance in samples:
|
||||||
|
with self.subTest(local_kind=local_kind, local_value=local_value):
|
||||||
|
runs = _run('First') + '<a:br><a:rPr sz="3000"/></a:br>' + _run('Second')
|
||||||
|
body = _txbody(runs)
|
||||||
|
body.find('a:p/a:pPr', NS).append(ET.fromstring(
|
||||||
|
f'<a:lnSpc xmlns:a="{NS["a"]}">'
|
||||||
|
f'<a:{local_kind} val="{local_value}"/></a:lnSpc>'
|
||||||
|
))
|
||||||
|
body.find('a:lstStyle', NS).append(ET.fromstring(
|
||||||
|
f'<a:lvl1pPr xmlns:a="{NS["a"]}"><a:lnSpc>'
|
||||||
|
f'<a:{inherited_kind} val="{inherited_value}"/>'
|
||||||
|
'</a:lnSpc></a:lvl1pPr>'
|
||||||
|
))
|
||||||
|
text = self._assert_baseline(self._import(body), baseline)
|
||||||
|
spans = [span for span in text.iter(f'{{{NS["s"]}}}tspan')
|
||||||
|
if span.get('dy') is not None]
|
||||||
|
self.assertEqual(len(spans), 1)
|
||||||
|
self.assertAlmostEqual(float(spans[0].get('dy')), advance, delta=0.000001)
|
||||||
|
|
||||||
|
def test_later_shifted_paragraph_keeps_the_whole_body_on_legacy_baselines(self) -> None:
|
||||||
|
body = _txbody(_run('First'))
|
||||||
|
shifted_paragraph = ET.fromstring(f'<a:p xmlns:a="{NS["a"]}">{_run("Raised")}</a:p>')
|
||||||
|
shifted_paragraph.find('a:r/a:rPr', NS).set('baseline', '30000')
|
||||||
|
body.append(shifted_paragraph)
|
||||||
|
imported = self._import(body)
|
||||||
|
texts = imported.findall('s:text', NS)
|
||||||
|
self.assertEqual(len(texts), 2)
|
||||||
|
self.assertAlmostEqual(float(texts[0].get('y')), 134.0)
|
||||||
|
self.assertTrue(all(text.get('data-pptx-text-baseline') is None for text in texts))
|
||||||
|
|
||||||
|
def test_svg_to_native_to_svg_restores_the_authored_first_baseline(self) -> None:
|
||||||
|
samples = [
|
||||||
|
(f'<text x="100" y="200" font-family="{family}" font-size="40">Hgpq</text>', 'split')
|
||||||
|
for family in ('Arial', 'Calibri', 'SimSun', 'SimHei', 'Microsoft YaHei')
|
||||||
|
]
|
||||||
|
samples.extend((
|
||||||
|
('<text x="100" y="200" font-family="Calibri, SimSun" font-size="40">'
|
||||||
|
'Hg中文pq</text>', 'split'),
|
||||||
|
('<text x="100" y="200" font-family="Microsoft YaHei" font-size="20">'
|
||||||
|
'<tspan>Hg</tspan><tspan font-size="40">pq</tspan></text>', 'split'),
|
||||||
|
('<text x="100" y="200" font-family="Microsoft YaHei" font-size="40">'
|
||||||
|
'<tspan x="100" dy="0">First</tspan>'
|
||||||
|
'<tspan x="100" dy="45.3333333333333">Second</tspan></text>', 'preserve'),
|
||||||
|
('<text x="100" y="200" font-family="Microsoft YaHei" font-size="64">'
|
||||||
|
'<tspan x="100" dy="0">First</tspan>'
|
||||||
|
'<tspan x="100" dy="76.8">Second</tspan></text>', 'preserve'),
|
||||||
|
))
|
||||||
|
for svg, flow in samples:
|
||||||
|
with self.subTest(svg=svg):
|
||||||
|
shape = self._text_shape(self._export(svg, text_flow=flow))
|
||||||
|
self._assert_baseline(self._import_shape(shape), 200)
|
||||||
|
|
||||||
|
def test_imported_text_with_exact_frame_reconstructs_insets_without_drift(self) -> None:
|
||||||
|
body = _txbody(_run('Hgpq'), top_inset=66 * EMU_PER_PX)
|
||||||
|
frame = Xfrm(x=80, y=100, w=500, h=200)
|
||||||
|
imported = self._import(body, frame)
|
||||||
|
for edited_text in ('Edited', 'Again'):
|
||||||
|
text = imported.find('s:text', NS)
|
||||||
|
self.assertEqual(text.get('data-pptx-text-baseline'), 'metrics-v1')
|
||||||
|
text.set('data-pptx-frame', '80 100 500 200')
|
||||||
|
text.find('s:tspan', NS).text = edited_text
|
||||||
|
shape = self._text_shape(self._export(ET.tostring(text, encoding='unicode')))
|
||||||
|
self.assertEqual(
|
||||||
|
shape.find('p:spPr/a:xfrm/a:off', NS).attrib,
|
||||||
|
{'x': '762000', 'y': '952500'},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
shape.find('p:spPr/a:xfrm/a:ext', NS).attrib,
|
||||||
|
{'cx': '4762500', 'cy': '1905000'},
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
int(shape.find('p:txBody/a:bodyPr', NS).get('tIns')),
|
||||||
|
628650, delta=1,
|
||||||
|
)
|
||||||
|
imported = self._import_shape(shape)
|
||||||
|
|
||||||
|
def test_legacy_unmarked_svg_keeps_its_existing_exact_frame_insets(self) -> None:
|
||||||
|
shape = self._text_shape(self._export(
|
||||||
|
'<text x="100" y="200" font-family="Microsoft YaHei" font-size="40" '
|
||||||
|
'data-pptx-frame="80 100 500 200">Legacy</text>'
|
||||||
|
))
|
||||||
|
self.assertEqual(shape.find('p:spPr/a:xfrm/a:off', NS).get('y'), '952500')
|
||||||
|
self.assertEqual(shape.find('p:txBody/a:bodyPr', NS).get('tIns'), '628650')
|
||||||
|
|
||||||
|
def test_real_imported_workspace_preserves_frame_after_text_edit(self) -> None:
|
||||||
|
self._write_svg(
|
||||||
|
'<text x="100" y="200" font-family="Microsoft YaHei" font-size="40" '
|
||||||
|
'data-pptx-frame="80 100 500 200">Hgpq</text>'
|
||||||
|
)
|
||||||
|
source = self.root / 'source.pptx'
|
||||||
|
workspace = self.root / 'workspace'
|
||||||
|
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
||||||
|
success = create_pptx_with_native_svg(
|
||||||
|
[self.svg_path], source, resource_root=self.root, pptx_structure='flat',
|
||||||
|
use_compat_mode=False, transition=None, workers=1, verbose=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(success)
|
||||||
|
convert_pptx_to_svg(
|
||||||
|
source, workspace, ConvertOptions(inheritance_mode='both', roundtrip=True),
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(source) as package:
|
||||||
|
original = self._text_shape(ET.fromstring(package.read('ppt/slides/slide1.xml')))
|
||||||
|
authoring_dir = workspace / 'authoring-svg-flat'
|
||||||
|
authored = authoring_dir / 'slide_01.svg'
|
||||||
|
document = ET.parse(authored)
|
||||||
|
text = document.getroot().find('.//s:text', NS)
|
||||||
|
self.assertIsNotNone(text)
|
||||||
|
self.assertEqual(text.get('data-pptx-text-baseline'), 'metrics-v1')
|
||||||
|
content = next(node for node in text.iter() if node.text == 'Hgpq')
|
||||||
|
content.text = 'Edited'
|
||||||
|
document.write(authored, encoding='utf-8', xml_declaration=True)
|
||||||
|
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
||||||
|
materialized = materialize_flat_authoring_roundtrip(
|
||||||
|
workspace, authoring_dir, workspace / 'materialized',
|
||||||
|
)
|
||||||
|
xml, *_rest = convert_svg_to_slide_shapes(
|
||||||
|
materialized.svg_files[0], resource_root=workspace, text_flow='preserve',
|
||||||
|
)
|
||||||
|
rebuilt = self._text_shape(ET.fromstring(xml))
|
||||||
|
self.assertEqual([node.text for node in rebuilt.findall('.//a:t', NS)], ['Edited'])
|
||||||
|
for child in ('a:off', 'a:ext'):
|
||||||
|
self.assertEqual(
|
||||||
|
rebuilt.find(f'p:spPr/a:xfrm/{child}', NS).attrib,
|
||||||
|
original.find(f'p:spPr/a:xfrm/{child}', NS).attrib,
|
||||||
|
)
|
||||||
|
for inset in ('lIns', 'tIns', 'rIns', 'bIns'):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
int(rebuilt.find('p:txBody/a:bodyPr', NS).get(inset)),
|
||||||
|
int(original.find('p:txBody/a:bodyPr', NS).get(inset)), delta=1,
|
||||||
|
)
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Text Overflow Import Regression Tests
|
||||||
|
|
||||||
|
Verify that native text overflow remains visible beyond short shape and table
|
||||||
|
cell frames, while explicit clipping retains the existing line truncation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_text_overflow_import
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Run the module from the scripts directory.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the local PPT Master importer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from pptx_to_svg.emu_units import Xfrm # noqa: E402
|
||||||
|
from pptx_to_svg.tbl_to_svg import convert_tbl # noqa: E402
|
||||||
|
from pptx_to_svg.txbody_to_svg import convert_txbody # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
||||||
|
P = 'http://schemas.openxmlformats.org/presentationml/2006/main'
|
||||||
|
SVG = 'http://www.w3.org/2000/svg'
|
||||||
|
NS = {'a': A, 's': SVG}
|
||||||
|
LINES = ('First', 'Second', 'Third')
|
||||||
|
|
||||||
|
|
||||||
|
def _text_body(
|
||||||
|
overflow: str | None,
|
||||||
|
*,
|
||||||
|
spacing_percent: int = 100000,
|
||||||
|
paragraphs: bool = False,
|
||||||
|
anchor: str = 't',
|
||||||
|
top_inset_px: int = 0,
|
||||||
|
bottom_inset_px: int = 0,
|
||||||
|
) -> ET.Element:
|
||||||
|
runs = [
|
||||||
|
'<a:r><a:rPr sz="2400"><a:latin typeface="Arial"/>'
|
||||||
|
f'<a:ea typeface="Arial"/></a:rPr><a:t>{text}</a:t></a:r>'
|
||||||
|
for text in LINES
|
||||||
|
]
|
||||||
|
paragraph_properties = (
|
||||||
|
f'<a:pPr><a:lnSpc><a:spcPct val="{spacing_percent}"/></a:lnSpc></a:pPr>'
|
||||||
|
)
|
||||||
|
if paragraphs:
|
||||||
|
content = ''.join(f'<a:p>{paragraph_properties}{run}</a:p>' for run in runs)
|
||||||
|
else:
|
||||||
|
line_break = '<a:br><a:rPr sz="2400"><a:latin typeface="Arial"/></a:rPr></a:br>'
|
||||||
|
content = f'<a:p>{paragraph_properties}{line_break.join(runs)}</a:p>'
|
||||||
|
overflow_attribute = f' vertOverflow="{overflow}"' if overflow is not None else ''
|
||||||
|
return ET.fromstring(
|
||||||
|
f'<p:txBody xmlns:p="{P}" xmlns:a="{A}">'
|
||||||
|
f'<a:bodyPr anchor="{anchor}" wrap="none" lIns="0" '
|
||||||
|
f'tIns="{top_inset_px * 9525}" rIns="0" bIns="{bottom_inset_px * 9525}"'
|
||||||
|
f'{overflow_attribute}/><a:lstStyle/>{content}</p:txBody>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_root(markup: str) -> ET.Element:
|
||||||
|
return ET.fromstring(f'<svg xmlns="{SVG}">{markup}</svg>')
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_text(root: ET.Element) -> str:
|
||||||
|
return ''.join(''.join(text.itertext()) for text in root.findall('.//s:text', NS))
|
||||||
|
|
||||||
|
|
||||||
|
class TextOverflowImportTests(unittest.TestCase):
|
||||||
|
def _import(self, body: ET.Element, *, height: float = 32) -> ET.Element:
|
||||||
|
result = convert_txbody(body, Xfrm(x=80, y=100, w=600, h=height), None)
|
||||||
|
return _svg_root(result.svg)
|
||||||
|
|
||||||
|
def _assert_anchor_shift(
|
||||||
|
self,
|
||||||
|
mode: str | None,
|
||||||
|
anchor: str,
|
||||||
|
paragraphs: bool,
|
||||||
|
expected_shift: float,
|
||||||
|
*,
|
||||||
|
height: float = 32,
|
||||||
|
top_inset_px: int = 0,
|
||||||
|
bottom_inset_px: int = 0,
|
||||||
|
) -> None:
|
||||||
|
options = {
|
||||||
|
'paragraphs': paragraphs,
|
||||||
|
'top_inset_px': top_inset_px,
|
||||||
|
'bottom_inset_px': bottom_inset_px,
|
||||||
|
}
|
||||||
|
top = self._import(_text_body(mode, **options), height=height)
|
||||||
|
anchored = self._import(_text_body(mode, anchor=anchor, **options), height=height)
|
||||||
|
self.assertEqual(_visible_text(anchored), 'FirstSecondThird')
|
||||||
|
top_texts = top.findall('s:text', NS)
|
||||||
|
anchored_texts = anchored.findall('s:text', NS)
|
||||||
|
self.assertEqual(len(anchored_texts), len(top_texts))
|
||||||
|
for original, shifted in zip(top_texts, anchored_texts):
|
||||||
|
self.assertAlmostEqual(float(shifted.get('y')) - float(original.get('y')), expected_shift)
|
||||||
|
self.assertEqual(
|
||||||
|
[span.get('dy') for span in anchored.findall('.//s:tspan', NS)],
|
||||||
|
[span.get('dy') for span in top.findall('.//s:tspan', NS)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_and_explicit_overflow_keep_all_soft_break_lines(self) -> None:
|
||||||
|
for mode in (None, 'overflow'):
|
||||||
|
for percent, advance in ((100000, 38.4), (150000, 57.6)):
|
||||||
|
with self.subTest(mode=mode, percent=percent):
|
||||||
|
imported = self._import(_text_body(mode, spacing_percent=percent))
|
||||||
|
self.assertEqual(_visible_text(imported), 'FirstSecondThird')
|
||||||
|
advances = [float(span.get('dy')) for span in imported.findall('.//s:tspan', NS)
|
||||||
|
if span.get('dy') is not None]
|
||||||
|
self.assertEqual(advances, [advance, advance])
|
||||||
|
|
||||||
|
def test_default_and_explicit_overflow_keep_paragraphs_below_the_frame(self) -> None:
|
||||||
|
for mode in (None, 'overflow'):
|
||||||
|
for percent, advance in ((100000, 38.4), (150000, 57.6)):
|
||||||
|
with self.subTest(mode=mode, percent=percent):
|
||||||
|
imported = self._import(
|
||||||
|
_text_body(mode, spacing_percent=percent, paragraphs=True),
|
||||||
|
)
|
||||||
|
texts = imported.findall('s:text', NS)
|
||||||
|
self.assertEqual([''.join(text.itertext()) for text in texts], list(LINES))
|
||||||
|
baselines = [float(text.get('y')) for text in texts]
|
||||||
|
self.assertAlmostEqual(baselines[1] - baselines[0], advance)
|
||||||
|
self.assertAlmostEqual(baselines[2] - baselines[1], advance)
|
||||||
|
self.assertGreater(baselines[-1], 132)
|
||||||
|
|
||||||
|
def test_explicit_clip_and_ellipsis_keep_existing_line_truncation(self) -> None:
|
||||||
|
for mode in ('clip', 'ellipsis'):
|
||||||
|
for paragraphs in (False, True):
|
||||||
|
for percent in (100000, 150000):
|
||||||
|
top = self._import(_text_body(
|
||||||
|
mode, spacing_percent=percent, paragraphs=paragraphs,
|
||||||
|
))
|
||||||
|
top_baseline = float(top.find('s:text', NS).get('y'))
|
||||||
|
for anchor in ('t', 'ctr', 'b'):
|
||||||
|
with self.subTest(mode=mode, paragraphs=paragraphs, percent=percent, anchor=anchor):
|
||||||
|
imported = self._import(_text_body(
|
||||||
|
mode, spacing_percent=percent, paragraphs=paragraphs, anchor=anchor,
|
||||||
|
))
|
||||||
|
# Ellipsis glyph synthesis is outside this importer fix.
|
||||||
|
self.assertEqual(_visible_text(imported), 'First')
|
||||||
|
texts = imported.findall('s:text', NS)
|
||||||
|
self.assertEqual(len(texts), 1)
|
||||||
|
self.assertEqual(float(texts[0].get('y')), top_baseline)
|
||||||
|
|
||||||
|
def test_overflow_keeps_center_and_bottom_alignment(self) -> None:
|
||||||
|
# Layout-model height: three 32px lines at 1.2 leading = 115.2px.
|
||||||
|
# These shifts test anchoring, not absolute native-render measurements.
|
||||||
|
for mode in (None, 'overflow'):
|
||||||
|
for paragraphs in (False, True):
|
||||||
|
for anchor, shift in (('ctr', -41.6), ('b', -83.2)):
|
||||||
|
with self.subTest(mode=mode, paragraphs=paragraphs, anchor=anchor):
|
||||||
|
self._assert_anchor_shift(mode, anchor, paragraphs, shift)
|
||||||
|
|
||||||
|
def test_nonoverflow_center_and_bottom_alignment_stays_within_frame(self) -> None:
|
||||||
|
for mode in (None, 'overflow'):
|
||||||
|
for paragraphs in (False, True):
|
||||||
|
for anchor, shift in (('ctr', 22.4), ('b', 44.8)):
|
||||||
|
with self.subTest(mode=mode, paragraphs=paragraphs, anchor=anchor):
|
||||||
|
self._assert_anchor_shift(mode, anchor, paragraphs, shift, height=160)
|
||||||
|
|
||||||
|
def test_overflow_alignment_uses_inner_frame_after_insets(self) -> None:
|
||||||
|
for mode in (None, 'overflow'):
|
||||||
|
for paragraphs in (False, True):
|
||||||
|
for anchor, shift in (('ctr', -51.6), ('b', -103.2)):
|
||||||
|
with self.subTest(mode=mode, paragraphs=paragraphs, anchor=anchor):
|
||||||
|
self._assert_anchor_shift(
|
||||||
|
mode, anchor, paragraphs, shift,
|
||||||
|
top_inset_px=8, bottom_inset_px=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_table_cells_follow_their_text_bodys_overflow_mode(self) -> None:
|
||||||
|
for mode, expected in ((None, 'FirstSecondThird'), ('overflow', 'FirstSecondThird'),
|
||||||
|
('clip', 'First'), ('ellipsis', 'First')):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
table = ET.fromstring(
|
||||||
|
f'<a:tbl xmlns:a="{A}"><a:tblPr/>'
|
||||||
|
'<a:tblGrid><a:gridCol w="5715000"/></a:tblGrid>'
|
||||||
|
'<a:tr h="304800"><a:tc>'
|
||||||
|
'<a:tcPr marL="0" marT="0" marR="0" marB="0" anchor="t"/>'
|
||||||
|
'</a:tc></a:tr></a:tbl>'
|
||||||
|
)
|
||||||
|
body = _text_body(mode)
|
||||||
|
body.tag = f'{{{A}}}txBody'
|
||||||
|
table.find('a:tr/a:tc', NS).insert(0, body)
|
||||||
|
result = convert_tbl(table, Xfrm(x=80, y=100, w=600, h=32), None)
|
||||||
|
self.assertEqual(_visible_text(_svg_root(result.svg)), expected)
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shadcn",
|
"sourceId": "shadcn",
|
||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "db2db460a26fa84fb65c8d903b213925fbdee9ed",
|
"commit": "d75a96ab781f3d659be1ad287347d5887ce9f2fc",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/shadcn",
|
"sourcePath": "skills/shadcn",
|
||||||
"syncedAt": "2026-09-28T16:00:01Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shuorenhua",
|
"name": "shuorenhua",
|
||||||
"version": "2.5.0",
|
"version": "2.5.1",
|
||||||
"description": "按用户要求审稿或去 AI 味,支持中英文,保留事实、关系与作者立场。",
|
"description": "按用户要求审稿或去 AI 味,支持中英文,保留事实、关系与作者立场。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "MrGeDiao"
|
"name": "MrGeDiao"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shuorenhua",
|
"sourceId": "shuorenhua",
|
||||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "9e6400d5e2cc050485c38fbf0dda682bce66c180",
|
"commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": ".",
|
"sourcePath": ".",
|
||||||
"syncedAt": "2026-09-27T16:00:00Z"
|
"syncedAt": "2026-10-01T16:00:00Z"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user