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",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
|
||||
"syncedAt": "2026-09-22T16:08:55Z"
|
||||
"commit": "f5d729488caa8f6a5b6c8086fe2cccd3e8a63f91",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "taste-skill",
|
||||
@@ -42,8 +42,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "db2db460a26fa84fb65c8d903b213925fbdee9ed",
|
||||
"syncedAt": "2026-09-28T16:00:01Z"
|
||||
"commit": "d75a96ab781f3d659be1ad287347d5887ce9f2fc",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -78,8 +78,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "680de11f1bef4628b68d5daad9dffec569fbd51f",
|
||||
"syncedAt": "2026-09-27T16:00:00Z"
|
||||
"commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "grill-me",
|
||||
@@ -130,10 +130,10 @@
|
||||
"id": "artemis-automation",
|
||||
"pluginName": "artemis-automation",
|
||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||
"ref": "v1.2.3",
|
||||
"ref": "v1.3.0",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "59f458205d7f5ec041a9dc11acc1122f128e827b",
|
||||
"syncedAt": "2026-09-29T16:00:00Z"
|
||||
"commit": "606ed941607c9e5482a4acd3f782d2b14acc9d06",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "embedded-app-pc-debug",
|
||||
@@ -168,8 +168,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "bc8bf60a350071dc192c454674eab3574db8b50c",
|
||||
"syncedAt": "2026-09-29T16:00:00Z"
|
||||
"commit": "60cb5b3efc1d65767d8f746dc12003fbd4f4bbcd",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "shuorenhua",
|
||||
@@ -177,8 +177,8 @@
|
||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "9e6400d5e2cc050485c38fbf0dda682bce66c180",
|
||||
"syncedAt": "2026-09-27T16:00:00Z"
|
||||
"commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c",
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-automation",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
||||
"author": {
|
||||
"name": "chenmingxuan",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"sourceId": "artemis-automation",
|
||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||
"ref": "v1.2.3",
|
||||
"commit": "59f458205d7f5ec041a9dc11acc1122f128e827b",
|
||||
"ref": "v1.3.0",
|
||||
"commit": "606ed941607c9e5482a4acd3f782d2b14acc9d06",
|
||||
"adapter": "claude-skill",
|
||||
"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` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||
- 用 `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。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||
@@ -27,10 +27,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
||||
## 配置和恢复边界
|
||||
|
||||
- `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`,不能静默降级或换模型。
|
||||
- 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`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||
|
||||
|
||||
+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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "caveman",
|
||||
"version": "2.7.0",
|
||||
"version": "3.0.0",
|
||||
"description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
|
||||
"author": {
|
||||
"name": "Julius Brussee",
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/JuliusBrussee/caveman",
|
||||
"repository": "https://github.com/JuliusBrussee/caveman",
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"productivity",
|
||||
"communication",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "caveman",
|
||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||
"ref": "main",
|
||||
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
|
||||
"commit": "f5d729488caa8f6a5b6c8086fe2cccd3e8a63f91",
|
||||
"adapter": "codex-plugin",
|
||||
"sourcePath": "plugins/caveman",
|
||||
"syncedAt": "2026-09-22T16:08:55Z"
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-09-29T16:02:00Z"
|
||||
"syncedAt": "2026-10-01T16:02:08Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "bc8bf60a350071dc192c454674eab3574db8b50c",
|
||||
"commit": "60cb5b3efc1d65767d8f746dc12003fbd4f4bbcd",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-09-29T16:00:00Z"
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "680de11f1bef4628b68d5daad9dffec569fbd51f",
|
||||
"commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
|
||||
"adapter": "claude-skill",
|
||||
"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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**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`
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -68,7 +68,7 @@ Missing material returns upstream, never invented or substituted.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+23
-11
@@ -791,6 +791,15 @@
|
||||
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
|
||||
// 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
|
||||
@@ -1534,7 +1543,7 @@
|
||||
label = label + (o.dim ? " · " + o.dim : "");
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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.
|
||||
refreshBodySizeHint = function () {
|
||||
var txt = t("font_body_size_hint");
|
||||
var sep = isCjkUi() ? "" : " ";
|
||||
var lo, hi;
|
||||
if (isPptCanvas(STATE.canvas)) {
|
||||
var pb = deliveryBodyPx(STATE.delivery_purpose);
|
||||
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 {
|
||||
var band = bodySizeBandForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||||
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);
|
||||
}
|
||||
// Flag (hint only) a value far outside the
|
||||
@@ -3322,7 +3332,7 @@
|
||||
? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur))
|
||||
: "";
|
||||
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;
|
||||
};
|
||||
@@ -3552,11 +3562,11 @@
|
||||
t("image_strategy_select_placeholder")));
|
||||
var parts = [];
|
||||
if (strategy.rendering) {
|
||||
parts.push(t("image_strategy_rendering") + ": " +
|
||||
parts.push(t("image_strategy_rendering") + labelSep() +
|
||||
comparisonValueLabel("rendering", strategy.rendering));
|
||||
}
|
||||
if (strategy.visual) parts.push(t("image_strategy_visual") + ": " + strategy.visual);
|
||||
if (strategy.mood) parts.push(t("image_strategy_mood") + ": " + strategy.mood);
|
||||
if (strategy.visual) parts.push(t("image_strategy_visual") + labelSep() + strategy.visual);
|
||||
if (strategy.mood) parts.push(t("image_strategy_mood") + labelSep() + strategy.mood);
|
||||
if (strategy.behavior) parts.push(strategy.behavior);
|
||||
desc.textContent = parts.join(" · ") || t("image_strategy_reference_hint");
|
||||
}
|
||||
@@ -3714,7 +3724,7 @@
|
||||
["image_strategy_visual", customStrategy.visual],
|
||||
["image_strategy_mood", customStrategy.mood]
|
||||
].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",
|
||||
customStrategy.behavior || t("image_strategy_custom_placeholder"));
|
||||
@@ -3812,7 +3822,7 @@
|
||||
(CAT.image_usage || []).forEach(function (option) {
|
||||
var label = optionLabel(option);
|
||||
var desc = optionDesc(option);
|
||||
if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc;
|
||||
if (desc) label += (isCjkUi() ? ":" : " — ") + desc;
|
||||
var chip = el("div", "chip");
|
||||
chip.appendChild(el("span", "chip-text", label));
|
||||
if (recommendedIds.indexOf(option.id) >= 0) {
|
||||
@@ -4407,6 +4417,7 @@
|
||||
document.getElementById("sections").style.display = "none";
|
||||
document.getElementById("actionbar").style.display = "none";
|
||||
var l = document.getElementById("loading");
|
||||
l.setAttribute("data-i18n", "deriving");
|
||||
l.textContent = t("deriving");
|
||||
l.style.display = "block";
|
||||
}
|
||||
@@ -4536,7 +4547,7 @@
|
||||
if (requested !== "zh" && requested !== "en" && requested !== "ja" &&
|
||||
requested !== "zh-TW") return;
|
||||
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;
|
||||
LANG = requested;
|
||||
applyStaticTranslations();
|
||||
@@ -4587,11 +4598,12 @@
|
||||
setMenuOpen(false);
|
||||
toggleBtn.focus();
|
||||
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;
|
||||
LANG = v;
|
||||
try { window.localStorage.setItem("ppt_lang", LANG); } catch (e2) { /* ignore */ }
|
||||
applyStaticTranslations();
|
||||
refreshLangToggle(toggleBtn);
|
||||
document.getElementById("confirm-status").textContent = "";
|
||||
if (REC && CAT) {
|
||||
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.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,
|
||||
then launches the combined page. In chat/delegated confirmation it authors the
|
||||
In the UI branch, the AI authors Stage 1 without reading candidate indexes or
|
||||
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
|
||||
the one Stage-1 confirmation, the AI installs any selection, completes the
|
||||
handoff/equivalent state, then inspects only the project-local template and
|
||||
|
||||
@@ -1067,6 +1067,39 @@ Requirements:
|
||||
|
||||
## 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
|
||||
the SVG quality checker.
|
||||
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 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 .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
|
||||
hyperlink_href: 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]
|
||||
@@ -90,6 +93,7 @@ class TextParagraph:
|
||||
indent_px: float = 0.0
|
||||
margin_left_px: float = 0.0
|
||||
line_height_ratio: float = DEFAULT_LINE_HEIGHT_RATIO
|
||||
line_spacing_px: float | None = None
|
||||
space_before_px: float = 0.0
|
||||
space_after_px: float = 0.0
|
||||
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"])
|
||||
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"
|
||||
# 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 = (
|
||||
body_pr is not None
|
||||
and body_pr.attrib.get("spcFirstLastPara") in {"1", "true"}
|
||||
@@ -197,15 +206,27 @@ def convert_txbody(
|
||||
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":
|
||||
cursor_y = inner_y + max(0.0, (inner_h - total_h) / 2.0)
|
||||
cursor_y = inner_y + anchor_space / 2.0
|
||||
elif anchor == "b":
|
||||
cursor_y = inner_y + max(0.0, inner_h - total_h)
|
||||
cursor_y = inner_y + anchor_space
|
||||
else:
|
||||
cursor_y = inner_y
|
||||
|
||||
bottom_y = inner_y + inner_h
|
||||
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(
|
||||
paragraphs,
|
||||
para_lines,
|
||||
@@ -214,13 +235,19 @@ def convert_txbody(
|
||||
space_after,
|
||||
):
|
||||
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:
|
||||
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
|
||||
if cursor_y >= bottom_y:
|
||||
if clip_vertical and cursor_y >= bottom_y:
|
||||
break
|
||||
|
||||
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.indent_px = _emu_px_attr_chain(para_style_chain, "indent", 0.0)
|
||||
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_after_px = _spacing_points_px(para_style_chain, "a:spcAft/a:spcPts")
|
||||
para.bullet_prefix = _resolve_bullet_prefix(
|
||||
@@ -788,6 +818,8 @@ def _apply_norm_autofit(
|
||||
for para in paragraphs:
|
||||
para.empty_line_font_size_px *= font_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:
|
||||
run.font_size_px *= font_scale
|
||||
|
||||
@@ -944,6 +976,11 @@ def _build_run(
|
||||
strikethrough=strikethrough,
|
||||
letter_spacing_px=letter_spacing_px,
|
||||
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:
|
||||
return DEFAULT_LINE_HEIGHT_RATIO
|
||||
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:
|
||||
return DEFAULT_LINE_HEIGHT_RATIO
|
||||
|
||||
@@ -1492,6 +1529,8 @@ def _copy_run(run: TextRun, *, text: str) -> TextRun:
|
||||
letter_spacing_px=run.letter_spacing_px,
|
||||
hyperlink_href=run.hyperlink_href,
|
||||
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:
|
||||
"""Total px height after wrapping. Each line uses its own max font size."""
|
||||
if not lines:
|
||||
return p.empty_line_font_size_px * p.line_height_ratio
|
||||
return _line_height(p, [])
|
||||
height = 0.0
|
||||
for line in lines:
|
||||
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:
|
||||
if p.line_spacing_px is not None:
|
||||
return p.line_spacing_px
|
||||
return _line_font_size(p, line) * p.line_height_ratio
|
||||
|
||||
|
||||
@@ -1533,9 +1574,8 @@ def _clip_lines_to_bottom(
|
||||
cursor_y = top_y
|
||||
for line in lines:
|
||||
line_h = _line_height(para, line)
|
||||
# PowerPoint lets the first line that starts within the box render even
|
||||
# when it slightly exceeds the bottom — only suppress lines whose top
|
||||
# is already at/below the bottom edge.
|
||||
# Approximate explicit clip/ellipsis at line boundaries. Default
|
||||
# overflow bypasses this filter; it must not discard source text.
|
||||
if cursor_y >= bottom_y:
|
||||
break
|
||||
visible.append(line)
|
||||
@@ -1554,6 +1594,8 @@ def _emit_paragraph(
|
||||
lines: list[list[TextRun]],
|
||||
inner_x: float, inner_w: float,
|
||||
top_y: float,
|
||||
*,
|
||||
metrics_baseline: bool,
|
||||
) -> str:
|
||||
"""Render a paragraph (already split into lines) as one <text> element.
|
||||
|
||||
@@ -1587,7 +1629,26 @@ def _emit_paragraph(
|
||||
return ""
|
||||
|
||||
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] = []
|
||||
for line_idx, line in enumerate(visible_lines):
|
||||
@@ -1602,7 +1663,7 @@ def _emit_paragraph(
|
||||
if line_advance is not None:
|
||||
spans.append(
|
||||
f'<tspan x="{fmt_num(anchor_x)}" '
|
||||
f'dy="{fmt_num(line_advance)}"></tspan>'
|
||||
f'dy="{fmt_num(line_advance, 8)}"></tspan>'
|
||||
)
|
||||
continue
|
||||
line_has_hyperlink = any(run.hyperlink_href for run in line)
|
||||
@@ -1615,7 +1676,7 @@ def _emit_paragraph(
|
||||
for run in line
|
||||
)
|
||||
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
|
||||
else ''
|
||||
)
|
||||
@@ -1627,19 +1688,21 @@ def _emit_paragraph(
|
||||
if run.formula_latex is not None:
|
||||
spans.append(
|
||||
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>'
|
||||
)
|
||||
else:
|
||||
spans.append(
|
||||
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>'
|
||||
)
|
||||
else:
|
||||
spans.append(_run_tspan_markup(run))
|
||||
|
||||
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>"
|
||||
|
||||
|
||||
@@ -1647,7 +1710,7 @@ def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
||||
text_anchor: str) -> str:
|
||||
parts = [
|
||||
f'x="{fmt_num(x)}"',
|
||||
f'y="{fmt_num(y)}"',
|
||||
f'y="{fmt_num(y, 8)}"',
|
||||
f'text-anchor="{text_anchor}"',
|
||||
'xml:space="preserve"',
|
||||
]
|
||||
|
||||
+17
-7
@@ -17,6 +17,7 @@ const strings = {
|
||||
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?',
|
||||
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.',
|
||||
},
|
||||
zh: {
|
||||
@@ -34,6 +35,7 @@ const strings = {
|
||||
blockUnknown: '该块的意见基准未知', blockMissing: '该块已不存在',
|
||||
exit: '退出评审', exitConfirm: '存在未应用草稿或未保存意见。是否停止评审并放弃待保存内容?',
|
||||
stopped: '评审服务已停止,可以关闭此标签页。',
|
||||
pageTitle: 'PPT Master · 设计规范评审', languageLabel: '语言', blocksLabel: '设计规范块', scopeLabel: '意见范围',
|
||||
disconnected: '无法连接评审服务,本地编辑已保留。', rebaseConfirm: '以当前磁盘版本为基准暂存此内容,替换服务端的当前草稿?请先核对最新原文;应用时将替换该块。',
|
||||
},
|
||||
'zh-TW': {
|
||||
@@ -51,6 +53,7 @@ const strings = {
|
||||
blockUnknown: '該區塊的意見基準未知', blockMissing: '該區塊已不存在',
|
||||
exit: '退出評審', exitConfirm: '存在未套用草稿或未儲存意見。是否停止評審並放棄待儲存內容?',
|
||||
stopped: '評審服務已停止,可以關閉此分頁。',
|
||||
pageTitle: 'PPT Master · 設計規範審閱', languageLabel: '語言', blocksLabel: '設計規範區塊', scopeLabel: '意見範圍',
|
||||
disconnected: '無法連線評審服務,本機編輯已保留。', rebaseConfirm: '以目前磁碟版本為基準暫存此內容,取代伺服器上的目前草稿?請先核對最新原文;套用時將取代該區塊。',
|
||||
},
|
||||
ja: {
|
||||
@@ -69,6 +72,7 @@ const strings = {
|
||||
blockUnknown: 'ブロックの基準は不明です', blockMissing: 'このブロックは存在しません',
|
||||
exit: 'レビューを終了', exitConfirm: '未適用の下書きまたは未保存のコメントがあります。レビューを終了して未保存の内容を破棄しますか?',
|
||||
stopped: 'レビューサービスは停止しました。このタブを閉じてください。',
|
||||
pageTitle: 'PPT Master · 設計仕様レビュー', languageLabel: '言語', blocksLabel: '設計仕様のブロック', scopeLabel: 'コメントの対象',
|
||||
disconnected: 'サービスに接続できません。編集内容は保持されています。', rebaseConfirm: '現在の原文を基準にこの内容を一時保存し、サーバーの下書きを置き換えますか?原文を先に確認してください。適用でこのブロックを置き換えます。',
|
||||
},
|
||||
};
|
||||
@@ -79,10 +83,15 @@ const node = (tag, text) => {
|
||||
return result;
|
||||
};
|
||||
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');
|
||||
try { lang = localStorage.getItem('spec-review-language') || lang; } catch (_) { /* Optional browser storage. */ }
|
||||
if (!strings[lang]) lang = 'en';
|
||||
// `ppt_lang` is the key the other local UIs use; the old key is a read-only fallback.
|
||||
// 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];
|
||||
|
||||
// User Markdown never crosses an HTML parser boundary. Unsupported constructs
|
||||
@@ -304,7 +313,7 @@ function renderComments() {
|
||||
$('comments').replaceChildren();
|
||||
for (const item of comments.filter(item => item.key === annotationKey())) {
|
||||
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') :
|
||||
(item.block_missing ? 'blockMissing' : (item.block_changed === true ? 'blockChanged' :
|
||||
(item.block_changed === false ? 'blockUnchanged' : 'blockUnknown')));
|
||||
@@ -372,16 +381,17 @@ async function reload() {
|
||||
if ([...drafts.values()].some(draft => draft.sha256 !== metadata.sha256)) showConflict(true);
|
||||
}
|
||||
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-aria]').forEach(element => { element.setAttribute('aria-label', t(element.dataset.aria)); });
|
||||
if (stopped) { $('stopped').textContent = t('stopped'); return; }
|
||||
renderStatus(); renderNavigation(); renderPreview(); renderComments();
|
||||
if (!$('conflict').hidden) showConflict();
|
||||
if (!$('conflict').hidden) { if (current?.missing) $('conflict').textContent = t('missing'); else showConflict(); }
|
||||
}
|
||||
|
||||
$('language').addEventListener('change', () => {
|
||||
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();
|
||||
});
|
||||
$('source').addEventListener('click', () => { source = !source; renderPreview(); });
|
||||
|
||||
+5
-5
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header><strong>PPT Master · Spec Review</strong><span id="fingerprint"></span>
|
||||
<select id="language" aria-label="Language">
|
||||
<header><strong data-label="pageTitle">PPT Master · Spec Review</strong><span id="fingerprint"></span>
|
||||
<select id="language" aria-label="Language" data-aria="languageLabel">
|
||||
<option value="zh">中文</option><option value="zh-TW">繁體中文</option>
|
||||
<option value="en">English</option><option value="ja">日本語</option>
|
||||
</select>
|
||||
@@ -25,7 +25,7 @@
|
||||
<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>
|
||||
</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>
|
||||
<main>
|
||||
<div class="toolbar"><h1 id="title"></h1>
|
||||
@@ -47,7 +47,7 @@
|
||||
</main>
|
||||
<aside class="annotations">
|
||||
<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>
|
||||
<div id="comments"></div>
|
||||
<label for="annotation" data-label="newAnnotation"></label>
|
||||
|
||||
+12
-6
@@ -375,7 +375,9 @@
|
||||
applyI18n();
|
||||
refreshLangUI(lang);
|
||||
// Re-render dynamic regions so they pick up the new language
|
||||
var annotationDraft = annotationText.value;
|
||||
updateSelectionPanel();
|
||||
annotationText.value = annotationDraft;
|
||||
updateAnnotationList();
|
||||
updateUndoButton();
|
||||
updatePendingStatus();
|
||||
@@ -503,7 +505,7 @@
|
||||
btn.textContent = t(action.key);
|
||||
btn.addEventListener("click", function () {
|
||||
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()) {
|
||||
annotationText.value = prefix;
|
||||
} else if (annotationText.value.indexOf(prefix) === -1) {
|
||||
@@ -590,6 +592,12 @@
|
||||
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
|
||||
// ================================================================
|
||||
@@ -610,9 +618,7 @@
|
||||
slideListEl.appendChild(empty);
|
||||
if (!currentSlide) {
|
||||
svgPlaceholder.style.display = "block";
|
||||
svgPlaceholder.textContent = liveMode
|
||||
? t("placeholder_live_ready")
|
||||
: t("empty_no_slides");
|
||||
setPlaceholder(liveMode ? "placeholder_live_ready" : "empty_no_slides");
|
||||
svgContent.style.display = "none";
|
||||
}
|
||||
updateNavLabel();
|
||||
@@ -694,7 +700,7 @@
|
||||
if (!liveMode || name !== currentSlide) return;
|
||||
waitingForSlide = name;
|
||||
svgPlaceholder.style.display = "block";
|
||||
svgPlaceholder.textContent = t("placeholder_slide_writing");
|
||||
setPlaceholder("placeholder_slide_writing");
|
||||
svgContent.style.display = "none";
|
||||
}
|
||||
|
||||
@@ -774,7 +780,7 @@
|
||||
if (!rootSvg || !hasContent) {
|
||||
showError(t("err_empty_svg"));
|
||||
svgPlaceholder.style.display = "block";
|
||||
svgPlaceholder.textContent = t("err_empty_svg");
|
||||
setPlaceholder("err_empty_svg");
|
||||
svgContent.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
+5
@@ -175,6 +175,11 @@ def copy_text_attrs(
|
||||
v = src_el.get(k)
|
||||
if v is not None:
|
||||
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 = 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:
|
||||
|
||||
+51
@@ -51,6 +51,7 @@ from .hyperlinks import (
|
||||
)
|
||||
from .theme_colors import color_node_xml
|
||||
from .theme_fonts import theme_font_tokens
|
||||
from .text_baseline import drawingml_text_baseline_offset
|
||||
from .text_properties import (
|
||||
drawingml_letter_spacing,
|
||||
normalize_project_text_segments,
|
||||
@@ -3091,6 +3092,12 @@ def _build_run_xml(
|
||||
) -> str:
|
||||
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
||||
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/>'
|
||||
text = str(run['text'])
|
||||
inline_formula = run.get(_INLINE_FORMULA_KEY)
|
||||
@@ -3382,6 +3389,50 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
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)
|
||||
|
||||
# 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:
|
||||
self.import_source(chart=True, mutate=_diagram_fixture)
|
||||
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))
|
||||
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)
|
||||
|
||||
+4
-2
@@ -435,8 +435,10 @@ class NativeExportGuardTests(unittest.TestCase):
|
||||
self.assertEqual(len(group.findall('p:sp', NS)), 2)
|
||||
frame = group.find('p:grpSpPr/a:xfrm', NS)
|
||||
self.assertEqual(frame.get('rot'), '5400000')
|
||||
self.assertEqual(frame.find('a:off', NS).attrib, {'x': '487680', 'y': '1468755'})
|
||||
self.assertEqual(frame.find('a:chOff', NS).attrib, {'x': '944880', 'y': '981075'})
|
||||
# Arial 20px has a native baseline offset of 15pt (20px), so the
|
||||
# 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))
|
||||
|
||||
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.get_json()["validation_errors"], [])
|
||||
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["after_sha256"], sha256(self.path.read_bytes()))
|
||||
self.assertIn("Edited", log["after"])
|
||||
@@ -355,7 +355,7 @@ class ReviewServiceTests(unittest.TestCase):
|
||||
json={"revision": updated["revision"]}).status_code, 200)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
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"])
|
||||
|
||||
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("--applied", item["id"])[0], 0)
|
||||
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):
|
||||
item = self.store.save_annotation("slide:01", "Original")
|
||||
@@ -386,7 +386,7 @@ class ReviewServiceTests(unittest.TestCase):
|
||||
item = self.store.save_annotation("global", "Original")
|
||||
self.check()
|
||||
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"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
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:02", "Missing block feedback")
|
||||
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"]
|
||||
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")
|
||||
@@ -508,7 +508,7 @@ class ReviewServiceTests(unittest.TestCase):
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _: apply(), range(2)))
|
||||
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):
|
||||
first = self.draft("slide:01")
|
||||
|
||||
+4
-4
@@ -160,7 +160,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
||||
for path in confirm.iterdir():
|
||||
os.utime(path, ns=(1_000_000_000, 1_000_000_000))
|
||||
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['revision'] = 2
|
||||
_write_json(rec, data)
|
||||
@@ -176,7 +176,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
||||
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||
})
|
||||
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'])
|
||||
|
||||
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())
|
||||
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)
|
||||
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||
if template:
|
||||
@@ -221,7 +221,7 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
||||
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||
response = client.post('/api/confirm', json=final)
|
||||
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['selection_sha256'], selection['selection_sha256'])
|
||||
self.assertFalse((confirm / 'template_handoff.json').exists())
|
||||
|
||||
+5
-5
@@ -121,7 +121,7 @@ class TemplateFlowBatch4Tests(unittest.TestCase):
|
||||
'text_slots': [{'selector': '#missing'}],
|
||||
}))
|
||||
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'):
|
||||
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'])
|
||||
parse_template_slide(path, 1)
|
||||
# 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'
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
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.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')
|
||||
opaque = path.read_text().replace('fill-opacity="0.8"', 'fill-opacity="1"')
|
||||
path.write_text(opaque)
|
||||
opaque = path.read_text(encoding="utf-8").replace('fill-opacity="0.8"', 'fill-opacity="1"')
|
||||
path.write_text(opaque, encoding="utf-8")
|
||||
self.assertTrue(any('paint order' in issue for issue in
|
||||
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||
for inherited in ('fill-opacity="0.8"', 'style="opacity:0.8"'):
|
||||
with self.subTest(inherited=inherited):
|
||||
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
|
||||
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||
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",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "db2db460a26fa84fb65c8d903b213925fbdee9ed",
|
||||
"commit": "d75a96ab781f3d659be1ad287347d5887ce9f2fc",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-09-28T16:00:01Z"
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shuorenhua",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "按用户要求审稿或去 AI 味,支持中英文,保留事实、关系与作者立场。",
|
||||
"author": {
|
||||
"name": "MrGeDiao"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shuorenhua",
|
||||
"repo": "https://github.com/MrGeDiao/shuorenhua.git",
|
||||
"ref": "main",
|
||||
"commit": "9e6400d5e2cc050485c38fbf0dda682bce66c180",
|
||||
"commit": "e7c2b8670bd3e1988bfec199e861cebd87fc5e9c",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": ".",
|
||||
"syncedAt": "2026-09-27T16:00:00Z"
|
||||
"syncedAt": "2026-10-01T16:00:00Z"
|
||||
}
|
||||
Reference in new issue
Block a user