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:
KeyInfo Bot committed 2026-10-02 00:02:09 +08:00
1 parent 5322b1447d
commit 6e0dfc4b44
36 files changed
+2538 -103

No files matched your search

+13 -13
View File
@@ -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`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
@@ -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
@@ -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
@@ -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,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(); });
@@ -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>
@@ -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;
}
@@ -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,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
@@ -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))
@@ -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)
@@ -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:
@@ -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")
@@ -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())
@@ -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)
@@ -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))
@@ -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")
@@ -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,
)
@@ -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"
}