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
2026-09-06 00:02:06 +08:00
parent ab10766d47
commit a2a9f686a9
74 changed files with 10610 additions and 387 deletions
+6 -6
View File
@@ -42,8 +42,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "367fdb7f0f8f8e7994b5aab632c7ce5014802b32", "commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
}, },
{ {
"id": "taste-skill", "id": "taste-skill",
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "8295ca1416ad17cf25ed17765024bc7ba411782c", "commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
}, },
{ {
"id": "grill-me", "id": "grill-me",
@@ -132,8 +132,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "602a2aba900e45fb2edd694f463f435430678e1f", "commit": "6685283fe8533a469ee1a9455e2bc4047c7453cb",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
}, },
{ {
"id": "shuorenhua", "id": "shuorenhua",
@@ -2,8 +2,8 @@
"sourceId": "caveman", "sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"commit": "367fdb7f0f8f8e7994b5aab632c7ce5014802b32", "commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/caveman", "sourcePath": "plugins/caveman",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
} }
@@ -362,6 +362,32 @@ from .validate import validate
MAX_RETRIES = 2 MAX_RETRIES = 2
def _is_smaller_than_body(candidate_body: str, body: str) -> bool:
"""True when `candidate_body` actually compresses `body`.
The non-expansion invariant for #776. It lives in a helper because it has
to hold for EVERY candidate, not just the first one: a candidate that fails
validation is sent back to Claude for repair, and the repaired text is what
gets written if it validates. Checking only the first candidate left the
retry path able to write a longer file and report it as a successful
compression — the original bug, one branch over.
Always compares bodies with frontmatter already removed. Frontmatter is
preserved verbatim, so counting it on one side and not the other would
measure the wrong thing.
"""
candidate_len = len(candidate_body.strip())
body_len = len(body.strip())
if candidate_len >= body_len:
print(
"❌ Compression aborted: output is not smaller than input "
f"({candidate_len} >= {body_len} chars)."
)
return False
return True
# Bounds each individual Claude call so a stalled CLI (dropped network, an # Bounds each individual Claude call so a stalled CLI (dropped network, an
# auth prompt with no TTY to answer it) can't hang past what LOCK_WAIT_SECONDS # auth prompt with no TTY to answer it) can't hang past what LOCK_WAIT_SECONDS
# assumes for the whole run's worst case (MAX_RETRIES+1 calls). # assumes for the whole run's worst case (MAX_RETRIES+1 calls).
@@ -649,6 +675,16 @@ def _compress_file_locked(filepath: Path) -> bool:
print(" already in caveman form. Original file is untouched (no backup created).") print(" already in caveman form. Original file is untouched (no backup created).")
return False return False
# A rewrite that is structurally faithful but LONGER than the input passes
# every check below (validate() only checks structural invariants, not
# length) and would otherwise be written over the original and reported
# as a successful compression — the opposite of what this tool exists to
# do (issue #776). Same length is also a reject: a compression that saved
# nothing isn't a compression.
if not _is_smaller_than_body(compressed_body, body):
print(" Original file is untouched (no backup created).")
return False
# Reassemble: frontmatter (verbatim) + compressed body # Reassemble: frontmatter (verbatim) + compressed body
compressed = frontmatter + compressed_body compressed = frontmatter + compressed_body
@@ -712,6 +748,18 @@ def _compress_file_locked(filepath: Path) -> bool:
print(" Possible preamble leak. Skipping this attempt.") print(" Possible preamble leak. Skipping this attempt.")
continue continue
# The repaired candidate is what gets written if it validates, so the
# non-expansion invariant has to hold for it too — a repair that
# restores the structure validate() asked for by padding the prose back
# out is exactly the "compression" #776 is about. `fixed` is a whole
# file (build_fix_prompt is given one, and the anchor check above
# requires it to start with the original's first line), so its
# frontmatter is split off to compare like against like.
_, fixed_body = split_frontmatter(fixed)
if not _is_smaller_than_body(fixed_body, body):
print(" Skipping this attempt.")
continue
compressed = fixed compressed = fixed
return False return False
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-09-04T16:02:13Z" "syncedAt": "2026-09-05T16:02:05Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "602a2aba900e45fb2edd694f463f435430678e1f", "commit": "6685283fe8533a469ee1a9455e2bc4047c7453cb",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "8295ca1416ad17cf25ed17765024bc7ba411782c", "commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-05T16:00:00Z"
} }
@@ -23,6 +23,11 @@
# 推荐核心后端:openai / gemini / qwen / zhipu / volcengine # 推荐核心后端:openai / gemini / qwen / zhipu / volcengine
# IMAGE_BACKEND=openai # IMAGE_BACKEND=openai
# Max concurrent requests in --manifest batch mode (default 3).
# Auto-halves on rate-limit; 1 is the serial fallback.
# --manifest 批量模式下的最大并发数(默认 3)。命中限流自动减半,最低 1(=串行)。
# IMAGE_CONCURRENCY=3
# You may also provide the same variables directly via the current process environment. # You may also provide the same variables directly via the current process environment.
# 也可以不写 .env,而是直接通过当前运行进程的环境变量提供同样的配置。 # 也可以不写 .env,而是直接通过当前运行进程的环境变量提供同样的配置。
@@ -39,6 +44,8 @@
# IMAGE_BACKEND=openai # IMAGE_BACKEND=openai
# OPENAI_API_KEY=sk-xxx # OPENAI_API_KEY=sk-xxx
# OPENAI_MODEL=gpt-image-2 # OPENAI_MODEL=gpt-image-2
# Legacy ChatGPT image API model / 旧版 ChatGPT 生图 API 模型
# OPENAI_MODEL=chatgpt-image-latest # Deprecated by OpenAI / OpenAI 已弃用
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # OPENAI_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible providers may need these compatibility knobs. # OpenAI-compatible providers may need these compatibility knobs.
# OpenAI-compatible 第三方平台可按需开启以下兼容参数。 # OpenAI-compatible 第三方平台可按需开启以下兼容参数。
@@ -53,6 +60,9 @@
# OPENAI_BACKGROUND=auto # OPENAI_BACKGROUND=auto
# auto / low # auto / low
# OPENAI_MODERATION=auto # OPENAI_MODERATION=auto
# Reference edits except gpt-image-2: high / low
# 除 gpt-image-2 外的参考图编辑:high / low
# OPENAI_INPUT_FIDELITY=high
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Example: Gemini / Gemini 示例 # Example: Gemini / Gemini 示例
@@ -97,6 +107,21 @@
# VOLCENGINE_MODEL=doubao-seedream-4-5-251128 # VOLCENGINE_MODEL=doubao-seedream-4-5-251128
# VOLCENGINE_BASE_URL=https://operator.las.cn-beijing.volces.com/api/v1 # VOLCENGINE_BASE_URL=https://operator.las.cn-beijing.volces.com/api/v1
# ─────────────────────────────────────────────────────────────
# Example: Tencent Cloud TokenHub / 腾讯云 TokenHub 生图示例
# ─────────────────────────────────────────────────────────────
# IMAGE_BACKEND=tencent
# TENCENT_API_KEY=your-tokenhub-key
# Alternative key / 备选密钥:TOKENHUB_API_KEY
# TENCENT_MODEL=hy-image-v3
# TENCENT_BASE_URL=https://tokenhub.tencentmaas.com
# International base / 国际基址:https://tokenhub-intl.tencentmaas.com
# Hy: hy-image-v3 uses /v1/wand/hunyuan-image/v3-generation / 混元模型使用此端点
# Seedream: seedream-image-v5.0-pro / seedream-image-v5.0-lite use /v1/wand/si-image/generation / 即梦模型使用此端点
# TENCENT_REVISE=true # Hy only / 仅混元
# TENCENT_WATERMARK=false # Seedream only / 仅即梦
# TENCENT_OUTPUT_FORMAT=png # Seedream: png / jpeg / 即梦输出格式
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Extended / Experimental backends # Extended / Experimental backends
# 扩展 / 实验后端 # 扩展 / 实验后端
@@ -170,12 +195,13 @@
# Use cloud providers only when you want high-quality or cloned voices — # Use cloud providers only when you want high-quality or cloned voices —
# all four (ElevenLabs / MiniMax / Qwen / CosyVoice) accept a cloned voice_id. # all four (ElevenLabs / MiniMax / Qwen / CosyVoice) accept a cloned voice_id.
# Clone the voice in the provider's console first, then pass --voice-id to # Clone the voice in the provider's console first, then pass --voice-id to
# notes_to_audio.py. See workflows/stages/generate-audio.md. # notes_to_audio.py. See workflows/stages/generate-audio.md (installed skill)
# or docs/audio-narration.md "Use a cloned voice" (repository clone).
# edge-tts 是默认旁白后端,无需 API Key。 # edge-tts 是默认旁白后端,无需 API Key。
# 需要高质量云端旁白或复刻音色时再配置云端提供商—— # 需要高质量云端旁白或复刻音色时再配置云端提供商——
# ElevenLabs / MiniMax / Qwen / CosyVoice 四家都支持传入复刻 voice_id。 # ElevenLabs / MiniMax / Qwen / CosyVoice 四家都支持传入复刻 voice_id。
# 先在 provider 控制台复刻得到 voice_id,再用 --voice-id 传给 notes_to_audio.py。 # 先在 provider 控制台复刻得到 voice_id,再用 --voice-id 传给 notes_to_audio.py。
# 详见 workflows/stages/generate-audio.md。 # 详见 workflows/stages/generate-audio.md(安装的 skill)或 docs/zh/audio-narration.md "使用复刻音色"(仓库克隆)
# #
# ELEVENLABS_API_KEY=your-elevenlabs-api-key # ELEVENLABS_API_KEY=your-elevenlabs-api-key
# MINIMAX_API_KEY=your-minimax-key # MINIMAX_API_KEY=your-minimax-key
@@ -2,7 +2,7 @@
name: ppt-master name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。" description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata: metadata:
version: "6.2.0" version: "6.3.0"
copyright: "Copyright (c) 2025-2026 Hugo He" copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT" license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master" official_repository: "https://github.com/hugohe3/ppt-master"
@@ -271,7 +271,7 @@ Mechanical repetition comes from reusing one carrier and topology without a page
- **Element grouping (Mandatory)**: wrap each logical Slide-local body unit in a descriptive, page-unique top-level `<g id>` with root-coordinate `data-pptx-bounds="x y width height"`. A helper-authored preset atom stays top-level with `data-pptx-frame` and no bounds; nested groups need none. Give a root background image or full-canvas scrim/decoration rectangle a stable `id` plus `data-pptx-role="background"` / `"decoration"` instead of a wrapper. Thresholds, exemptions, and the Morph staging marker: [`shared-standards-core.md`](./shared-standards-core.md) §4.3. - **Element grouping (Mandatory)**: wrap each logical Slide-local body unit in a descriptive, page-unique top-level `<g id>` with root-coordinate `data-pptx-bounds="x y width height"`. A helper-authored preset atom stays top-level with `data-pptx-frame` and no bounds; nested groups need none. Give a root background image or full-canvas scrim/decoration rectangle a stable `id` plus `data-pptx-role="background"` / `"decoration"` instead of a wrapper. Thresholds, exemptions, and the Morph staging marker: [`shared-standards-core.md`](./shared-standards-core.md) §4.3.
- **Reference — nested edit groups**: a top-level group may contain descriptive nested `<g>` groups for meaningful subunits; they need no bounds and create no animation step, with no default depth or quota. - **Reference — nested edit groups**: a top-level group may contain descriptive nested `<g>` groups for meaningful subunits; they need no bounds and create no animation step, with no default depth or quota.
- **Default — bounds are the module zone, not a glyph box (may skip when no text is estimable)**: make each zone as generous as the canvas and siblings allow without overlap. A group's bounds are the union of every child's geometry — text lines, preset frames, image frames, line stroke half-width — plus margin, never the title text alone. An untransformed line spans `y 0.85 × font_size` to `y + 0.35 × font_size`. Write the sentence first, then fit the zone to it; recompute the bounds after any font-size, line-count, or child-geometry change. - **Default — bounds are the module zone, not a glyph box (may skip when no text is estimable)**: make each zone as generous as the canvas and siblings allow without overlap. A group's bounds are the union of every child's geometry — text lines, preset frames, image frames, line stroke half-width — plus margin, never the title text alone. An untransformed line spans `y 0.85 × font_size` to `y + 0.35 × font_size`, so a zone's top sits at or above the first baseline 0.85 × size and its bottom at or below the last baseline + 0.35 × size (a 16px footer on baseline 676 needs a bottom of 682, not 680). Write the sentence first, then fit the zone to it; recompute the bounds after any font-size, line-count, or child-geometry change.
- **Width estimation**: width is estimated, never measured per page. The route calibrates every role once before P01 (Default Step 6, Quick §3) and keeps a per-role chars-per-100-px table in context; size each zone as characters ÷ that rate × 100; a line mixing CJK with Latin words or digits adds the two parts, CJK chars ÷ CJK rate + other chars ÷ Latin rate (acronyms and uppercase words use the CAPS rate, numbers the DIGITS rate; the rates are sample averages while the checker measures real glyphs, so keep about 5% below the bounds width; a line rewritten after calibration — an expanded title, a longer label — is re-estimated the same way, the outline column covers planned wording only). Never trim wording to satisfy an estimate. When text does not fit, expand a zone with unused space first, then reflow or switch texture (prose → points) before dropping a qualifier; larger bounds never repair off-canvas text. - **Width estimation**: width is estimated, never measured per page. The route calibrates every role once before P01 (Default Step 6, Quick §3) and keeps a per-role chars-per-100-px table in context; size each zone as characters ÷ that rate × 100; a line mixing CJK with Latin words or digits adds the two parts, CJK chars ÷ CJK rate + other chars ÷ Latin rate (acronyms and uppercase words use the CAPS rate, numbers the DIGITS rate; the rates are sample averages while the checker measures real glyphs, so keep about 5% below the bounds width; a line rewritten after calibration — an expanded title, a longer label — is re-estimated the same way, the outline column covers planned wording only). Never trim wording to satisfy an estimate. When text does not fit, expand a zone with unused space first, then reflow or switch texture (prose → points) before dropping a qualifier; larger bounds never repair off-canvas text.
- **Spec adherence**: binding color, canvas, typography, identity, resource, and template anchors hold; layout and other References apply under §2.1 without becoming locks. - **Spec adherence**: binding color, canvas, typography, identity, resource, and template anchors hold; layout and other References apply under §2.1 without becoming locks.
- **Template structure**: inherit the native framework only for `template_reuse_scope: mirror|layout`; `style` uses the flat route. - **Template structure**: inherit the native framework only for `template_reuse_scope: mirror|layout`; `style` uses the flat route.
@@ -34,7 +34,7 @@ python3 skills/ppt-master/scripts/stamp_native_fallbacks.py "<svg-file-or-direct
The hash is a synchronization receipt, not proof of semantic equivalence; never stamp stale JSON to satisfy validation. The hash is a synchronization receipt, not proof of semantic equivalence; never stamp stale JSON to satisfy validation.
**Hard rule — activation is the opt-in**: a marker only declares eligibility. Normal `svg_to_pptx.py` converts the fallback children into editable DrawingML shapes; `--native-charts-and-tables` emits the PowerPoint Chart/Table object and skips the fallback children — data-object-first and possibly lossy (marker-local labels, callouts, KPIs, guide lines, custom bins, or styling absent from the payload may normalize; export warns). Loss of visual parity is not grounds to remove a convertible marker; use fallback export when exact artwork matters more. **Hard rule — activation is the opt-in**: a marker only declares eligibility. Normal `svg_to_pptx.py` converts the fallback children into editable DrawingML shapes; `--native-charts-and-tables` emits the PowerPoint Chart/Table object and discards the fallback children, so everything the fallback shows lives in the payload — data, point emphasis, line/area treatment, spacing, chrome, and companion text — and the final checker's parity findings block that export until they do. A convertible marker stays even when a detail has no native field: it becomes companion text or an unmarked sibling object, never a simpler drawing.
| Replacement marker | Native output | Required metadata | | Replacement marker | Native output | Required metadata |
|---|---|---| |---|---|---|
@@ -62,11 +62,11 @@ The hash is a synchronization receipt, not proof of semantic equivalence; never
</g> </g>
``` ```
**Hard rule — project by the selected authority**: SVG-first metadata describes the same data and visible chrome as its fallback — for a chart every category/point and series value, x/y/size data, visible point colors and labels, line/area treatment, title/axis/legend chrome, companion text, bounds, and typography native export cannot infer; for a table every resolved cell, header/summary line, rectangular span, cell style, alignment, bounds, and typography. JSON-first metadata is the authority and its preview may be approximate. Never simplify the artwork to fit the payload: when the closed payload cannot carry required data or topology, Default returns the native-ready decision upstream and Quick revises it before drawing; only the resolved non-native object stays unmarked, and an explicit `=yes` is never silently ignored. **Per-page verification**: every `=yes` key matches exactly one marker with one JSON child, and `=no` / unlisted objects have none — `rg -n 'data-pptx-replace-with="(chart|table)"|<metadata type="application/json">' <project_path>/svg_output/<current_page>.svg`. **Hard rule — project by the selected authority**: SVG-first metadata describes the same data and visible chrome as its fallback — for a chart every category/point and series value (`null` for a break in a line), x/y/size data, visible point colors and markers (`line_style` / `point_colors`), line/area treatment (a line over a translucent fill is a `combo` of an area plot and a line plot), title/axis/legend chrome, companion text, bounds, and typography native export cannot infer; bar categories follow payload order top-down, and bar spacing, clustered overlap, and where a line starts inside `plot_area` are read from the fallback; for a table every resolved cell, header/summary line, rectangular span, cell style, alignment, bounds, and typography. JSON-first metadata is the authority and its preview may be approximate. Never simplify the artwork to fit the payload: when the closed payload cannot carry required data or topology, Default returns the native-ready decision upstream and Quick revises it before drawing; only the resolved non-native object stays unmarked, and an explicit `=yes` is never silently ignored. **Per-page verification**: every `=yes` key matches exactly one marker with one JSON child, and `=no` / unlisted objects have none — `rg -n 'data-pptx-replace-with="(chart|table)"|<metadata type="application/json">' <project_path>/svg_output/<current_page>.svg`.
### Table schema — `ppt-master.semantic-table.v2` ### Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` / `runs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md). Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
**Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry).
@@ -175,7 +175,7 @@ These forms are needed only when the stated PPT behavior matters:
| Desired behavior | Required form | | Desired behavior | Required form |
|---|---| |---|---|
| One editable PPT text frame with mixed formatting or multiline prose | Use one `<text>` per logical paragraph and non-positional `<tspan>` children for inline runs. Per-run `fill` / `font-weight` / `font-size` is retained: export walks nested runs and emits one DrawingML run per styled segment, so an emphasised phrase stays inside the same editable frame, and a positioned line-break `<tspan>` may itself contain inline runs. Keep the first line as direct text; later lines use direct positioned `<tspan>` children that repeat parent `x` with positive relative `dy`; an all-`<tspan>` form may start at `dy="0"`. Default retains these breaks without PowerPoint wrapping; `--reflow-text` may join eligible lines. A font-size change, list marker, or larger accepted gap starts another paragraph. Sibling `<text>` elements are not a paragraph's line breaks — the checker reports likely cases as a warning because the detection is heuristic, and they are repaired before export; sibling frames remain valid for independent text. | | One editable PPT text frame with mixed formatting or multiline prose | Use one `<text>` per logical paragraph and non-positional `<tspan>` children for inline runs. Per-run `fill` / `font-weight` / `font-size` is retained: export walks nested runs and emits one DrawingML run per styled segment, so an emphasised phrase stays inside the same editable frame, and a positioned line-break `<tspan>` may itself contain inline runs. Keep the first line as direct text; later lines use direct positioned `<tspan>` children that repeat parent `x` with positive relative `dy`; an all-`<tspan>` form may start at `dy="0"`, and it is the form to use when the first line itself carries an inline run. Default retains these breaks without PowerPoint wrapping; `--reflow-text` may join eligible lines. A font-size change, list marker, or larger accepted gap starts another paragraph. Sibling `<text>` elements are not a paragraph's line breaks — the checker reports likely cases as a warning because the detection is heuristic, and they are repaired before export; sibling frames remain valid for independent text. |
| Stable object grouping or object-level animation anchor | Wrap the intended object in `<g id="...">`. Content grouping is **mandatory** per §4.3 — a top-level `<g id>` is also the animation anchor; it is not an optional convenience. | | Stable object grouping or object-level animation anchor | Wrap the intended object in `<g id="...">`. Content grouping is **mandatory** per §4.3 — a top-level `<g id>` is also the animation anchor; it is not an optional convenience. |
| Native PowerPoint background promotion | Outside structured mode, make the first visual layer a direct full-canvas `<rect>` (or one inside a simple single-child group) with a solid, linear/radial gradient, or preset-pattern fill and no transform, filter, clip, rounding, or visible stroke; export writes it as Slide `p:bg`. Structured routes follow [`pptx-structure-interface.md`](./pptx-structure-interface.md). | | Native PowerPoint background promotion | Outside structured mode, make the first visual layer a direct full-canvas `<rect>` (or one inside a simple single-child group) with a solid, linear/radial gradient, or preset-pattern fill and no transform, filter, clip, rounding, or visible stroke; export writes it as Slide `p:bg`. Structured routes follow [`pptx-structure-interface.md`](./pptx-structure-interface.md). |
| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`: keep objects Slide-local and author no Master/Layout identities, layers, or slots; export emits one clean Master plus Blank Layout. | | Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`: keep objects Slide-local and author no Master/Layout identities, layers, or slots; export emits one clean Master plus Blank Layout. |
@@ -189,7 +189,7 @@ These forms are needed only when the stated PPT behavior matters:
**Hard rule — root groups protect body-text layout**: every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group, so it takes no separate zone and creates no root-group overlap, and a clipped picture's zone is its visible region; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are overlap-exempt only; thresholds and estimator: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4. **Hard rule — root groups protect body-text layout**: every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group, so it takes no separate zone and creates no root-group overlap, and a clipped picture's zone is its visible region; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are overlap-exempt only; thresholds and estimator: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. Titles, direct atomic Master/Layout elements, and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `<g>` solely to silence an ungrouped-element advisory. Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. Titles, direct atomic Master/Layout elements, and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `<g>` solely to silence an ungrouped-element advisory; a `<g>` that carries the role is still a root group and declares `data-pptx-bounds` — only a root primitive is exempt.
**Structural atoms and slots are excluded automatically.** `data-pptx-layer` and `data-pptx-placeholder` semantics are read first; otherwise explicit `data-pptx-role` values (`background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, `logo`) mark Slide-local static framing (§4.1, [`semantic-svg.md`](semantic-svg.md)). A normal slot group has exactly one direct compatible carrier; several drawing atoms require the explicit composite `object` proxy fallback. Native chart/table carrier groups retain their specialized [`native-data-interface.md`](./native-data-interface.md) contract. **Structural atoms and slots are excluded automatically.** `data-pptx-layer` and `data-pptx-placeholder` semantics are read first; otherwise explicit `data-pptx-role` values (`background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, `logo`) mark Slide-local static framing (§4.1, [`semantic-svg.md`](semantic-svg.md)). A normal slot group has exactly one direct compatible carrier; several drawing atoms require the explicit composite `object` proxy fallback. Native chart/table carrier groups retain their specialized [`native-data-interface.md`](./native-data-interface.md) contract.
@@ -0,0 +1,502 @@
#!/usr/bin/env python3
"""Install selected Brand / Style / Layout / Deck workspaces into a project.
Runs the install half of ``workflows/stages/apply-template-workspace.md`` §4
as one command: validate every root, map each spec to its kind-qualified
project name, resolve the structural owner (Layout over Deck), copy the owner's
roster and every root's ``images/`` / ``icons/`` once, refuse destination
collisions and duplicate kinds before writing anything, prepend one provenance
line under each copied spec's H1, and print the §5.3 completion receipt.
Usage::
python3 scripts/apply_template.py <project_path> --root <workspace_root> [--root ...] [--dry-run]
A root is a workspace directory exposing ``templates/design_spec.md`` (library
shape) or one ``templates/design_spec.<kind>.<id>.md`` per kind (project
shape). A root that resolves to the target project is consumed in place. The
tool selects nothing: which roots to pass is the Stage-1 / Quick decision.
"""
from __future__ import annotations
import argparse
import filecmp
import json
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from attribution_guard import require_skill_integrity # noqa: E402
from console_encoding import configure_utf8_stdio # noqa: E402
from register_template import ( # noqa: E402
KIND_CONFIG,
SpecParseError,
_read_spec,
_validate_spec_shape,
validate_qualified_spec_identity,
)
configure_utf8_stdio()
SKILL_DIR = SCRIPT_DIR.parent
REPO_ROOT = SKILL_DIR.parent.parent
CHECKER = SCRIPT_DIR / "svg_quality_checker.py"
KINDS = ("brand", "style", "layout", "deck")
STRUCTURAL_KINDS = ("layout", "deck")
ASSET_DIRS = ("images", "icons")
BITMAP_SUFFIXES = {
".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
}
class ApplyTemplateError(Exception):
"""A contract violation that blocks installation."""
@dataclass
class SpecRecord:
kind: str
template_id: str
path: Path
frontmatter: dict
@dataclass
class RootRecord:
supplied: str
root: Path
source: str # library | explicit
specs: list[SpecRecord]
in_place: bool = False
@property
def kinds(self) -> list[str]:
return [spec.kind for spec in self.specs]
@property
def templates_dir(self) -> Path:
return self.root / "templates"
@property
def display(self) -> str:
if self.source == "library":
kind = self.specs[0].kind
kind_dir = KIND_CONFIG[kind]["dir"].name
return f"skills/ppt-master/templates/{kind_dir}/{self.specs[0].template_id}/"
return f"{self.root}/"
@dataclass
class Mapping:
src: Path
dst: Path
content: bytes | None = None # rewritten specs carry their bytes
status: str = "copy" # copy | identical | in-place
@dataclass
class InstallPlan:
project: Path
roots: list[RootRecord]
owner: RootRecord | None
owner_kind: str | None
mappings: list[Mapping] = field(default_factory=list)
removals: list[Path] = field(default_factory=list)
@property
def installed_specs(self) -> list[str]:
return [
f"design_spec.{spec.kind}.{spec.template_id}.md"
for root in self.roots
for spec in root.specs
]
# ---------------------------------------------------------------------------
# Discovery and validation
# ---------------------------------------------------------------------------
def _frontmatter_identity(spec_path: Path) -> tuple[str, str, dict]:
frontmatter, _body = _read_spec(spec_path)
fm = frontmatter or {}
kind = str(fm.get("kind") or "").strip()
if kind not in KINDS:
raise ApplyTemplateError(
f"{spec_path} declares no kind in its frontmatter; a legacy or "
"semantic-only package needs a new workspace from Create Template"
)
template_id = str(fm.get(KIND_CONFIG[kind]["id_key"]) or "").strip()
if not template_id:
raise ApplyTemplateError(
f"{spec_path} declares kind {kind!r} without {KIND_CONFIG[kind]['id_key']}"
)
return kind, template_id, fm
def _discover_specs(templates_dir: Path) -> list[SpecRecord]:
bare = templates_dir / "design_spec.md"
try:
qualified = _validate_spec_shape(templates_dir)
except SpecParseError as exc:
raise ApplyTemplateError(str(exc)) from exc
if bare.is_file():
kind, template_id, fm = _frontmatter_identity(bare)
return [SpecRecord(kind, template_id, bare, fm)]
specs: list[SpecRecord] = []
for path, _kind in qualified:
try:
kind, template_id, fm, _body = validate_qualified_spec_identity(path)
except SpecParseError as exc:
raise ApplyTemplateError(str(exc)) from exc
specs.append(SpecRecord(kind, template_id, path, fm))
if not specs:
raise ApplyTemplateError(
f"{templates_dir} holds neither design_spec.md nor "
"design_spec.<kind>.<id>.md"
)
return specs
def _source_label(root: Path, specs: list[SpecRecord]) -> str:
if len(specs) != 1:
return "explicit"
spec = specs[0]
config = KIND_CONFIG[spec.kind]
library_root = (config["dir"] / spec.template_id).resolve()
if root.resolve() != library_root:
return "explicit"
index_path = config["index"]
try:
index = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return "explicit"
return "library" if spec.template_id in index else "explicit"
def _load_root(supplied: str, project: Path) -> RootRecord:
root = Path(supplied).expanduser()
if not root.is_dir():
raise ApplyTemplateError(f"workspace root is not a directory: {supplied}")
if root.name == "templates" and (root / "design_spec.md").is_file():
raise ApplyTemplateError(
f"{supplied} is an inner templates/ directory; pass its workspace "
"root so sibling images/ and icons/ install with it"
)
root = root.resolve()
templates_dir = root / "templates"
if not templates_dir.is_dir():
raise ApplyTemplateError(f"{supplied} has no templates/ directory")
specs = _discover_specs(templates_dir)
in_place = root == project.resolve()
if in_place and any(spec.path.name == "design_spec.md" for spec in specs):
raise ApplyTemplateError(
"an in-place project root must already hold kind-qualified specs "
"(design_spec.<kind>.<id>.md); templates/design_spec.md is a "
"library shape"
)
record = RootRecord(
supplied=supplied,
root=root,
source="explicit" if in_place else _source_label(root, specs),
specs=specs,
in_place=in_place,
)
if record.source == "library" and specs[0].kind == "style":
for extra in (*ASSET_DIRS, "exports"):
if (root / extra).exists():
raise ApplyTemplateError(
f"Style-only library package {record.display} carries "
f"{extra}/; a Style contributes only its spec"
)
return record
def _validate_root(record: RootRecord) -> None:
command = [
sys.executable,
str(CHECKER),
str(record.templates_dir),
"--template-mode",
"--canonical-authoring",
]
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode != 0:
tail = "\n".join(
line for line in (result.stdout + result.stderr).splitlines()[-25:]
)
raise ApplyTemplateError(
f"template validation failed for {record.display} "
f"(exit {result.returncode}):\n{tail}"
)
def _check_kind_cardinality(roots: list[RootRecord]) -> None:
seen: dict[str, str] = {}
for record in roots:
for kind in record.kinds:
if kind in seen:
raise ApplyTemplateError(
f"kind {kind!r} is contributed by both {seen[kind]} and "
f"{record.display}; select one root per kind"
)
seen[kind] = record.display
# ---------------------------------------------------------------------------
# Planning
# ---------------------------------------------------------------------------
def _provenance_line(record: RootRecord) -> str:
return f"> **Installed from**: `{record.display}` ({record.source})"
def _spec_with_provenance(spec_path: Path, record: RootRecord) -> bytes:
text = spec_path.read_text(encoding="utf-8")
lines = text.split("\n")
body_start = 0
if lines and lines[0].strip() == "---":
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
body_start = idx + 1
break
h1 = next(
(idx for idx in range(body_start, len(lines)) if lines[idx].startswith("# ")),
None,
)
if h1 is None:
raise ApplyTemplateError(f"{spec_path} has no H1 to anchor the provenance line")
rewritten = lines[: h1 + 1] + ["", _provenance_line(record)] + lines[h1 + 1 :]
return "\n".join(rewritten).encode("utf-8")
def _iter_files(directory: Path) -> list[Path]:
return sorted(path for path in directory.rglob("*") if path.is_file())
def _is_spec_file(path: Path) -> bool:
return path.name == "design_spec.md" or (
path.name.startswith("design_spec.") and path.suffix == ".md"
)
def _structural_files(record: RootRecord) -> list[Path]:
return [
path
for path in _iter_files(record.templates_dir)
if not _is_spec_file(path) and path.suffix.lower() not in BITMAP_SUFFIXES
]
def _plan(project: Path, roots: list[RootRecord]) -> InstallPlan:
owner = next((r for r in roots if "layout" in r.kinds), None)
owner_kind = "layout" if owner else None
if owner is None:
owner = next((r for r in roots if "deck" in r.kinds), None)
owner_kind = "deck" if owner else None
plan = InstallPlan(project=project, roots=roots, owner=owner, owner_kind=owner_kind)
project_templates = project / "templates"
for record in roots:
if record.in_place:
continue
for spec in record.specs:
plan.mappings.append(
Mapping(
src=spec.path,
dst=project_templates / f"design_spec.{spec.kind}.{spec.template_id}.md",
content=_spec_with_provenance(spec.path, record),
)
)
for asset_dir in ASSET_DIRS:
source_dir = record.root / asset_dir
if not source_dir.is_dir():
continue
for path in _iter_files(source_dir):
plan.mappings.append(
Mapping(src=path, dst=project / asset_dir / path.relative_to(source_dir))
)
if owner is not None and not owner.in_place:
for path in _structural_files(owner):
plan.mappings.append(
Mapping(src=path, dst=project_templates / path.relative_to(owner.templates_dir))
)
in_place_deck = next(
(r for r in roots if r.in_place and "deck" in r.kinds), None
)
if owner_kind == "layout" and in_place_deck is not None:
plan.removals.extend(_structural_files(in_place_deck))
return plan
def _preflight(plan: InstallPlan) -> None:
by_dst: dict[Path, Mapping] = {}
for mapping in plan.mappings:
if mapping.dst in by_dst:
raise ApplyTemplateError(
f"two selected files map to {mapping.dst}: "
f"{by_dst[mapping.dst].src} and {mapping.src}"
)
by_dst[mapping.dst] = mapping
removals = set(plan.removals)
collisions: list[str] = []
for mapping in plan.mappings:
if mapping.dst in removals or not mapping.dst.exists():
continue
if mapping.content is not None:
identical = mapping.dst.read_bytes() == mapping.content
else:
identical = filecmp.cmp(mapping.src, mapping.dst, shallow=False)
if identical:
mapping.status = "identical"
else:
collisions.append(f"{mapping.dst} (from {mapping.src})")
if collisions:
raise ApplyTemplateError(
"destination collision; remove or rename before installing:\n "
+ "\n ".join(collisions)
)
# ---------------------------------------------------------------------------
# Writing and receipt
# ---------------------------------------------------------------------------
def _write(plan: InstallPlan) -> None:
for path in plan.removals:
path.unlink()
for mapping in plan.mappings:
if mapping.status == "identical":
continue
mapping.dst.parent.mkdir(parents=True, exist_ok=True)
if mapping.content is not None:
mapping.dst.write_bytes(mapping.content)
else:
shutil.copy2(mapping.src, mapping.dst)
def _receipt(plan: InstallPlan) -> str:
kinds_present = {kind for root in plan.roots for kind in root.kinds}
identity = (
"brand" if "brand" in kinds_present
else "deck" if "deck" in kinds_present
else "current-project"
)
structure = plan.owner_kind or "free-design"
application = "deck" if "deck" in kinds_present else "none"
direction = "style" if "style" in kinds_present else "unresolved"
active_roster = (
f"{plan.owner_kind}:{plan.owner.display}" if plan.owner else "none"
)
install = "in-place" if all(root.in_place for root in plan.roots) else "copied"
return (
"roots=" + ";".join(root.display for root in plan.roots)
+ "; sources=" + ";".join(root.source for root in plan.roots)
+ "; kinds=" + ";".join(",".join(root.kinds) for root in plan.roots)
+ f"; segments=identity:{identity},structure:{structure},"
f"application_context:{application},direction:{direction}"
+ f"; active_roster={active_roster}"
+ f"; install={install}"
+ "; installed_specs=" + ",".join(plan.installed_specs)
)
def apply_templates(
project_path: str | Path,
root_args: list[str],
*,
dry_run: bool = False,
validate: bool = True,
) -> InstallPlan:
"""Plan, preflight, and (unless ``dry_run``) install the selected roots."""
project = Path(project_path).expanduser().resolve()
if not project.is_dir():
raise ApplyTemplateError(f"project path is not a directory: {project_path}")
if not root_args:
raise ApplyTemplateError("pass at least one --root workspace")
roots = [_load_root(arg, project) for arg in root_args]
unique = {record.root for record in roots}
if len(unique) != len(roots):
raise ApplyTemplateError("the same workspace root was passed more than once")
_check_kind_cardinality(roots)
if validate:
for record in roots:
_validate_root(record)
plan = _plan(project, roots)
_preflight(plan)
if not dry_run:
_write(plan)
return plan
def main(argv: list[str] | None = None) -> int:
require_skill_integrity()
parser = argparse.ArgumentParser(
description=(
"Install selected Brand/Style/Layout/Deck workspaces into a "
"project's templates/, images/, and icons/ (apply-template-workspace §4)."
),
)
parser.add_argument("project_path", help="Initialized project root")
parser.add_argument(
"--root",
action="append",
default=[],
metavar="WORKSPACE_ROOT",
help="Workspace root to install; repeat for several kinds (one root per kind)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate, plan, and print the mapping and receipt without writing",
)
parser.add_argument(
"--skip-validation",
action="store_true",
help="Skip the per-root svg_quality_checker --template-mode run (already validated in this turn)",
)
args = parser.parse_args(argv)
try:
plan = apply_templates(
args.project_path,
args.root,
dry_run=args.dry_run,
validate=not args.skip_validation,
)
except ApplyTemplateError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
verb = "would install" if args.dry_run else "installed"
for path in plan.removals:
print(f"[remove] {path}")
same = 0
for mapping in plan.mappings:
if mapping.status == "identical":
same += 1
label = "[same]" if mapping.status == "identical" else "[copy]"
print(f"{label} {mapping.dst}")
copied = len(plan.mappings) - same
note = f", {same} already present" if same else ""
print(f"[OK] {verb} {copied} file(s) into {plan.project}{note}")
print(_receipt(plan))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -58,14 +58,20 @@ Useful options:
output path is known. With multiple inputs, each successful conversion prints output path is known. With multiple inputs, each successful conversion prints
its own JSON line after that source finishes. its own JSON line after that source finishes.
- At the unified `source_to_md.py` entry, `--images all|filtered|none`, - At the unified `source_to_md.py` entry, `--images all|filtered|none`,
`--no-images`, and `--filter-images` map to the PDF image mode. The web `--no-images`, and `--filter-images` map to the PDF image mode.
backend exposes its own direct `--no-images` option described below. `--no-images` (or `--images none`) also applies to web pages (images stay
remote links, no `<stem>_files/`) and is a no-op on Markdown/text.
- A `.md` / `.markdown` / `.txt` URL whose body is not HTML is saved verbatim
under a `Source:` header, named by the URL's filename stem.
- Unknown backend-specific flags are passed through to each selected converter. - Unknown backend-specific flags are passed through to each selected converter.
- `-o/--output` selects one Markdown file for one input, or an output directory - `-o/--output` selects one Markdown file for one input, or an output directory
for multiple inputs / directory inputs. for multiple inputs / directory inputs.
A path that names an existing directory, or ends in `/`, is always treated as A path that names an existing directory, or ends in `/`, is always treated as
a directory, even for a single input: the file keeps its default `<stem>.md` a directory, even for a single input: the file keeps its default `<stem>.md`
name inside it. name inside it; an extension-less `-o` for one input gains `.md`.
Local batch outputs are planned together; input/output collisions gain `_2`,
`_3`, etc. suffixes and are reported on stderr. A single-input file `-o`
refuses an existing file except its own Markdown/text passthrough.
For multi-source project intake, use `project_manager.py import-sources` with For multi-source project intake, use `project_manager.py import-sources` with
all source paths / URLs. For local files, the default is to keep generated all source paths / URLs. For local files, the default is to keep generated
@@ -57,6 +57,7 @@ python3 scripts/image_gen.py "A cinematic portrait" --backend minimax
python3 scripts/image_gen.py "A product launch hero image" --backend qwen python3 scripts/image_gen.py "A product launch hero image" --backend qwen
python3 scripts/image_gen.py "科技感背景图" --backend zhipu python3 scripts/image_gen.py "科技感背景图" --backend zhipu
python3 scripts/image_gen.py "A product KV in cinematic style" --backend volcengine python3 scripts/image_gen.py "A product KV in cinematic style" --backend volcengine
python3 scripts/image_gen.py "A mountain landscape" --backend tencent
``` ```
Configuration sources: Configuration sources:
@@ -14,24 +14,25 @@ file no longer repeats. Section titles mirror the owning section of
## §2 Metadata placement and bounds ## §2 Metadata placement and bounds
one child `<metadata type="application/json">` (attribute `data-pptx-json` is read-compatible, not canonical); the marker's `data-pptx-replace-with` selects the schema. Provide `x`, `y`, `width`, `height` in metadata or as `data-pptx-x/y/width/height` on the group; omitted bounds are inferred from the fallback geometry (then marker/ancestor `translate` / `scale` apply), complete explicit bounds are absolute slide coordinates. `x` / `y` are finite and inside the 32-bit DrawingML range; `width` / `height` additionally resolve to at least one EMU (tables: per resolved row and column). JSON-first markers require all four bounds in metadata. Classic charts accept root `plot_area` (`x`, `y`, `width`, `height`, a positive rectangle inside the frame → `c:manualLayout`); ChartEx rejects it. `svg_quality_checker.py` validates marker kind, JSON, bounds/fallback, table rows/columns, chart type and data shape, the authority contract, and (SVG-first) baseline freshness. one child `<metadata type="application/json">` (attribute `data-pptx-json` is read-compatible, not canonical); the marker's `data-pptx-replace-with` selects the schema. Provide `x`, `y`, `width`, `height` in metadata or as `data-pptx-x/y/width/height` on the group; omitted bounds are inferred from the fallback geometry (then marker/ancestor `translate` / `scale` apply), complete explicit bounds are absolute slide coordinates. `x` / `y` are finite and inside the 32-bit DrawingML range; `width` / `height` additionally resolve to at least one EMU (tables: per resolved row and column). JSON-first markers require all four bounds in metadata. Classic charts accept root `plot_area` (`x`, `y`, `width`, `height`, a positive rectangle inside the frame → `c:manualLayout`; the frame grows past the plot on the side that carries tick labels until that strip is two axis-font ems tall, because PowerPoint drops the manual vertical layout when its labels do not fit and auto-fits the plot to the frame top instead); ChartEx rejects it. `svg_quality_checker.py` validates marker kind, JSON, bounds/fallback, table rows/columns, chart type and data shape, the authority contract, and (SVG-first) baseline freshness.
## §2 Table schema — `ppt-master.semantic-table.v2` ## §2 Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`. Native tables are rectangular grids: `columns` for the optional header row, `rows` for body rows (shorter rows padded unless `strict_grid: true`; at most 1000 resolved rows and columns); `column_widths` / `row_heights` are finite non-negative relative weights matching the grid with one positive value; `header_rows` is an integer in range; `strict_grid`, `style.band_row`, and `bold` are JSON booleans. Exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` and kebab-case `cell_styles` selected by `cell_style` (precedence: cell defaults → named style → cell fields; object-valued `padding` merges, everything else replaces; `<style>` and `class` remain forbidden). Every payload carries that exact `schema`. Native tables are rectangular grids: `columns` for the optional header row, `rows` for body rows (shorter rows padded unless `strict_grid: true`; at most 1000 resolved rows and columns); `column_widths` / `row_heights` are finite non-negative relative weights matching the grid with one positive value (`row_heights` counts the header row when `columns` is present) (a row's top and bottom padding shrink to what its resolved height can hold beside the text line, since PowerPoint would otherwise grow the row past the drawn one); `header_rows` is an integer in range; `strict_grid`, `style.band_row`, and `bold` are JSON booleans. Exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` and kebab-case `cell_styles` selected by `cell_style` (precedence: cell defaults → named style → cell fields; object-valued `padding` merges, everything else replaces; `<style>` and `class` remain forbidden).
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` derives `zh-CN` for CJK and `en-US` otherwise. `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values. Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` derives `zh-CN` for CJK and `en-US` otherwise. `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a plain-text cell without `color` takes `defaults.run.color` (plain text is one run, so `defaults.run` `bold` / `color` / `font_size` reach it when the cell and `defaults.cell` leave them unset), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry. `style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
**Hard rule — the table payload is complete**: a payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules — every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Merged cells — canonical rectangular contract only**: positive integer `row_span` / `col_span` on the anchor, every covered cell blank as `{"merge_continuation": true}` (a bare `{"text": ""}` is blank only while no `defaults.cell` adds fields), spans inside the grid and non-overlapping; the exporter writes `rowSpan` / `gridSpan` / `hMerge` / `vMerge`. CamelCase aliases, raw OOXML merge fields, top-level merge lists, nonblank covered cells, invalid spans, and overlaps fail. **Hard rule — the table payload is complete**: a payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules — every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Merged cells — canonical rectangular contract only**: positive integer `row_span` / `col_span` on the anchor, every covered cell blank (canonical `{"merge_continuation": true}`), spans inside the grid and non-overlapping; the exporter writes `rowSpan` / `gridSpan` / `hMerge` / `vMerge`. Covered-cell blankness is checked before expanding defaults: accepted authored values are `null`, `""`, and objects containing only optional `text` (absent, `null`, or `""`) and `merge_continuation` (absent or exactly `true`), including `{}`. Authored formatting fields make a covered cell nonblank; inherited defaults do not, so `{"text": ""}` stays blank with `defaults.cell.color`. CamelCase aliases, raw OOXML merge fields, top-level merge lists, nonblank covered cells, invalid spans, and overlaps fail.
## §2 Chart schemas ## §2 Chart schemas
- **Category charts**`column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values). `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none. - **Category charts**`column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Column, bar, line, and area values may be `null` for a gap (the workbook cell stays blank, the cache omits the point, `dispBlanksAs` is `gap`; at least one value per series is a number) — a fallback that breaks one line into segments is two series of the same colour with `null` outside their segment. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values). Bar categories run top-down in payload order — the exporter reverses the category axis and moves the value-axis crossing so the value axis keeps its declared position; `axes.category.reverse: false` restores PowerPoint's bottom-up order (column charts stay left-to-right unless `reverse: true`). Bar/column spacing: root `gap_width` (`0..500`, the empty slot share as a percentage of one bar; PowerPoint default `150`) and clustered `overlap` (`-100..100`, the share of one bar the next series covers, negative for a gap); SVG-first export reads both from the fallback bars inside `plot_area` when the payload omits them, JSON-first uses the defaults.
- **Combo** — shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible). Export layers areas below columns and lines. - **Line markers**`line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none. Set `data_labels.number_format` to match the fallback's rendered value labels.
- **Axes**classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`; single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported. - **Combo**shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible); column plots take `gap_width` / `overlap`, line plots `line_style` / `marker_size`. An area series is a fill without an outline; `line_width` adds a same-colour outline around the whole region (baseline included), so a fallback that strokes only the top edge — or draws markers on it — is an area plot under a line plot with the same values. Export layers areas below columns and lines.
- **Axes** — classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`, `tick_marks` (`none` default / `out` / `in` / `cross`), tick-label `color` / `font_family` / `font_size` (SVG px) when one axis's labels differ from the chart-wide text style, and on value axes `cross_between` (`between` — points sit between ticks, the default written explicitly because renderers disagree when it is absent — or `mid_category` — the first point sits on the axis edge; SVG-first line/area export reads it from where the fallback line starts inside `plot_area`); single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. For single-plot category charts, `kind: "date"` is supported only by `area`. Stock dates follow the separate Stock contract below. `position` is honoured through the axis crossing (`top` / `right` cross at the far category, and a reversed crossing axis swaps the ends). `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported.
- **XY**`scatter` and `bubble` use `series[].x` + `series[].y` (`bubble` adds one `series[].size` / `sizes` per point), or `series[].points` as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects. - **XY**`scatter` and `bubble` use `series[].x` + `series[].y` (`bubble` adds one `series[].size` / `sizes` per point), or `series[].points` as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
- **ChartEx**`treemap` / `sunburst` (`values` plus `levels[level][point]` or path-style `categories`; treemap `parent_label_layout: "banner" | "overlapping" | "none"` (default `overlapping`), PowerPoint labels only the top level and leaves), `histogram` (`values`), `pareto` / `waterfall` / `funnel` (`categories` + `values`; `waterfall` accepts `subtotals` / `subtotal_indices`), `boxWhisker` (`series[].values`, optional `series[].categories`). ChartEx writes no `<cx:title>` without a payload title (an empty title shows the series name), emits title/subtitle as companion text boxes, and takes `style.colors` / root `colors` into its color-style part. Non-Microsoft renderers show a limited subset. - **ChartEx**`treemap` / `sunburst` (`values` plus `levels[level][point]` or path-style `categories`; treemap `parent_label_layout: "banner" | "overlapping" | "none"` (default `overlapping`), PowerPoint labels only the top level and leaves), `histogram` (`values`), `pareto` / `waterfall` / `funnel` (`categories` + `values`; `waterfall` accepts `subtotals` / `subtotal_indices`), `boxWhisker` (`series[].values`, optional `series[].categories`). ChartEx writes no `<cx:title>` without a payload title (an empty title shows the series name), emits title/subtitle as companion text boxes, and takes `style.colors` / root `colors` into its color-style part. Non-Microsoft renderers show a limited subset.
- **Stock** — numeric Excel date serials in `categories` / `dates` plus exactly four series open / high / low / close (`series` with four entries or top-level `open` / `high` / `low` / `close`). - **Stock** — numeric Excel date serials in `categories` / `dates` plus exactly four series open / high / low / close (`series` with four entries or top-level `open` / `high` / `low` / `close`).
@@ -39,4 +40,4 @@ Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (`l` / `ctr` / `r`
## §2 Chart chrome, typography, and color ## §2 Chart chrome, typography, and color
SVG-first metadata matches the fallback chrome; JSON-first owns it. Metadata sizes use SVG px (`1px = 0.75pt`); `style.font_family` and `title_font_size`, `subtitle_font_size`, `axis_font_size`, `axis_title_font_size`, `legend_font_size`, `note_font_size` are required only when native must preserve typography an SVG-first fallback cannot supply unambiguously (JSON-first never infers from its preview). A string or unbounded-object `title` becomes native `c:title` (`subtitle` line two); a title object with complete `x`, `y`, `width`, `height` becomes a companion text box (partial bounds or `subtitle` fail); `name` names the object; `title`, `subtitle`, and axis-title objects accept `text`, `font_size`, `font_family`, `color`; the checker rejects SVG-first title/axis text absent from the fallback. Axis titles are explicit via `axis_titles` (`category`, `value`, `x`, `y`, `secondary_value`) or the root aliases `category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, `secondary_value_axis_title`; `show_value_axis_labels: false` hides numeric tick labels (e.g. a radar without radial coordinates); native legends are opt-in via `show_legend: true` and `legend_position` (`bottom` default; `top` / `left` / `right`). SVG-first parity reads the fallback literally — `style.axis_color` is the dominant stroke among elements whose id or class names `axis`, `style.grid_color` the dominant stroke among those naming `grid` / `gridline`, and a role with no labeled stroke falls back to the marker's dominant stroke (axis and grid lines in different colors need those labels or one steals the other's dominant); numbers match in written form (`286.20``286.2`; a payload `73.0` is stored as `73` and renders as `73` under General, so a fallback that shows `73.0` needs `number_format: "0.0"`; a `number_format` on `data_labels` or an axis makes its rendered form, such as `28.0` for `0.0`, an accepted variant), and marker text that is not a category, data label, axis label, or legend entry needs a companion entry. Companion text (`caption`, `source`, `note`, `notes`, `footnote`, `footnotes`) exports as editable text boxes — strings or objects with `text`, `x`, `y`, `width`, `height` (slide coordinates), `font_size`, `color`, `align`, `bold`; use it for captions, sources, center labels, and annotations, and `data_labels` for point values. `style.colors` sets series colors (treemap/sunburst tile palette in order); the exporter writes explicit chart-area fill, plot-area fill, axis, gridline, and label colors (SVG-first infers them from the largest panel `<rect>`, text, and strokes; JSON-first from JSON or stable defaults), overridable under `style` with `chart_area_fill`, `plot_area_fill`, `text_color`, `axis_color`, `grid_color` (`"none"` for transparent); generated payloads use uppercase `#RRGGBB`, with `#RGB`, `rgb()`, and CSS names normalized. Negative bars keep the series fill. The final checker reports every SVG-first parity finding as a warning tagged `(blocks --native-charts-and-tables export)`; that export refuses the same findings as errors, so clear them before exporting the native variant. SVG-first metadata matches the fallback chrome; JSON-first owns it. Metadata sizes use SVG px (`1px = 0.75pt`); `style.font_family` and `title_font_size`, `subtitle_font_size`, `axis_font_size`, `axis_title_font_size`, `legend_font_size`, `note_font_size` are required only when native must preserve typography an SVG-first fallback cannot supply unambiguously (JSON-first never infers from its preview). A string or unbounded-object `title` becomes native `c:title` (`subtitle` line two); a title object with complete `x`, `y`, `width`, `height` becomes a companion text box (partial bounds or `subtitle` fail); `name` names the object; `title`, `subtitle`, and axis-title objects accept `text`, `font_size`, `font_family`, `color`; the checker rejects SVG-first title/axis text absent from the fallback. Axis titles are explicit via `axis_titles` (`category`, `value`, `x`, `y`, `secondary_value`) or the root aliases `category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, `secondary_value_axis_title`; `show_value_axis_labels: false` hides numeric tick labels (e.g. a radar without radial coordinates); native legends are opt-in via `show_legend: true` and `legend_position` (`bottom` default; `top` / `left` / `right`). SVG-first parity reads the fallback literally — `style.axis_color` is the dominant stroke among elements whose id or class names `axis`, `style.grid_color` the dominant stroke among those naming `grid` / `gridline`, and a role with no labeled stroke falls back to the marker's dominant stroke (axis and grid lines in different colors need those labels or one steals the other's dominant); bar/column fills that are not a series or point colour, line-chart circles with no `lineMarker` / `point_colors` (or in a colour neither carries), translucent area fills without `fill_opacity`, a stroked line or markers over a single-type area, and a bar/column fallback whose first and last category labels contradict the resolved `axes.category.reverse` are each reported; numbers match in written form (`286.20``286.2`; a payload `73.0` is stored as `73` and renders as `73` under General, so a fallback that shows `73.0` needs `number_format: "0.0"`; a `number_format` on `data_labels` or an axis makes its rendered form, such as `28.0` for `0.0`, an accepted variant), and marker text that is not a category, data label, axis label, or legend entry needs a companion entry. Companion text (`caption`, `source`, `note`, `notes`, `footnote`, `footnotes`) exports as editable text boxes — strings or objects with `text`, `x`, `y`, `width`, `height` (slide coordinates), `font_size`, `color`, `align`, `bold`; use it for captions, sources, center labels, and annotations, and `data_labels` for point values. On an SVG-first marker a companion whose text appears exactly once in the fallback is placed from that `<text>`: the box is bottom-anchored with its bottom edge a quarter em under that baseline (so the glyph bottom lands where the SVG drew it whatever ascent the viewer's font has), `text-anchor` decides which edge `x` names and the paragraph alignment, and the payload keeps only `width` — so a `y` copied from the SVG baseline does not push the box onto the plot. JSON-first and unmatched companions use the payload box. `style.colors` sets series colors (treemap/sunburst tile palette in order); the exporter writes explicit chart-area fill, plot-area fill, axis, gridline, and label colors (SVG-first infers them from the largest panel `<rect>`, text, and strokes; JSON-first from JSON or stable defaults), overridable under `style` with `chart_area_fill`, `plot_area_fill`, `text_color`, `axis_color`, `grid_color` (`"none"` for transparent); generated payloads use uppercase `#RRGGBB`, with `#RGB`, `rgb()`, and CSS names normalized. Negative bars keep the series fill. The final checker reports every SVG-first parity finding as a warning tagged `(blocks --native-charts-and-tables export)`; that export refuses the same findings as errors, so clear them before exporting the native variant.
@@ -148,14 +148,7 @@ top-level group that itself lacks `data-pptx-layer`, `data-pptx-role`, and
| Target state | Behavior | | Target state | Behavior |
|---|---| |---|---|
| Ordinary content group | Animatable; a legacy block resolves one row and `effects[]` may resolve several rows against the same final shape | | Ordinary content group | Animatable; a legacy block resolves one row and `effects[]` may resolve several rows against the same final shape |
| Legacy chrome-like id | Skipped unless explicitly named in `animations.json` | | Chrome-like ids, static roles/placeholders, and structural exclusions | See [`animations.md`](../../references/animations.md) §5 for defaults and explicit sidecar overrides |
| Explicit sidecar group override | May override only the legacy chrome-name heuristic |
| `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable |
An explicit sidecar entry cannot turn a Master/Layout/Slide structural layer or
an explicitly marked static page-frame role/placeholder into an animation
target. This boundary preserves PPTX structure even when a legacy id resembles
content.
--- ---
@@ -313,4 +306,4 @@ animation-to-video contract.
| `after_effect` | `none`, `dim` with `color`, `hide`, or `hide-on-next-click` | | `after_effect` | `none`, `dim` with `color`, `hide`, or `hide-on-next-click` |
| `sound` | Object-animation cue: project-relative or absolute `.m4a` / `.mp3` / `.wav` on low-level inputs; bundled selections use the synced project-relative `.wav` path | | `sound` | Object-animation cue: project-relative or absolute `.m4a` / `.mp3` / `.wav` on low-level inputs; bundled selections use the synced project-relative `.wav` path |
An unlisted SVG inherits the resolved deck-wide settings; a listed slide may contain only the `transition`, `animation`, `groups`, or `morph` fields it overrides; chrome groups (an id equal to, or holding a `-`/`_`-separated token from, `bg`, `background`, `header`, `footer`, `decor`, `decoration`, `decorations`, `chrome`, `nav`, `watermark`, `logo`, `pagenumber`, `pagenum`, `slidenumber`, `slidenum`, `rule`; `list-groups` names the excluded ids at the end of each slide line) and static role/placeholder groups are pinned to `none` unless explicitly named; a `data-pptx-layer` group never animates, and a full-canvas background rect becomes the slide background rather than a shape — except a `*-header` / `*-footer` group whose text reaches the page's median font size, which is read as the title block and stays an ordinary target; a group carrying `data-pptx-layer` or a static role/placeholder marker never animates. An unlisted SVG inherits the resolved deck-wide settings; a listed slide may contain only the `transition`, `animation`, `groups`, or `morph` fields it overrides. The chrome-name heuristic matches an id equal to, or holding a `-`/`_`-separated token from, `bg`, `background`, `header`, `footer`, `decor`, `decoration`, `decorations`, `chrome`, `nav`, `watermark`, `logo`, `pagenumber`, `pagenum`, `slidenumber`, `slidenum`, `rule`; `list-groups` names the excluded ids at the end of each slide line. A marker-free `*-header` / `*-footer` group whose text reaches the page's median font size is read as the title block and stays an ordinary target. For chrome defaults, explicit sidecar overrides, and structural exclusions, see [`animations.md`](../../references/animations.md) §5.
@@ -917,8 +917,8 @@ Behavior:
- Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level - Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices` - Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
- Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation` - Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation`
- Per-element animation applies to ordinary top-level SVG `<g id="...">` groups; each group is a PowerPoint shape-target anchor, not necessarily one Animation Pane row. Use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent - Per-element animation applies to ordinary top-level SVG `<g id="...">` groups; each group is a PowerPoint shape-target anchor, not necessarily one Animation Pane row. Use one group per logical Slide-local content unit rather than targeting a group count
- An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker - For chrome defaults, static role/placeholder overrides, and structural exclusions, see [`animations.md`](../../references/animations.md) §5
- Start mode is set globally by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), or `with-previous` (all together on slide entry). A sidecar row may override it with `trigger`; the slide value is only the inherited Start mode - Start mode is set globally by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), or `with-previous` (all together on slide entry). A sidecar row may override it with `trigger`; the slide value is only the inherited Start mode
- `on-click` is for live presentations only; recorded narration rejects every row that resolves to it, including a row with `trigger_shape`, because the tool does not generate object-level click timings - `on-click` is for live presentations only; recorded narration rejects every row that resolves to it, including a row with `trigger_shape`, because the tool does not generate object-level click timings
- Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide - Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide
@@ -1013,6 +1013,10 @@ Requirements:
`text_measure.py` imports the same single-line DrawingML width estimator used by `text_measure.py` imports the same single-line DrawingML width estimator used by
the SVG quality checker. the SVG quality checker.
Use Arial, Times New Roman, Georgia, Verdana, or Calibri for bundled per-glyph
advance measurements from `svg_to_pptx/drawingml/font_advances.json` in regular,
bold, italic, and bold-italic styles. Expect other families to keep the
class-average estimate, with the existing fixed advances for monospaced faces.
- `measure` prints one `width<TAB>text` line per input, or a JSON array with - `measure` prints one `width<TAB>text` line per input, or a JSON array with
`--json`. `--json`.
@@ -1180,6 +1184,23 @@ python3 scripts/svg_position_calculator.py calc line --data "68:0.5,71:1.5,49:2.
### `flatten_tspan.py` ### `flatten_tspan.py`
Positioned `x`/`y`/nonzero `dy` rows keep the existing split/preserve/reflow
behavior. A row starter's `dx` is consumed by its resolved line position;
later inline scalar `dx` stays with its run through flattening.
Native export represents inline `dx` with a separate NBSP run before the
affected text. Its `a:rPr@spc`, in hundredths of a point, is
`round(75 * (dx_px - estimated_space_width_px))`. The space estimate uses the
current run's font and size. This keeps both positive and negative movement
local to the boundary, including the first run, without changing tracking
inside a label. Font substitution and estimated space metrics can introduce
a small width difference. Spacing runs stay separate; positioned bullet
markers remain literal text so bullet extraction cannot remove the offset.
A small nonzero `dy` still starts a positioned row; it is not an inline
superscript/subscript displacement. Use the supported `baseline-shift` form
for inline vertical shifts.
```bash ```bash
python3 scripts/svg_finalize/flatten_tspan.py projects/<project>/svg_output python3 scripts/svg_finalize/flatten_tspan.py projects/<project>/svg_output
python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg
@@ -1224,6 +1245,10 @@ rely on — mapping tables, accepted-but-warned spellings, rejection boundaries,
and imported native-shape metadata — is documented in and imported native-shape metadata — is documented in
[`svg-contract.md`](svg-contract.md). This tool guide does not repeat it. [`svg-contract.md`](svg-contract.md). This tool guide does not repeat it.
For the first-pair approximation of odd-length or multi-segment custom
`stroke-dasharray` lists and stroke-width normalization, see
[`svg-contract.md`](svg-contract.md) §6.6.
`svg_quality_checker.py` validates source SVG before finalization. `svg_quality_checker.py` validates source SVG before finalization.
`finalize_svg.py` and native export apply the preprocessing required by that `finalize_svg.py` and native export apply the preprocessing required by that
contract, while native conversion fails on unsupported visual elements rather contract, while native conversion fails on unsupported visual elements rather
@@ -55,6 +55,14 @@ python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace
Consumes `templates/*.svg` directly, compiles the declared structured Master/Layout contract into `<authoring_workspace>/exports/<template_id>_template_preview.pptx` (creating `exports/` on demand), and reopens the result to verify one slide per prototype, the expected Master/Layout counts, exact Presentation → Master → Layout → Slide registration, distinct Theme parts per Master, valid unique `p14:creationId` and registration IDs, and — for `standard` / `fidelity` — that every carrier-bound placeholder on each review Slide has the same type, effective index, and full frame as its registered Layout placeholder. Authored modes use ephemeral SVG copies with concise preview-only sample text so long `{{...}}` markers stay readable; source SVGs, carrier typography, slot metadata, and Layout frames are unchanged. The default review keeps visible Chart/Table fallbacks; `--native-charts-and-tables -o <distinct_path>` writes a separately named JSON-first review. The first export refuses an existing output; `--force` replaces it intentionally. It needs no project `spec_lock.md`, creates no persistent project, and never infers structure. Consumes `templates/*.svg` directly, compiles the declared structured Master/Layout contract into `<authoring_workspace>/exports/<template_id>_template_preview.pptx` (creating `exports/` on demand), and reopens the result to verify one slide per prototype, the expected Master/Layout counts, exact Presentation → Master → Layout → Slide registration, distinct Theme parts per Master, valid unique `p14:creationId` and registration IDs, and — for `standard` / `fidelity` — that every carrier-bound placeholder on each review Slide has the same type, effective index, and full frame as its registered Layout placeholder. Authored modes use ephemeral SVG copies with concise preview-only sample text so long `{{...}}` markers stay readable; source SVGs, carrier typography, slot metadata, and Layout frames are unchanged. The default review keeps visible Chart/Table fallbacks; `--native-charts-and-tables -o <distinct_path>` writes a separately named JSON-first review. The first export refuses an existing output; `--force` replaces it intentionally. It needs no project `spec_lock.md`, creates no persistent project, and never infers structure.
## `apply_template.py`
```bash
python3 skills/ppt-master/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...] [--dry-run] [--skip-validation]
```
Runs the install half of [`apply-template-workspace.md`](../../workflows/stages/apply-template-workspace.md) §4 for the roots Stage 1 (Default) or the user (Quick) already selected; it selects nothing. Each `--root` is a workspace root exposing `templates/design_spec.md` (library shape) or one `templates/design_spec.<kind>.<id>.md` per kind (project shape); an inner `templates/` directory is refused so sibling `images/` and `icons/` travel with the spec. Per root it runs `svg_quality_checker.py <root>/templates --template-mode --canonical-authoring` (`--skip-validation` when that already ran in the same turn), labels the root `library` when it resolves to the index-derived `templates/<kind_dir>/<id>/` and `explicit` otherwise, and rejects a Style-only library package that carries `images/`, `icons/`, or `exports/`. It then resolves the structural owner (Layout when selected, otherwise Deck), maps every spec to `<project>/templates/design_spec.<kind>.<id>.md` with exactly one `> **Installed from**: \`<root>\` (library|explicit)` line under the H1, maps the owner's `templates/` roster and other non-bitmap structural files and every root's `images/` / `icons/` once, and refuses duplicate kinds, a root passed twice, and any destination that already exists with different bytes — all before writing; a byte-identical destination is reported as `[same]`, so a rerun is a no-op. A root equal to the target project is consumed in place; when a selected Layout supersedes that project's in-place Deck roster, the Deck's structural files are removed and the Layout roster written in the same run. `--dry-run` prints the mapping and receipt without writing. The last stdout line is the §5.3 completion receipt (`roots=…; sources=…; kinds=…; segments=…; active_roster=…; install=…; installed_specs=…`).
## `register_template.py` ## `register_template.py`
```bash ```bash
@@ -34,7 +34,7 @@ from console_encoding import configure_utf8_stdio
configure_utf8_stdio() configure_utf8_stdio()
import numpy as np import numpy as np
from PIL import Image from PIL import Image, ImageOps
# Algorithm parameters # Algorithm parameters
ALPHA_THRESHOLD = 0.002 # Alpha threshold; values below this are not processed ALPHA_THRESHOLD = 0.002 # Alpha threshold; values below this are not processed
@@ -156,7 +156,8 @@ def process_image(input_path: Path, output_path: Path | None = None, verbose: bo
Returns: Returns:
Output file path Output file path
""" """
image = Image.open(input_path) with Image.open(input_path) as source:
image = ImageOps.exif_transpose(source)
width, height = image.size width, height = image.size
config = detect_watermark_config(width, height) config = detect_watermark_config(width, height)
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Tencent Cloud TokenHub image generation backend.
Configuration keys:
TENCENT_API_KEY / TOKENHUB_API_KEY (required)
TENCENT_BASE_URL (optional; domestic base or full endpoint)
TENCENT_MODEL (optional; defaults to hy-image-v3)
TENCENT_REVISE (optional; Hy only, defaults to true)
TENCENT_WATERMARK (optional; Seedream only, defaults to false)
TENCENT_OUTPUT_FORMAT (optional; Seedream only, png or jpeg)
"""
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
if __name__ == "__main__":
print(__doc__)
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend tencent")
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import base64
import math
import os
import time
import requests
from image_backends.backend_common import ( # noqa: E402
MAX_RETRIES,
download_image,
http_error,
is_permanent_error,
is_rate_limit_error,
normalize_image_size,
require_api_key,
resolve_output_path,
retry_delay,
save_image_bytes,
)
DEFAULT_BASE_URL = "https://tokenhub.tencentmaas.com"
DEFAULT_MODEL = "hy-image-v3"
MODEL_ENDPOINTS = {
DEFAULT_MODEL: "/v1/wand/hunyuan-image/v3-generation",
"seedream-image-v5.0-pro": "/v1/wand/si-image/generation",
"seedream-image-v5.0-lite": "/v1/wand/si-image/generation",
}
SUPPORTED_MODELS = set(MODEL_ENDPOINTS)
IMAGE_SIZE_PIXELS = {"512px": 512, "1K": 1024, "2K": 2048, "4K": 4096}
def _validate_model(model: str) -> str:
"""Limit the backend to the synchronous TokenHub image contracts."""
resolved = model.strip()
if resolved not in SUPPORTED_MODELS:
detail = ""
if resolved.startswith("vidu"):
detail = " Vidu uses an asynchronous submit/query API that is not supported."
raise ValueError(
f"Unsupported Tencent model '{model}'. Supported: {sorted(SUPPORTED_MODELS)}.{detail}"
)
return resolved
def _resolve_url(base_url: str, model: str = DEFAULT_MODEL) -> str:
"""Resolve the model endpoint while accepting a full TokenHub URL."""
base = base_url.strip().rstrip("/")
if "/v1/wand/" in base:
return base
return base + MODEL_ENDPOINTS[model]
def _resolve_size(aspect_ratio: str, image_size: str, model: str = DEFAULT_MODEL) -> str:
"""Resolve a pixel size from the requested area and aspect ratio."""
normalized = normalize_image_size(image_size)
pixels = IMAGE_SIZE_PIXELS.get(normalized)
if pixels is None:
raise ValueError(
f"Unsupported image size '{image_size}' for Tencent backend. "
f"Supported logical sizes: {list(IMAGE_SIZE_PIXELS)}."
)
try:
ratio_width, ratio_height = (int(value) for value in aspect_ratio.split(":"))
if ratio_width <= 0 or ratio_height <= 0:
raise ValueError
except ValueError as exc:
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for Tencent backend. "
"Use positive width:height integers, such as 16:9."
) from exc
if model == "seedream-image-v5.0-lite":
pixels = max(pixels, 2048)
elif model == "seedream-image-v5.0-pro" and normalized not in {"1K", "2K"}:
raise ValueError(
f"Unsupported image size '{image_size}' for Seedream pro. "
"Use 1K or 2K; 4K requires seedream-image-v5.0-lite."
)
ratio = ratio_width / ratio_height
step = 16 if model == DEFAULT_MODEL else 1
width = math.floor(pixels * math.sqrt(ratio) / step) * step
height = math.floor(pixels / math.sqrt(ratio) / step) * step
if model == DEFAULT_MODEL and (
pixels > 1024
or not (512 <= width <= 2048 and 512 <= height <= 2048)
or width * height > 1024 * 1024
):
raise ValueError(
f"Unsupported image size '{image_size}' / aspect ratio '{aspect_ratio}' for Hy-Image-3.0. "
"Width and height must each be 512-2048 pixels in multiples of 16, "
"with area <= 1024x1024. Use 1K with a ratio from 1:4 to 4:1, or 512px at 1:1."
)
return f"{width}x{height}"
def _read_bool(name: str, default: bool) -> bool:
"""Read a boolean provider option without silently accepting typos."""
value = os.environ.get(name, str(default)).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise ValueError(f"Invalid argument: set {name} to true or false.")
def _generate_image(api_key: str, prompt: str,
aspect_ratio: str = "1:1", image_size: str = "1K",
output_dir: str = None, filename: str = None,
model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL) -> str:
"""Generate one image with the Tencent backend."""
model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size, model)
url = _resolve_url(base_url, model)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"prompt": prompt,
"size": size,
}
output_format = "png"
if model == DEFAULT_MODEL:
payload["revise"] = _read_bool("TENCENT_REVISE", True)
else:
output_format = (os.environ.get("TENCENT_OUTPUT_FORMAT") or "png").strip().lower()
if output_format not in {"png", "jpeg"}:
raise ValueError("Invalid argument: set TENCENT_OUTPUT_FORMAT to png or jpeg.")
payload.update({
"watermark": _read_bool("TENCENT_WATERMARK", False),
"response_format": "url",
"output_format": output_format,
})
print("[Tencent Cloud TokenHub]")
print(f" Model: {model}")
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
print(f" Aspect Ratio: {aspect_ratio}")
if model == "seedream-image-v5.0-lite" and normalize_image_size(image_size) in {"512px", "1K"}:
print(f" [INFO] Seedream lite requires at least 2K; upgrading {image_size} to 2K.")
print(f" Resolution: {size}")
print()
print(" [..] Generating...", end="", flush=True)
start = time.time()
response = requests.post(url, headers=headers, json=payload, timeout=300)
elapsed = time.time() - start
print(f"\n [DONE] Response received ({elapsed:.1f}s)")
if response.status_code != 200:
raise http_error(response, "Tencent image generation")
data = response.json()
items = data.get("data") or []
item = items[0] if items and isinstance(items[0], dict) else {}
image_url = item.get("url")
image_b64 = item.get("b64_json") if model != DEFAULT_MODEL else None
if not image_url and not image_b64:
raise RuntimeError(f"Tencent response missing image URL or base64 data: {data}")
path = resolve_output_path(prompt, output_dir, filename, f".{output_format}")
if image_url:
return download_image(image_url, path)
return save_image_bytes(base64.b64decode(image_b64), path, content_type=f"image/{output_format}")
def generate(prompt: str,
aspect_ratio: str = "1:1", image_size: str = "1K",
output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Tencent backend."""
resolved_model = _validate_model(model or os.environ.get("TENCENT_MODEL") or DEFAULT_MODEL)
normalized_size = normalize_image_size(image_size)
_resolve_size(aspect_ratio, normalized_size, resolved_model)
prompt_limit = 8192 if resolved_model == DEFAULT_MODEL else 600
if not prompt or len(prompt) > prompt_limit:
raise ValueError(f"Invalid request: {resolved_model} requires a prompt of 1-{prompt_limit} characters.")
api_key = require_api_key(
"TENCENT_API_KEY",
"TOKENHUB_API_KEY",
message=(
"No API key found. Set TENCENT_API_KEY or TOKENHUB_API_KEY "
"in the current environment or a .env file."
),
)
base_url = os.environ.get("TENCENT_BASE_URL") or DEFAULT_BASE_URL
last_error = None
for attempt in range(max_retries + 1):
try:
return _generate_image(
api_key=api_key,
prompt=prompt,
aspect_ratio=aspect_ratio,
image_size=normalized_size,
output_dir=output_dir,
filename=filename,
model=resolved_model,
base_url=base_url,
)
except Exception as exc:
last_error = exc
if is_permanent_error(exc):
raise
if attempt >= max_retries:
break
limited = is_rate_limit_error(exc)
delay = retry_delay(attempt, rate_limited=limited)
label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay)
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
@@ -14,6 +14,7 @@ Backend selection (`IMAGE_BACKEND` in `.env` or the current process environment)
IMAGE_BACKEND=qwen -> Alibaba Qwen image backend IMAGE_BACKEND=qwen -> Alibaba Qwen image backend
IMAGE_BACKEND=zhipu -> Zhipu GLM-Image backend IMAGE_BACKEND=zhipu -> Zhipu GLM-Image backend
IMAGE_BACKEND=volcengine -> Volcengine Seedream backend IMAGE_BACKEND=volcengine -> Volcengine Seedream backend
IMAGE_BACKEND=tencent -> Tencent Cloud TokenHub backend
IMAGE_BACKEND=modelscope -> ModelScope backend IMAGE_BACKEND=modelscope -> ModelScope backend
IMAGE_BACKEND=siliconflow -> SiliconFlow backend IMAGE_BACKEND=siliconflow -> SiliconFlow backend
IMAGE_BACKEND=fal -> fal.ai backend IMAGE_BACKEND=fal -> fal.ai backend
@@ -76,6 +77,8 @@ IMAGE_ENV_PREFIXES = (
"VOLCENGINE_", "VOLCENGINE_",
"LAS_", "LAS_",
"ARK_", "ARK_",
"TENCENT_",
"TOKENHUB_",
"MODELSCOPE_", "MODELSCOPE_",
"SILICONFLOW_", "SILICONFLOW_",
"FAL_", "FAL_",
@@ -154,6 +157,15 @@ BACKEND_REGISTRY = {
"key_hint": "LAS_API_KEY / VOLCENGINE_API_KEY / ARK_API_KEY", "key_hint": "LAS_API_KEY / VOLCENGINE_API_KEY / ARK_API_KEY",
"aliases": ["ark", "doubao", "seedream"], "aliases": ["ark", "doubao", "seedream"],
}, },
"tencent": {
"module": "backend_tencent",
"tier": "extended",
"label": "Tencent Cloud TokenHub",
"default_model": "hy-image-v3",
"default_image_size": "1K",
"key_hint": "TENCENT_API_KEY / TOKENHUB_API_KEY",
"aliases": ["tokenhub", "hunyuan", "tencentmaas"],
},
"modelscope": { "modelscope": {
"module": "backend_modelscope", "module": "backend_modelscope",
"tier": "experimental", "tier": "experimental",
@@ -446,7 +446,7 @@ def _normalize_multi_frame_jpeg(path: Path) -> bool:
if path.suffix.lower() not in {".jpg", ".jpeg"}: if path.suffix.lower() not in {".jpg", ".jpeg"}:
return False return False
try: try:
from PIL import Image # type: ignore from PIL import Image, ImageOps # type: ignore
except ImportError: except ImportError:
return False return False
try: try:
@@ -458,10 +458,10 @@ def _normalize_multi_frame_jpeg(path: Path) -> bool:
if not multi_frame: if not multi_frame:
return False return False
source.seek(0) source.seek(0)
frame = source.convert("RGB") frame = ImageOps.exif_transpose(source).convert("RGB")
save_kwargs: dict[str, object] = {"quality": 95} save_kwargs: dict[str, object] = {"quality": 95}
for key in ("exif", "icc_profile"): for key in ("exif", "icc_profile"):
value = source.info.get(key) value = frame.info.get(key)
if value: if value:
save_kwargs[key] = value save_kwargs[key] = value
except (OSError, ValueError, SyntaxError): except (OSError, ValueError, SyntaxError):
@@ -2740,7 +2740,7 @@ def _line_style(chart: ET.Element, series_nodes: list[ET.Element]) -> str:
def _category_values(cat: ET.Element | None) -> list[str]: def _category_values(cat: ET.Element | None) -> list[str]:
cache = _first_cache(cat, ("strCache", "strLit")) cache = _first_cache(cat, ("strCache", "strLit"))
if cache is not None: if cache is not None:
return [str(value) for value in _cache_point_values(cache)] return [str(value) if value is not None else "" for value in _cache_point_values(cache)]
cache = _first_cache(cat, ("numCache", "numLit")) cache = _first_cache(cat, ("numCache", "numLit"))
if cache is None: if cache is None:
return [] return []
@@ -2748,7 +2748,7 @@ def _category_values(cat: ET.Element | None) -> list[str]:
if format_code and format_code.lower() != "general": if format_code and format_code.lower() != "general":
raise _UnsupportedChart("unsupported-formatted-category-cache") raise _UnsupportedChart("unsupported-formatted-category-cache")
numbers = _numeric_cache_values(cache) numbers = _numeric_cache_values(cache)
return [str(value) for value in numbers] return [str(value) if value is not None else "" for value in numbers]
def _category_cache_is_numeric(chart: ET.Element) -> bool: def _category_cache_is_numeric(chart: ET.Element) -> bool:
@@ -2804,21 +2804,24 @@ def _series_name(ser: ET.Element, index: int) -> str:
def _text_cache_values(parent: ET.Element | None) -> list[str]: def _text_cache_values(parent: ET.Element | None) -> list[str]:
cache = _first_cache(parent, ("strCache", "strLit")) cache = _first_cache(parent, ("strCache", "strLit"))
if cache is not None: if cache is not None:
return [str(value) for value in _cache_point_values(cache)] return [str(value) if value is not None else "" for value in _cache_point_values(cache)]
cache = _first_cache(parent, ("numCache", "numLit")) cache = _first_cache(parent, ("numCache", "numLit"))
return [str(value) for value in _cache_point_values(cache)] return [str(value) if value is not None else "" for value in _cache_point_values(cache)]
def _numeric_values(parent: ET.Element | None) -> list[int | float]: def _numeric_values(parent: ET.Element | None) -> list[int | float | None]:
cache = _first_cache(parent, ("numCache", "numLit")) cache = _first_cache(parent, ("numCache", "numLit"))
if cache is None: if cache is None:
return [] return []
return _numeric_cache_values(cache) return _numeric_cache_values(cache)
def _numeric_cache_values(cache: ET.Element) -> list[int | float]: def _numeric_cache_values(cache: ET.Element) -> list[int | float | None]:
values: list[int | float] = [] values: list[int | float | None] = []
for value in _cache_point_values(cache): for value in _cache_point_values(cache):
if value is None:
values.append(None)
continue
number = float(value) number = float(value)
if not math.isfinite(number): if not math.isfinite(number):
raise _UnsupportedChart("unsupported-chart-cache") raise _UnsupportedChart("unsupported-chart-cache")
@@ -2836,7 +2839,7 @@ def _first_cache(parent: ET.Element | None, names: tuple[str, ...]) -> ET.Elemen
return None return None
def _cache_point_values(cache: ET.Element | None) -> list[str]: def _cache_point_values(cache: ET.Element | None) -> list[str | None]:
if cache is None: if cache is None:
return [] return []
points: dict[int, str] = {} points: dict[int, str] = {}
@@ -2861,11 +2864,10 @@ def _cache_point_values(cache: ET.Element | None) -> list[str]:
point_count = len(points) point_count = len(points)
if ( if (
point_count < 0 point_count < 0
or point_count != len(points) or any(idx >= point_count for idx in points)
or any(idx not in points for idx in range(point_count))
): ):
raise _UnsupportedChart("unsupported-chart-cache") raise _UnsupportedChart("unsupported-chart-cache")
return [points[idx] for idx in range(point_count)] return [points.get(idx) for idx in range(point_count)]
def _element_val(elem: ET.Element | None) -> str | None: def _element_val(elem: ET.Element | None) -> str | None:
@@ -323,6 +323,7 @@ def _render_combo(
float(value) float(value)
for series in all_series for series in all_series
for value in series.get("values") or [] for value in series.get("values") or []
if value is not None
] ]
if not all_series or not values: if not all_series or not values:
return [] return []
@@ -482,7 +483,7 @@ def _category_segments(
grouping: str, grouping: str,
) -> tuple[list[list[tuple[float, float]]], bool]: ) -> tuple[list[list[tuple[float, float]]], bool]:
raw = [ raw = [
[float(value) for value in item.get("values", [])[:count]] [float(value) if value is not None else 0.0 for value in item.get("values", [])[:count]]
for item in series for item in series
] ]
percent = grouping == "percentStacked" percent = grouping == "percentStacked"
@@ -539,6 +540,8 @@ def _render_bars(
fill = style.fill or "none" fill = style.fill or "none"
stroke = style.stroke or "none" stroke = style.stroke or "none"
for category_index, (start, end) in enumerate(row): for category_index, (start, end) in enumerate(row):
if item["values"][category_index] is None:
continue
offset = 0.0 if stacked else series_index * bar_span offset = 0.0 if stacked else series_index * bar_span
display_index = ( display_index = (
len(categories) - 1 - category_index len(categories) - 1 - category_index
@@ -625,28 +628,41 @@ def _render_lines_or_areas(
(x_positions[idx], _map(end, lo, hi, plot.y + plot.h, plot.y)) (x_positions[idx], _map(end, lo, hi, plot.y + plot.h, plot.y))
for idx, (_start, end) in enumerate(row) for idx, (_start, end) in enumerate(row)
] ]
if chart_type == "area": runs: list[list[int]] = [[]]
bottom = [ for idx, value in enumerate(item["values"][:count]):
(x_positions[idx], _map(start, lo, hi, plot.y + plot.h, plot.y)) if value is None:
for idx, (start, _end) in enumerate(row) if runs[-1]:
] runs.append([])
points = top + list(reversed(bottom)) else:
parts.append( runs[-1].append(idx)
f'<polygon points="{_points(points)}" fill="{fill_color}" ' for indices in runs:
f'fill-opacity="{_fmt(min(style.fill_opacity, 0.58))}" ' if not indices:
f'stroke="{line_color or "none"}" stroke-opacity="{_fmt(style.stroke_opacity)}" ' continue
f'stroke-width="{_fmt(max(0.7, style.stroke_width))}" ' points = [top[idx] for idx in indices]
'stroke-linejoin="round"/>' if chart_type == "area":
) bottom = [
elif line_color is not None: (x_positions[idx], _map(row[idx][0], lo, hi, plot.y + plot.h, plot.y))
parts.append( for idx in indices
f'<polyline points="{_points(top)}" fill="none" stroke="{line_color}" ' ]
f'stroke-opacity="{_fmt(style.stroke_opacity)}" ' points += list(reversed(bottom))
f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" ' parts.append(
f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>' f'<polygon points="{_points(points)}" fill="{fill_color}" '
) f'fill-opacity="{_fmt(min(style.fill_opacity, 0.58))}" '
f'stroke="{line_color or "none"}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
f'stroke-width="{_fmt(max(0.7, style.stroke_width))}" '
'stroke-linejoin="round"/>'
)
elif line_color is not None:
parts.append(
f'<polyline points="{_points(points)}" fill="none" stroke="{line_color}" '
f'stroke-opacity="{_fmt(style.stroke_opacity)}" '
f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" '
f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>'
)
if show_markers: if show_markers:
for x, y in top: for idx, (x, y) in enumerate(top):
if item["values"][idx] is None:
continue
radius = max(2.0, style.marker_size / 2) radius = max(2.0, style.marker_size / 2)
parts.append( parts.append(
f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" ' f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" '
@@ -657,6 +673,8 @@ def _render_lines_or_areas(
f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>' f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>'
) )
for idx, (x, y) in enumerate(top): for idx, (x, y) in enumerate(top):
if item["values"][idx] is None:
continue
labels = _point_data_labels(payload, item, idx) labels = _point_data_labels(payload, item, idx)
if labels: if labels:
value = float(item["values"][idx]) value = float(item["values"][idx])
@@ -1876,7 +1876,7 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
f'fill="none" stroke="#999999" stroke-dasharray="4 4"/>' f'fill="none" stroke="#999999" stroke-dasharray="4 4"/>'
f'<text x="{fmt_num(node.xfrm.x + node.xfrm.w / 2)}" ' f'<text x="{fmt_num(node.xfrm.x + node.xfrm.w / 2)}" '
f'y="{fmt_num(node.xfrm.y + node.xfrm.h / 2)}" ' f'y="{fmt_num(node.xfrm.y + node.xfrm.h / 2)}" '
f'text-anchor="middle" font-size="14" fill="#999999">' f'text-anchor="middle" font-family="Arial" font-size="14" fill="#999999">'
f"[{_xml_escape(label)}]</text>" f"[{_xml_escape(label)}]</text>"
) )
if chart_payload_metadata: if chart_payload_metadata:
@@ -1896,7 +1896,7 @@ def _graphic_preview_label(node: ShapeNode, label: str) -> str:
f'width="{fmt_num(node.xfrm.w)}" height="22" ' f'width="{fmt_num(node.xfrm.w)}" height="22" '
f'fill="#FFFFFF" fill-opacity="0.82" stroke="#999999" stroke-width="0.5"/>' f'fill="#FFFFFF" fill-opacity="0.82" stroke="#999999" stroke-width="0.5"/>'
f'<text x="{fmt_num(node.xfrm.x + 6)}" y="{fmt_num(node.xfrm.y + 15)}" ' f'<text x="{fmt_num(node.xfrm.x + 6)}" y="{fmt_num(node.xfrm.y + 15)}" '
f'font-size="11" fill="#666666">[{_xml_escape(label)}]</text>' f'font-family="Arial" font-size="11" fill="#666666">[{_xml_escape(label)}]</text>'
) )
@@ -29,7 +29,7 @@
"skills/ppt-master/references/executor-web-image.md": 750, "skills/ppt-master/references/executor-web-image.md": 750,
"skills/ppt-master/references/executor-notes.md": 1000, "skills/ppt-master/references/executor-notes.md": 1000,
"skills/ppt-master/references/shared-standards.md": 500, "skills/ppt-master/references/shared-standards.md": 500,
"skills/ppt-master/references/shared-standards-core.md": 7000, "skills/ppt-master/references/shared-standards-core.md": 7250,
"skills/ppt-master/references/svg-effects.md": 9500, "skills/ppt-master/references/svg-effects.md": 9500,
"skills/ppt-master/references/native-data-interface.md": 3000, "skills/ppt-master/references/native-data-interface.md": 3000,
"skills/ppt-master/references/pptx-structure-interface.md": 3000, "skills/ppt-master/references/pptx-structure-interface.md": 3000,
@@ -1035,7 +1035,7 @@
"files": [ "files": [
"skills/ppt-master/scripts/docs/conversion.md" "skills/ppt-master/scripts/docs/conversion.md"
], ],
"max_tokens": 7500 "max_tokens": 7750
}, },
"stage.shared.troubleshooting": { "stage.shared.troubleshooting": {
"description": "Conditional troubleshooting reference for generation failures.", "description": "Conditional troubleshooting reference for generation failures.",
@@ -1344,13 +1344,14 @@
}, },
{ {
"kind": "exact", "kind": "exact",
"fingerprint": "286b55cb84d7", "fingerprint": "b8f9b97d03fc",
"paths": [ "paths": [
"skills/ppt-master/templates/styles/academic-research/templates/design_spec.md", "skills/ppt-master/templates/styles/academic-research/templates/design_spec.md",
"skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md", "skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md",
"skills/ppt-master/templates/styles/creative-pitch/templates/design_spec.md", "skills/ppt-master/templates/styles/creative-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/incident-postmortem/templates/design_spec.md", "skills/ppt-master/templates/styles/incident-postmortem/templates/design_spec.md",
"skills/ppt-master/templates/styles/investor-pitch/templates/design_spec.md", "skills/ppt-master/templates/styles/investor-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md",
"skills/ppt-master/templates/styles/narrative-keynote/templates/design_spec.md", "skills/ppt-master/templates/styles/narrative-keynote/templates/design_spec.md",
"skills/ppt-master/templates/styles/operating-review/templates/design_spec.md", "skills/ppt-master/templates/styles/operating-review/templates/design_spec.md",
"skills/ppt-master/templates/styles/product-launch/templates/design_spec.md", "skills/ppt-master/templates/styles/product-launch/templates/design_spec.md",
@@ -1422,13 +1423,14 @@
}, },
{ {
"kind": "exact", "kind": "exact",
"fingerprint": "1136fd17656e", "fingerprint": "7e6ac64f0c0c",
"paths": [ "paths": [
"skills/ppt-master/templates/styles/academic-research/templates/design_spec.md", "skills/ppt-master/templates/styles/academic-research/templates/design_spec.md",
"skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md", "skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md",
"skills/ppt-master/templates/styles/creative-pitch/templates/design_spec.md", "skills/ppt-master/templates/styles/creative-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/incident-postmortem/templates/design_spec.md", "skills/ppt-master/templates/styles/incident-postmortem/templates/design_spec.md",
"skills/ppt-master/templates/styles/investor-pitch/templates/design_spec.md", "skills/ppt-master/templates/styles/investor-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md",
"skills/ppt-master/templates/styles/narrative-keynote/templates/design_spec.md", "skills/ppt-master/templates/styles/narrative-keynote/templates/design_spec.md",
"skills/ppt-master/templates/styles/operating-review/templates/design_spec.md", "skills/ppt-master/templates/styles/operating-review/templates/design_spec.md",
"skills/ppt-master/templates/styles/product-launch/templates/design_spec.md", "skills/ppt-master/templates/styles/product-launch/templates/design_spec.md",
@@ -1447,6 +1449,24 @@
"skills/ppt-master/templates/layouts/README.md" "skills/ppt-master/templates/layouts/README.md"
], ],
"reason": "Identical one-line pointer to routing.md §7 and apply-template-workspace in the kind READMEs; the rule owner is routing.md §7 (rule-owners R22)." "reason": "Identical one-line pointer to routing.md §7 and apply-template-workspace in the kind READMEs; the rule owner is routing.md §7 (rule-owners R22)."
},
{
"kind": "exact",
"fingerprint": "602f1c4f5ee3",
"paths": [
"skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md",
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md"
],
"reason": "mbb-consulting inherits the consulting-decision method and image direction by design; only its visual system differs."
},
{
"kind": "exact",
"fingerprint": "9e4ef33145e2",
"paths": [
"skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md",
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md"
],
"reason": "mbb-consulting inherits the consulting-decision method and image direction by design; only its visual system differs."
} }
] ]
}, },
@@ -109,25 +109,32 @@ class ImageRotator:
Number of images fixed Number of images fixed
""" """
target_path = Path(target_dir) target_path = Path(target_dir)
if not target_path.exists(): if not target_path.is_dir():
return 0 raise FileNotFoundError(f"Directory not found: {target_path}")
print(f"[AUTO] Checking EXIF orientation information...") print(f"[AUTO] Checking EXIF orientation information...")
fixed_count = 0 fixed_count = 0
failed_count = 0
valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF
# Pre-collect file list to avoid issues caused by modifying during iteration # Pre-collect file list to avoid issues caused by modifying during iteration
files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts] files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
for f in files: for f in files:
if self._fix_single_exif(f): try:
fixed_count += 1 if self._fix_single_exif(f):
fixed_count += 1
except RuntimeError as e:
print(f" [WARN] {e}")
failed_count += 1
if fixed_count > 0: if fixed_count > 0:
print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)") print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)")
else: elif not failed_count:
print(f"[INFO] No images requiring EXIF correction found") print(f"[INFO] No images requiring EXIF correction found")
if failed_count:
raise RuntimeError(f"EXIF correction failed for {failed_count} of {len(files)} image(s)")
return fixed_count return fixed_count
def generate_contact_sheet( def generate_contact_sheet(
@@ -356,10 +363,15 @@ class ImageRotator:
raise ValueError("Invalid input: not a file path nor a valid JSON string") raise ValueError("Invalid input: not a file path nor a valid JSON string")
elif isinstance(json_source, list): elif isinstance(json_source, list):
tasks = json_source tasks = json_source
else:
raise ValueError("Rotation tasks must be a JSON array")
if not isinstance(tasks, list):
raise ValueError("Rotation tasks must be a JSON array")
gif_paths = [] gif_paths = []
for task in tasks: for task in tasks:
if not isinstance(task, dict): if not isinstance(task, dict) or not isinstance(task.get('path'), str):
continue continue
task_path = self._normalize_task_path(task.get('path', '')) task_path = self._normalize_task_path(task.get('path', ''))
if Path(task_path).suffix.lower() == '.gif': if Path(task_path).suffix.lower() == '.gif':
@@ -375,13 +387,19 @@ class ImageRotator:
cwd = Path(os.getcwd()) cwd = Path(os.getcwd())
repo_root = self._repo_root() repo_root = self._repo_root()
stats = {'total': len(tasks), 'success': 0} stats = {'total': len(tasks), 'success': 0, 'failed': 0}
for task in tasks: for task in tasks:
if not isinstance(task, dict) or not isinstance(task.get('path'), str):
print(f"[ERROR] Invalid rotation task: {task!r}")
stats['failed'] += 1
continue
rel_path = self._normalize_task_path(task.get('path', '')) rel_path = self._normalize_task_path(task.get('path', ''))
rotation = task.get('rotation') rotation = task.get('rotation')
if not rel_path or rotation is None: if not rel_path or rotation is None:
print(f"[ERROR] Rotation task requires path and rotation: {task!r}")
stats['failed'] += 1
continue continue
# Absolute paths should stay absolute; repo-relative paths should resolve from repo root. # Absolute paths should stay absolute; repo-relative paths should resolve from repo root.
@@ -405,6 +423,7 @@ class ImageRotator:
if not target_file.exists(): if not target_file.exists():
print(f"[SKIP] File not found: {rel_path}") print(f"[SKIP] File not found: {rel_path}")
stats['failed'] += 1
continue continue
try: try:
@@ -413,6 +432,7 @@ class ImageRotator:
stats['success'] += 1 stats['success'] += 1
except Exception as e: except Exception as e:
print(f"[ERROR] {target_file.name}: {e}") print(f"[ERROR] {target_file.name}: {e}")
stats['failed'] += 1
return stats return stats
@@ -458,8 +478,7 @@ class ImageRotator:
) )
return True return True
except Exception as e: except Exception as e:
print(f" [WARN] Failed to read EXIF for {file_path.name}: {e}") raise RuntimeError(f"Failed to fix EXIF for {file_path.name}: {e}") from e
return False
def _get_exif_orientation(self, img: Image.Image) -> Optional[int]: def _get_exif_orientation(self, img: Image.Image) -> Optional[int]:
"""Get the Orientation value""" """Get the Orientation value"""
@@ -500,14 +519,15 @@ class ImageRotator:
if ccw_angle == 0: if ccw_angle == 0:
return return
prepared = ImageOps.exif_transpose(img)
if ccw_angle == 90: if ccw_angle == 90:
rotated = img.transpose(T.ROTATE_90) rotated = prepared.transpose(T.ROTATE_90)
elif ccw_angle == 180: elif ccw_angle == 180:
rotated = img.transpose(T.ROTATE_180) rotated = prepared.transpose(T.ROTATE_180)
elif ccw_angle == 270: elif ccw_angle == 270:
rotated = img.transpose(T.ROTATE_270) rotated = prepared.transpose(T.ROTATE_270)
else: else:
rotated = img.rotate(ccw_angle, expand=True) rotated = prepared.rotate(ccw_angle, expand=True)
rotated.load() rotated.load()
@@ -766,7 +786,7 @@ def main(argv: list[str] | None = None) -> int:
except Exception as e: except Exception as e:
print(f"[ERROR] Execution failed: {e}") print(f"[ERROR] Execution failed: {e}")
return 1 return 1
return 0 return 1 if stats['failed'] else 0
if args.command == 'auto': if args.command == 'auto':
target_dir = args.images_directory target_dir = args.images_directory
@@ -235,12 +235,13 @@ def _expand_cell(
style = cell_styles[style_name] style = cell_styles[style_name]
expanded = _format_layers(defaults["cell"], style, cell) expanded = _format_layers(defaults["cell"], style, cell)
if "paragraphs" not in expanded: # A plain-text cell is one run, and a text-only paragraph is one run in
# A plain-text cell is one run: run defaults reach it unless the # the cell's colour: run defaults reach both through the cell unless the
# cell (or its cell_style / cell defaults) already set the field. # cell (or its cell_style / cell defaults) already set the field. Runs
for field in _PLAIN_TEXT_RUN_FIELDS: # spelled out under paragraphs[].runs take the same defaults directly.
if field not in expanded and field in defaults["run"]: for field in _PLAIN_TEXT_RUN_FIELDS:
expanded[field] = copy.deepcopy(defaults["run"][field]) if field not in expanded and field in defaults["run"]:
expanded[field] = copy.deepcopy(defaults["run"][field])
if "paragraphs" in expanded: if "paragraphs" in expanded:
paragraphs = expanded["paragraphs"] paragraphs = expanded["paragraphs"]
if not isinstance(paragraphs, list): if not isinstance(paragraphs, list):
@@ -53,6 +53,7 @@ from PIL import (
ImageChops, ImageChops,
ImageFilter, ImageFilter,
ImageMath, ImageMath,
ImageOps,
) )
_GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$") _GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$")
@@ -663,7 +664,8 @@ def slice_sheet(
if suffix and suffix != ".png": if suffix and suffix != ".png":
raise ValueError(f"--alpha requires .png output names, got {name!r}") raise ValueError(f"--alpha requires .png output names, got {name!r}")
sheet = Image.open(sheet_path).convert("RGBA") with Image.open(sheet_path) as source:
sheet = ImageOps.exif_transpose(source).convert("RGBA")
sw, sh = sheet.size sw, sh = sheet.size
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -21,6 +21,7 @@ Dependencies:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import codecs
import json import json
import os import os
import subprocess import subprocess
@@ -91,18 +92,24 @@ def _dispatch_output_arg(
batch_mode = True batch_mode = True
if output_arg and batch_mode and conversion_type == "web": if output_arg and batch_mode and conversion_type == "web":
return None return None
if output_arg and batch_mode:
return str(
unique_output_path(
Path(output_arg),
default_markdown_path(input_arg).stem,
used_outputs,
)
)
if output_arg:
return output_arg
if batch_mode and conversion_type != "web": if batch_mode and conversion_type != "web":
return str(default_markdown_path(input_arg)) default_output = default_markdown_path(input_arg)
if output_arg:
default_output = Path(output_arg) / default_output.name
output = unique_output_path(default_output.parent, default_output.stem, used_outputs)
if output != default_output:
_print_status(
f"[INFO] Renamed output for {input_arg}: {default_output} -> {output} "
"(input/output collision)"
)
return str(output)
if output_arg:
# One input with an extension-less -o names the Markdown file, not a
# directory: `-o sources_cf` writes `sources_cf.md` (a directory is
# spelled with a trailing separator or already exists).
if not Path(output_arg).suffix:
return f"{output_arg}.md"
return output_arg
return None return None
@@ -139,20 +146,54 @@ def write_passthrough(
"""Copy text-like input to Markdown and write the profile sidecar.""" """Copy text-like input to Markdown and write the profile sidecar."""
source = Path(input_arg) source = Path(input_arg)
try: try:
text = source.read_text(encoding="utf-8", errors="replace") raw = source.read_bytes()
except OSError as exc: except OSError as exc:
print(f"[ERROR] Cannot read {source}: {exc}", file=sys.stderr) print(f"[ERROR] Cannot read {source}: {exc}", file=sys.stderr)
return 1 return 1
encodings = ("utf-8", "gb18030")
if raw.startswith(codecs.BOM_UTF8):
encodings = ("utf-8-sig",)
elif raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
encodings = ("utf-16",)
for encoding in encodings:
try:
text = raw.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
print(
f"[ERROR] Cannot decode {source} as {' or '.join(encodings)}. "
"Save the source as UTF-8 text and retry.",
file=sys.stderr,
)
return 1
if any(ord(char) < 32 and char not in "\t\n\r\f" for char in text):
print(f"[ERROR] Binary control characters in {source}; provide a text file.", file=sys.stderr)
return 1
if encoding != "utf-8" and output.resolve() == source.resolve():
print(
f"[ERROR] {source} uses {encoding}; choose a different -o path for UTF-8 output.",
file=sys.stderr,
)
return 1
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
if output.resolve() != source.resolve(): if output.resolve() != source.resolve():
output.write_text(text, encoding="utf-8") output.write_bytes(text.encode("utf-8"))
warnings = []
if encoding != "utf-8":
warnings.append(f"Detected source encoding: {encoding}; converted to UTF-8.")
profile = write_conversion_profile( profile = write_conversion_profile(
input_path=input_arg, input_path=input_arg,
markdown_path=output, markdown_path=output,
converter="source_to_md.py", converter="source_to_md.py",
conversion_type=conversion_type, conversion_type=conversion_type,
warnings=warnings,
) )
for warning in warnings:
print(f"[INFO] {warning}")
_print_status(f"[OK] Saved Markdown to: {output}") _print_status(f"[OK] Saved Markdown to: {output}")
_print_status(f" Wrote conversion profile -> {profile}") _print_status(f" Wrote conversion profile -> {profile}")
print_output(output) print_output(output)
@@ -255,6 +296,10 @@ def dispatch_single(
output = Path(output_arg) if output_arg else None output = Path(output_arg) if output_arg else None
emit_result: Path | None = None emit_result: Path | None = None
extra_args = list(unknown_args) extra_args = list(unknown_args)
if _skips_images(args) and "--no-images" not in extra_args:
# web_to_md keeps remote image links instead of downloading the
# page's images into `<stem>_files/`.
extra_args.append("--no-images")
if output is None: if output is None:
emit_file = tempfile.NamedTemporaryFile( emit_file = tempfile.NamedTemporaryFile(
prefix="ppt-master-web-result-", prefix="ppt-master-web-result-",
@@ -376,7 +421,10 @@ converter, so existing converter behavior remains the source of truth.
parser.add_argument( parser.add_argument(
"--no-images", "--no-images",
action="store_true", action="store_true",
help="Alias for --images none on PDF inputs", help=(
"Skip images: PDF image mode none; web pages keep remote image "
"links instead of downloading; no-op for Markdown/text"
),
) )
parser.add_argument( parser.add_argument(
"--filter-images", "--filter-images",
@@ -406,11 +454,29 @@ def _conversion_type_for_input(input_arg: str, requested_type: str) -> str:
return requested_type return requested_type
def _skips_images(args: argparse.Namespace) -> bool:
return bool(args.no_images or args.images == "none")
def _validate_pdf_image_flags(args: argparse.Namespace, conversion_types: list[str]) -> bool: def _validate_pdf_image_flags(args: argparse.Namespace, conversion_types: list[str]) -> bool:
if not _has_pdf_image_flags(args): if not _has_pdf_image_flags(args):
return True return True
if any(conversion_type != "pdf" for conversion_type in conversion_types): skip_only = _skips_images(args) and not (
print("[ERROR] Image extraction flags are currently supported only for PDFs", file=sys.stderr) args.filter_images or args.render_vector_figures
)
for conversion_type in conversion_types:
if conversion_type == "pdf":
continue
# Web pages keep remote links; Markdown/text passthrough has no
# images to skip, so the flag is accepted as a no-op there.
if conversion_type in {"web", "markdown", "text"} and skip_only:
continue
print(
"[ERROR] Image extraction flags are supported only for PDFs; "
"--no-images (or --images none) also applies to web pages and "
"Markdown/text passthrough",
file=sys.stderr,
)
return False return False
return True return True
@@ -432,17 +498,35 @@ def dispatch_many(
if output_dir.exists() and not output_dir.is_dir(): if output_dir.exists() and not output_dir.is_dir():
print(f"[ERROR] Batch output path is not a directory: {args.output}", file=sys.stderr) print(f"[ERROR] Batch output path is not a directory: {args.output}", file=sys.stderr)
return 1 return 1
output_dir.mkdir(parents=True, exist_ok=True)
used_outputs: set[Path] = set() input_paths = {Path(item).resolve() for item in inputs if not is_url(item)}
for input_arg, conversion_type in zip(inputs, conversion_types): used_outputs = set(input_paths)
output_arg = _dispatch_output_arg( output_args = [
_dispatch_output_arg(
input_arg, input_arg,
conversion_type, conversion_type,
args.output, args.output,
batch_mode, batch_mode,
used_outputs, used_outputs,
) )
for input_arg, conversion_type in zip(inputs, conversion_types)
]
if args.output and not batch_mode and output_args:
output = Path(output_args[0])
own_passthrough = (
output.resolve() in input_paths and conversion_types[0] in {"markdown", "text"}
)
if output.exists() and not own_passthrough:
print(
f"[ERROR] Refusing to overwrite existing file: {output}. Choose a different -o path.",
file=sys.stderr,
)
return 1
if args.output and batch_mode:
Path(args.output).mkdir(parents=True, exist_ok=True)
for input_arg, conversion_type, output_arg in zip(inputs, conversion_types, output_args):
web_output_dir = ( web_output_dir = (
args.output args.output
if args.output and batch_mode and conversion_type == "web" if args.output and batch_mode and conversion_type == "web"
@@ -79,7 +79,9 @@ def _format_cell_value(value: Any) -> str:
if isinstance(value, time): if isinstance(value, time):
return value.isoformat(timespec="seconds") return value.isoformat(timespec="seconds")
if isinstance(value, float): if isinstance(value, float):
return _markdown_escape(f"{value:g}") # Excel shows 15 significant digits; that keeps 1234567.89 exact while
# 0.1 + 0.2 reads as 0.3 rather than its binary expansion.
return _markdown_escape(repr(float(f"{value:.15g}")).removesuffix(".0"))
return _markdown_escape(str(value)) return _markdown_escape(str(value))
@@ -5,6 +5,8 @@ Uses PyMuPDF to extract PDF text content and convert to Markdown format.
Supports heading levels, bold, italic, and list detection. Supports heading levels, bold, italic, and list detection.
""" """
from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
@@ -24,11 +26,17 @@ from _conversion_profile import write_conversion_profile_best_effort # noqa: E4
configure_utf8_stdio() configure_utf8_stdio()
try: # Help must not depend on the optional conversion packages: a stdlib-only
import fitz # PyMuPDF # interpreter still gets the argparse usage (docs/rules/code-style.md §4).
except ImportError: _HELP_REQUESTED = __name__ == "__main__" and any(
print("[ERROR] PyMuPDF not installed. Run: pip install PyMuPDF", file=sys.stderr) arg in {"-h", "--help"} for arg in sys.argv[1:]
sys.exit(1) )
if not _HELP_REQUESTED:
try:
import fitz # PyMuPDF
except ImportError:
print("[ERROR] PyMuPDF not installed. Run: pip install PyMuPDF", file=sys.stderr)
sys.exit(1)
FONT_BODY_SIZE = 12 FONT_BODY_SIZE = 12
FONT_H1_SIZE = 24 FONT_H1_SIZE = 24
@@ -1746,7 +1754,7 @@ def extract_pdf_to_markdown(
return markdown_content return markdown_content
def main() -> int: def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point.""" """Run the CLI entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='PDF to Markdown converter (with structure detection and LLM optimization)', description='PDF to Markdown converter (with structure detection and LLM optimization)',
@@ -1793,7 +1801,7 @@ Structure detection features:
help=f'DPI for --render-vector-figures output (default: {VECTOR_FIGURE_DPI})', help=f'DPI for --render-vector-figures output (default: {VECTOR_FIGURE_DPI})',
) )
args = parser.parse_args() args = parser.parse_args(argv)
return run_path_batch( return run_path_batch(
args.inputs, args.inputs,
@@ -50,13 +50,23 @@ from pptx_ooxml.diagram_read import ( # noqa: E402
smartart_to_markdown, smartart_to_markdown,
) )
from pptx import Presentation
from pptx.enum.action import PP_ACTION
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
configure_utf8_stdio() configure_utf8_stdio()
# Help must not depend on the optional conversion packages: a stdlib-only
# interpreter still gets the argparse usage (docs/rules/code-style.md §4).
_HELP_REQUESTED = __name__ == "__main__" and any(
arg in {"-h", "--help"} for arg in sys.argv[1:]
)
if not _HELP_REQUESTED:
try:
from pptx import Presentation
from pptx.enum.action import PP_ACTION
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
except ImportError:
print("[ERROR] python-pptx not installed. Run: pip install python-pptx", file=sys.stderr)
sys.exit(1)
EMU_PER_INCH = 914400 EMU_PER_INCH = 914400
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
@@ -1294,7 +1304,7 @@ def convert_presentation_to_markdown(
return markdown_content return markdown_content
def main() -> int: def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point.""" """Run the CLI entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Convert PowerPoint files to Markdown", description="Convert PowerPoint files to Markdown",
@@ -1320,7 +1330,7 @@ Legacy .ppt is not parsed directly. Resave it as .pptx or export it to PDF first
help="Output Markdown file for one input, or output directory for multiple inputs/directories", help="Output Markdown file for one input, or output directory for multiple inputs/directories",
) )
args = parser.parse_args() args = parser.parse_args(argv)
return run_path_batch( return run_path_batch(
args.inputs, args.inputs,
@@ -26,6 +26,8 @@ TLS fingerprint handling:
(scripts/source_to_md/web_to_md.cjs) remains available as a fallback. (scripts/source_to_md/web_to_md.cjs) remains available as a fallback.
""" """
from __future__ import annotations
import argparse import argparse
import codecs import codecs
import datetime import datetime
@@ -50,13 +52,19 @@ from _conversion_profile import ( # noqa: E402
configure_utf8_stdio() configure_utf8_stdio()
try: # Help must not depend on the optional conversion packages: a stdlib-only
import requests # interpreter still gets the argparse usage (docs/rules/code-style.md §4).
from bs4 import BeautifulSoup, Comment, NavigableString, Tag _HELP_REQUESTED = __name__ == "__main__" and any(
except ImportError: arg in {"-h", "--help"} for arg in sys.argv[1:]
print("Error: This script requires 'requests' and 'beautifulsoup4'.") )
print("Please run: pip install requests beautifulsoup4") if not _HELP_REQUESTED:
sys.exit(1) try:
import requests
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
except ImportError:
print("Error: This script requires 'requests' and 'beautifulsoup4'.", file=sys.stderr)
print("Please run: pip install requests beautifulsoup4", file=sys.stderr)
sys.exit(1)
# Prefer curl_cffi for TLS-fingerprint impersonation (bypasses JA3 blocking on # Prefer curl_cffi for TLS-fingerprint impersonation (bypasses JA3 blocking on
# sites like WeChat). Fall back to plain requests when it's not installed. # sites like WeChat). Fall back to plain requests when it's not installed.
@@ -174,12 +182,13 @@ def _decode_response_text(response) -> str:
return raw.decode("utf-8", errors="replace") return raw.decode("utf-8", errors="replace")
try: try:
from PIL import Image from PIL import Image, ImageOps
PILLOW_AVAILABLE = True PILLOW_AVAILABLE = True
except ImportError: except ImportError:
PILLOW_AVAILABLE = False PILLOW_AVAILABLE = False
print("[WARN] Pillow not installed. WebP images will not be converted to PNG.") if not _HELP_REQUESTED:
print(" Run: pip install Pillow") print("[WARN] Pillow not installed. WebP images will not be converted to PNG.", file=sys.stderr)
print(" Run: pip install Pillow", file=sys.stderr)
# ============ Config ============ # ============ Config ============
CONFIG = { CONFIG = {
@@ -215,14 +224,14 @@ CONFIG = {
} }
def fetch_url(url: str) -> str: def fetch_url(url: str) -> tuple[str, str]:
"""Fetch a web page with explicit headers and encoding detection. """Fetch a web page with explicit headers and encoding detection.
Args: Args:
url: Target URL. url: Target URL.
Returns: Returns:
The response body as text. The response body as text and the final URL after redirects.
""" """
headers = { headers = {
"User-Agent": CONFIG["user_agent"], "User-Agent": CONFIG["user_agent"],
@@ -235,7 +244,7 @@ def fetch_url(url: str) -> str:
timeout=CONFIG["timeout"], verify=False) timeout=CONFIG["timeout"], verify=False)
response.raise_for_status() response.raise_for_status()
return _decode_response_text(response) return _decode_response_text(response), response.url
except Exception as e: except Exception as e:
raise Exception(f"Failed to fetch {url}: {str(e)}") raise Exception(f"Failed to fetch {url}: {str(e)}")
@@ -383,7 +392,8 @@ def download_and_rewrite_images(
# Convert webp to png (optimized) # Convert webp to png (optimized)
try: try:
img_data = io.BytesIO(resp.content) img_data = io.BytesIO(resp.content)
pil_image = Image.open(img_data) with Image.open(img_data) as source:
pil_image = ImageOps.exif_transpose(source)
# Update filename to .png # Update filename to .png
converted_from = filename converted_from = filename
@@ -714,7 +724,9 @@ def element_to_markdown(element: Tag | NavigableString | None) -> str:
return f"{content} " return f"{content} "
def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str: def simple_html_to_markdown_traversal(
soup: Tag | BeautifulSoup | None, page_url: str = "",
) -> str:
"""Convert HTML content to Markdown using BeautifulSoup traversal.""" """Convert HTML content to Markdown using BeautifulSoup traversal."""
lines = [] lines = []
@@ -727,17 +739,17 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
if isinstance(node, NavigableString): if isinstance(node, NavigableString):
text = str(node) text = str(node)
# Normalize whitespace but keep single spaces # Normalize whitespace but keep single spaces
text = re.sub(r'\s+', ' ', text) return re.sub(r'\s+', ' ', text)
if text.strip():
return text
return ""
if node.name in ['script', 'style', 'comment', 'meta', 'link']: if node.name in ['script', 'style', 'comment', 'meta', 'link']:
return "" return ""
# Handle Block Elements # Handle Block Elements
is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4', is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr'] 'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr',
'section', 'article', 'main', 'header', 'footer', 'nav',
'aside', 'ul', 'ol', 'dl', 'dt', 'dd', 'figure',
'figcaption', 'address', 'details', 'summary']
# Pre-processing # Pre-processing
prefix = "" prefix = ""
@@ -762,6 +774,8 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
elif node.name == 'pre': elif node.name == 'pre':
# Extract raw text from pre to preserve formatting # Extract raw text from pre to preserve formatting
return f"\n\n```\n{node.get_text()}\n```\n\n" return f"\n\n```\n{node.get_text()}\n```\n\n"
elif is_block:
prefix, suffix = "\n\n", "\n\n"
# Inline formatting # Inline formatting
if node.name in ['strong', 'b']: if node.name in ['strong', 'b']:
@@ -772,7 +786,9 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
prefix, suffix = "`", "`" prefix, suffix = "`", "`"
elif node.name == 'a': elif node.name == 'a':
href = node.get('href') href = node.get('href')
if href and not href.startswith('javascript:'): if href and not href.lower().startswith('javascript:'):
if not href.startswith('#') and urlparse(href).scheme.lower() not in {'mailto', 'tel'}:
href = urljoin(page_url, href)
prefix = "[" prefix = "["
suffix = f"]({href})" suffix = f"]({href})"
else: else:
@@ -789,7 +805,18 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
for child in node.children: for child in node.children:
res = traverse(child) res = traverse(child)
if res: if res:
inner_text += res if isinstance(child, NavigableString) and not res.strip():
if inner_text and not inner_text.endswith((' ', '\n')):
inner_text += ' '
else:
if res.startswith('\n'):
inner_text = inner_text.rstrip(' ')
elif inner_text.endswith((' ', '\n')) and child.name != 'br':
res = res.lstrip(' ')
inner_text += res
if is_block:
inner_text = inner_text.strip()
# Post-processing for tables (simplified) # Post-processing for tables (simplified)
if node.name == 'tr': if node.name == 'tr':
@@ -828,6 +855,56 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
return md or "" return md or ""
PLAIN_TEXT_SUFFIXES = (".md", ".markdown", ".txt")
def is_plain_text_document(url: str, body: str) -> bool:
"""Return whether a fetched URL is raw Markdown / plain text, not HTML.
A raw file such as ``.../CHANGELOG.md`` on raw.githubusercontent.com is
already Markdown; running it through the HTML extractor loses the file or
fails on a missing body. The URL suffix decides, guarded by the absence of
an HTML document tag near the top of the body.
"""
path = urlparse(url).path.lower()
if not path.endswith(PLAIN_TEXT_SUFFIXES):
return False
head = body[:4096].lower()
return "<html" not in head and "<body" not in head and "<!doctype html" not in head
def _save_plain_text_document(
url: str, body: str, output_file: str | None,
) -> tuple[bool, str, str | None, str | None]:
stem = os.path.splitext(os.path.basename(urlparse(url).path))[0]
output_path = output_file or os.path.join(
CONFIG["output_dir"], f"{derive_base_name(stem, url)}.md",
)
output_dirname = os.path.dirname(output_path) or "."
os.makedirs(output_dirname, exist_ok=True)
header = (
"<!--\n"
f" Source: {url}\n"
f" Crawled: {datetime.datetime.now().isoformat()}\n"
" Format: raw Markdown / plain text saved verbatim\n"
"-->\n\n"
)
with open(output_path, "w", encoding="utf-8") as f:
f.write(header + body)
profile_path = write_conversion_profile_best_effort(
input_path=url,
markdown_path=output_path,
converter="web_to_md.py",
conversion_type="web",
asset_dir=None,
)
print(f" [OK] Raw Markdown / plain text: {len(body)} chars saved verbatim")
print(f" [OK] Saved: {output_path}")
if profile_path:
print(f" [OK] Conversion profile: {profile_path}")
return True, url, None, output_path
def process_url( def process_url(
url: str, url: str,
output_file: str | None = None, output_file: str | None = None,
@@ -842,8 +919,12 @@ def process_url(
""" """
print(f"\n[Fetching] {url}") print(f"\n[Fetching] {url}")
try: try:
html = fetch_url(url) html, page_url = fetch_url(url)
if is_plain_text_document(url, html):
return _save_plain_text_document(url, html, output_file)
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
base = soup.find('base', href=True)
base_url = urljoin(page_url, base['href']) if base else page_url
# Extract Metadata # Extract Metadata
metadata = extract_metadata(soup, url) metadata = extract_metadata(soup, url)
@@ -872,15 +953,15 @@ def process_url(
image_count = 0 image_count = 0
if download_images: if download_images:
image_count = download_and_rewrite_images( image_count = download_and_rewrite_images(
content_div, url, image_dir, rel_image_prefix) content_div, base_url, image_dir, rel_image_prefix)
else: else:
rewrite_images_to_remote_urls(content_div, url) rewrite_images_to_remote_urls(content_div, base_url)
if image_count: if image_count:
print(f" [OK] Images: {image_count} saved to {image_dir}") print(f" [OK] Images: {image_count} saved to {image_dir}")
# Convert to MD # Convert to MD
# Note: We pass the element to our traversal function # Note: We pass the element to our traversal function
markdown_text = simple_html_to_markdown_traversal(content_div) markdown_text = simple_html_to_markdown_traversal(content_div, base_url)
print(f" [OK] Content: {len(markdown_text)} chars") print(f" [OK] Content: {len(markdown_text)} chars")
# Construct content # Construct content
@@ -1018,7 +1099,8 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
# Disable warnings for verify=False if needed, though often useful to see if not _HELP_REQUESTED:
import urllib3 # Disable warnings for verify=False if needed, though often useful to see
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
raise SystemExit(main()) raise SystemExit(main())
@@ -108,7 +108,7 @@ _SLIDE_CACHE_LOCK = threading.Lock()
_SLIDE_CACHE: dict = {} # path -> (mtime, (content, warnings)) _SLIDE_CACHE: dict = {} # path -> (mtime, (content, warnings))
_LIST_CACHE_LOCK = threading.Lock() _LIST_CACHE_LOCK = threading.Lock()
_LIST_CACHE: dict = {} # path -> (mtime, annotation_count_on_disk) _LIST_CACHE: dict = {} # path -> (mtime, (annotation_count_on_disk, ok, error))
# Keep live preview on a separate range from Confirm UI so a stale preview tab # Keep live preview on a separate range from Confirm UI so a stale preview tab
# cannot send ``/api/shutdown`` to a later Confirm UI process. # cannot send ``/api/shutdown`` to a later Confirm UI process.
@@ -558,8 +558,8 @@ def create_app(
ok = True ok = True
error_msg: Optional[str] = None error_msg: Optional[str] = None
disk_count = _cache_get(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime) cached = _cache_get(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime)
if disk_count is None: if cached is None:
try: try:
tree = ET.parse(path_str) tree = ET.parse(path_str)
disk_count = len(parse_annotations(tree.getroot())) disk_count = len(parse_annotations(tree.getroot()))
@@ -568,7 +568,9 @@ def create_app(
error_msg = f'XML parse error: {exc}' error_msg = f'XML parse error: {exc}'
disk_count = 0 disk_count = 0
logger.warning('slide parse failed: %s: %s', svg_file.name, exc) logger.warning('slide parse failed: %s: %s', svg_file.name, exc)
_cache_put(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime, disk_count) _cache_put(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime, (disk_count, ok, error_msg))
else:
disk_count, ok, error_msg = cached
if svg_file.name in annotations: if svg_file.name in annotations:
annotation_count = len(annotations[svg_file.name]) annotation_count = len(annotations[svg_file.name])
@@ -33,7 +33,7 @@ from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio() configure_utf8_stdio()
try: try:
from PIL import Image from PIL import Image, ImageOps
except ImportError: except ImportError:
print("Error: PIL (Pillow) is required. Run: pip install Pillow") print("Error: PIL (Pillow) is required. Run: pip install Pillow")
exit(1) exit(1)
@@ -243,7 +243,8 @@ def process_svg_images(
try: try:
# Open and process image # Open and process image
img = Image.open(img_path) with Image.open(img_path) as source:
img = ImageOps.exif_transpose(source)
output_is_png = img_path.suffix.lower() == '.png' output_is_png = img_path.suffix.lower() == '.png'
# Preserve alpha for PNG assets such as translucent overlays. # Preserve alpha for PNG assets such as translucent overlays.
@@ -87,6 +87,7 @@ def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
try: try:
from PIL import Image as PILImage from PIL import Image as PILImage
from PIL import ImageOps
import io import io
except ImportError: except ImportError:
return img_bytes return img_bytes
@@ -109,6 +110,8 @@ def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
f"exempt from size limits") f"exempt from size limits")
return img_bytes return img_bytes
source_format = img.format
img = ImageOps.exif_transpose(img)
changed = False changed = False
# Downscale if exceeding max_dimension # Downscale if exceeding max_dimension
@@ -131,7 +134,7 @@ def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
img.save(buf, format='PNG', optimize=True) img.save(buf, format='PNG', optimize=True)
else: else:
# For other formats, just re-save # For other formats, just re-save
fmt = img.format or 'PNG' fmt = source_format or 'PNG'
img.save(buf, format=fmt) img.save(buf, format=fmt)
optimized = buf.getvalue() optimized = buf.getvalue()
@@ -42,7 +42,7 @@ configure_utf8_stdio()
# Try to import PIL for getting image dimensions # Try to import PIL for getting image dimensions
try: try:
from PIL import Image from PIL import Image, ImageOps
HAS_PIL = True HAS_PIL = True
except ImportError: except ImportError:
HAS_PIL = False HAS_PIL = False
@@ -54,7 +54,7 @@ def get_image_dimensions_pil(image_path: str) -> tuple[int | None, int | None]:
"""Get image dimensions using PIL.""" """Get image dimensions using PIL."""
try: try:
with Image.open(image_path) as img: with Image.open(image_path) as img:
return img.width, img.height return ImageOps.exif_transpose(img).size
except Exception as e: except Exception as e:
print(f" [WARN] Cannot read image with PIL: {e}") print(f" [WARN] Cannot read image with PIL: {e}")
return None, None return None, None
@@ -121,7 +121,7 @@ def get_image_dimensions_from_base64(data_uri: str) -> tuple[int | None, int | N
if HAS_PIL: if HAS_PIL:
with Image.open(io.BytesIO(img_bytes)) as img: with Image.open(io.BytesIO(img_bytes)) as img:
return img.width, img.height return ImageOps.exif_transpose(img).size
else: else:
# Use basic method # Use basic method
if img_bytes[:8] == b'\x89PNG\r\n\x1a\n': if img_bytes[:8] == b'\x89PNG\r\n\x1a\n':
@@ -663,7 +663,7 @@ def flatten_text_with_tspans(
# Keep raw XML whitespace and tails until the shared downstream # Keep raw XML whitespace and tails until the shared downstream
# text normalizer sees the whole line. A whitespace-only run can # text normalizer sees the whole line. A whitespace-only run can
# still be the visible boundary between two formatted runs. # still be the visible boundary between two formatted runs.
if content or child.tail: if content or child.tail or child.get("dx") is not None:
current_line_tspans.append(child) current_line_tspans.append(child)
# Process the last line # Process the last line
@@ -790,6 +790,7 @@ def _create_text_element_from_line(
and not tspans[0].tail and not tspans[0].tail
and tspans[0].get(INLINE_FORMULA_ATTR) is None and tspans[0].get(INLINE_FORMULA_ATTR) is None
and not _declares_baseline_shift(tspans[0]) and not _declares_baseline_shift(tspans[0])
and (tspans[0].get("dx") is None or _is_new_line_tspan(tspans[0]))
): ):
tspan = tspans[0] tspan = tspans[0]
content = collect_text_content(tspan) content = collect_text_content(tspan)
@@ -100,10 +100,13 @@ try:
parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio, parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio,
parse_project_opacity as _parse_project_opacity, parse_project_opacity as _parse_project_opacity,
parse_svg_color as _parse_export_color, parse_svg_color as _parse_export_color,
parse_svg_length as _parse_svg_length,
parse_transform_matrix as _parse_transform_matrix, parse_transform_matrix as _parse_transform_matrix,
project_definition_index as _project_definition_index,
project_mask_errors as _project_mask_errors, project_mask_errors as _project_mask_errors,
rect_to_dml_xfrm as _rect_to_dml_xfrm, rect_to_dml_xfrm as _rect_to_dml_xfrm,
split_project_text_clusters as _split_project_text_clusters, split_project_text_clusters as _split_project_text_clusters,
svg_hidden_reason as _svg_hidden_reason,
transform_point as _transform_point, transform_point as _transform_point,
unsafe_exported_font_faces as _unsafe_exported_font_faces, unsafe_exported_font_faces as _unsafe_exported_font_faces,
validate_dml_shape_matrix as _validate_dml_shape_matrix, validate_dml_shape_matrix as _validate_dml_shape_matrix,
@@ -120,10 +123,13 @@ except ImportError:
_parse_project_image_aspect_ratio = None _parse_project_image_aspect_ratio = None
_parse_project_opacity = None _parse_project_opacity = None
_parse_export_color = None _parse_export_color = None
_parse_svg_length = None
_parse_transform_matrix = None _parse_transform_matrix = None
_project_definition_index = None
_project_mask_errors = None _project_mask_errors = None
_rect_to_dml_xfrm = None _rect_to_dml_xfrm = None
_split_project_text_clusters = None _split_project_text_clusters = None
_svg_hidden_reason = None
_transform_point = None _transform_point = None
_unsafe_exported_font_faces = None _unsafe_exported_font_faces = None
_validate_dml_shape_matrix = None _validate_dml_shape_matrix = None
@@ -140,17 +146,25 @@ except ImportError:
try: try:
from svg_to_pptx.drawingml.converter import ( from svg_to_pptx.drawingml.converter import (
SvgNativeConversionError as _SvgNativeConversionError, SvgNativeConversionError as _SvgNativeConversionError,
collect_hidden_visuals as _collect_hidden_visuals,
collect_unsupported_visuals as _collect_unsupported_visuals, collect_unsupported_visuals as _collect_unsupported_visuals,
preserved_native_text_body as _preserved_native_text_body, preserved_native_text_body as _preserved_native_text_body,
) )
except ImportError: except ImportError:
_SvgNativeConversionError = None _SvgNativeConversionError = None
_collect_hidden_visuals = None
_collect_unsupported_visuals = None _collect_unsupported_visuals = None
_preserved_native_text_body = None _preserved_native_text_body = None
try:
from svg_to_pptx.drawingml.styles import parse_pattern_colors as _parse_pattern_colors
except ImportError:
_parse_pattern_colors = None
try: try:
from svg_to_pptx.drawingml.elements import ( from svg_to_pptx.drawingml.elements import (
drawingml_text_frame_width_emu as _drawingml_text_frame_width_emu, drawingml_text_frame_width_emu as _drawingml_text_frame_width_emu,
empty_clip_path_reason as _empty_clip_path_reason,
estimate_single_line_text_frame_width as _estimate_single_line_text_frame_width, estimate_single_line_text_frame_width as _estimate_single_line_text_frame_width,
project_image_errors as _project_image_errors, project_image_errors as _project_image_errors,
validate_single_line_text_run_advances as _validate_single_line_text_run_advances, validate_single_line_text_run_advances as _validate_single_line_text_run_advances,
@@ -158,6 +172,7 @@ try:
) )
except ImportError: except ImportError:
_drawingml_text_frame_width_emu = None _drawingml_text_frame_width_emu = None
_empty_clip_path_reason = None
_estimate_single_line_text_frame_width = None _estimate_single_line_text_frame_width = None
_project_image_errors = None _project_image_errors = None
_validate_single_line_text_run_advances = None _validate_single_line_text_run_advances = None
@@ -1295,6 +1310,7 @@ class SVGQualityChecker:
# 2a. Validate direct geometry lengths and stroke widths. # 2a. Validate direct geometry lengths and stroke widths.
svg_contracts.check_geometry_length_values(root, result) svg_contracts.check_geometry_length_values(root, result)
self._check_shape_coordinate_ranges(root, result)
# 2b. Validate line-presentation grammar and mappings. # 2b. Validate line-presentation grammar and mappings.
svg_contracts.check_stroke_style_values(root, result) svg_contracts.check_stroke_style_values(root, result)
@@ -1351,6 +1367,7 @@ class SVGQualityChecker:
# 7b. Reject visual elements the native converter cannot dispatch. # 7b. Reject visual elements the native converter cannot dispatch.
self._check_unsupported_visual_elements(root, result) self._check_unsupported_visual_elements(root, result)
self._check_hidden_elements(root, result)
# 7c. Fail closed on invalid PPTX preset/adjustment metadata. # 7c. Fail closed on invalid PPTX preset/adjustment metadata.
self._check_preset_geometry_metadata(root, result) self._check_preset_geometry_metadata(root, result)
@@ -2792,11 +2809,14 @@ class SVGQualityChecker:
cls, cls,
container: ET.Element, container: ET.Element,
inherited_xml_space: str, inherited_xml_space: str,
*,
include_container_dx: bool = False,
) -> List[Tuple[ET.Element, str, str]] | None: ) -> List[Tuple[ET.Element, str, str]] | None:
"""Collect inline text while rejecting descendant positioning.""" """Collect inline text and empty dx markers, rejecting baseline jumps."""
if ( if (
_normalize_project_text_segments is None _normalize_project_text_segments is None
or _resolve_project_xml_space is None or _resolve_project_xml_space is None
or _parse_svg_length is None
): ):
return None return None
raw_runs: List[Tuple[ET.Element, str, str]] = [] raw_runs: List[Tuple[ET.Element, str, str]] = []
@@ -2813,6 +2833,17 @@ class SVGQualityChecker:
) )
except ValueError: except ValueError:
return False return False
if (
cls._is_tspan(element)
and element.get('dx') is not None
and (element is not container or include_container_dx)
):
try:
dx = _parse_svg_length(element.get('dx'))
except ValueError:
return False
if dx:
raw_runs.append((element, xml_space, ''))
if element.text: if element.text:
append_run(element, element.text, xml_space) append_run(element, element.text, xml_space)
for child in list(element): for child in list(element):
@@ -2821,7 +2852,9 @@ class SVGQualityChecker:
# container: its runs are measured like any other inline run. # container: its runs are measured like any other inline run.
if not (cls._is_tspan(child) or _local_name(child) == 'a'): if not (cls._is_tspan(child) or _local_name(child) == 'a'):
return False return False
if any(child.get(name) is not None for name in ('x', 'y', 'dx', 'dy')): if any(child.get(name) is not None for name in ('x', 'y', 'dy')):
return False
if child.get('dx') is not None and not cls._is_tspan(child):
return False return False
if any( if any(
name.startswith('data-paragraph-') name.startswith('data-paragraph-')
@@ -2843,13 +2876,14 @@ class SVGQualityChecker:
raw_runs: List[Tuple[ET.Element, str, str]], raw_runs: List[Tuple[ET.Element, str, str]],
) -> List[Tuple[ET.Element, str]]: ) -> List[Tuple[ET.Element, str]]:
"""Normalize collected segments while retaining their style owner.""" """Normalize collected segments while retaining their style owner."""
normalized = _normalize_project_text_segments([ normalized = dict(_normalize_project_text_segments([
(xml_space, raw) (xml_space, raw)
for _owner, xml_space, raw in raw_runs for _owner, xml_space, raw in raw_runs
]) ]))
return [ return [
(raw_runs[index][0], text) (owner, normalized.get(index, ''))
for index, text in normalized for index, (owner, _xml_space, raw) in enumerate(raw_runs)
if not raw or index in normalized
] ]
@classmethod @classmethod
@@ -2876,7 +2910,11 @@ class SVGQualityChecker:
if member.text: if member.text:
raw_runs.append((text_el, parent_xml_space, member.text)) raw_runs.append((text_el, parent_xml_space, member.text))
continue continue
member_runs = cls._inline_text_segments(member, parent_xml_space) member_runs = cls._inline_text_segments(
member,
parent_xml_space,
include_container_dx=member is not line_group[0],
)
if member_runs is None: if member_runs is None:
return None return None
raw_runs.extend(member_runs) raw_runs.extend(member_runs)
@@ -3044,6 +3082,12 @@ class SVGQualityChecker:
'opacity_chain': tuple(reversed(opacity_chain)), 'opacity_chain': tuple(reversed(opacity_chain)),
'inline_formula': owner.get(_INLINE_FORMULA_ATTR), 'inline_formula': owner.get(_INLINE_FORMULA_ATTR),
}) })
if not text:
resolved[-1]['_inline_dx'] = _parse_svg_length(
owner.get('dx'),
font_size=font_sizes[id(owner)],
)
resolved[-1]['inline_formula'] = None
return cls._coalesce_checker_text_runs(resolved) return cls._coalesce_checker_text_runs(resolved)
@staticmethod @staticmethod
@@ -3075,7 +3119,7 @@ class SVGQualityChecker:
merged: List[Dict] = [] merged: List[Dict] = []
previous_signature: Tuple | None = None previous_signature: Tuple | None = None
for run in runs: for run in runs:
if run.get('inline_formula') is not None: if run.get('inline_formula') is not None or '_inline_dx' in run:
merged.append(run) merged.append(run)
previous_signature = None previous_signature = None
continue continue
@@ -3214,6 +3258,8 @@ class SVGQualityChecker:
children = list(text_el) children = list(text_el)
if not children: if not children:
return None return None
line_groups = None
synthetic_first = None
if (text_el.text or '').strip(): if (text_el.text or '').strip():
if _classify_paragraph_block is None: if _classify_paragraph_block is None:
return None return None
@@ -3224,6 +3270,23 @@ class SVGQualityChecker:
if paragraph is None: if paragraph is None:
return None return None
_base, _extras, _breaks, line_groups, synthetic_first = paragraph _base, _extras, _breaks, line_groups, synthetic_first = paragraph
elif any(
descendant.get('dx') is not None
for child in children
if not cls._is_line_tspan(child)
for descendant in child.iter()
):
line_groups = []
for child in children:
if not (cls._is_tspan(child) or _local_name(child) == 'a'):
return None
if cls._is_line_tspan(child):
line_groups.append([child])
elif line_groups:
line_groups[-1].append(child)
else:
return None
if line_groups is not None:
try: try:
current_y = _parse_project_geometry_length( current_y = _parse_project_geometry_length(
text_el.get('y') or '0', text_el.get('y') or '0',
@@ -3934,29 +3997,75 @@ class SVGQualityChecker:
element: ET.Element, element: ET.Element,
parent_by_id: Dict[int, ET.Element], parent_by_id: Dict[int, ET.Element],
) -> bool: ) -> bool:
"""Return whether inherited display or visibility hides an element.""" """Resolve ordinary suppression and empty clips before measuring visuals."""
current: ET.Element | None = element if _svg_hidden_reason is not None and _svg_hidden_reason(element, parent_by_id) is not None:
return True
if _empty_clip_path_reason is None or _project_definition_index is None:
return False
clipped = []
root = element
current = element
while current is not None: while current is not None:
style_values = ( if current.get('clip-path') is not None:
_parse_inline_style(current.get('style')) clipped.append(current)
if _parse_inline_style is not None root = current
else {}
)
display = style_values.get('display')
if display is None:
display = current.get('display')
if display and display.strip().lower() == 'none':
return True
current = parent_by_id.get(id(current)) current = parent_by_id.get(id(current))
visibility = ( if not clipped:
_effective_presentation_value( return False
element, definitions, _duplicates = _project_definition_index(root)
'visibility', return any(_empty_clip_path_reason(item, definitions, parent_by_id) for item in clipped)
parent_by_id,
def _check_hidden_elements(self, root: ET.Element, result: Dict) -> None:
"""Advise which hidden SVG objects will be omitted during native export."""
if _collect_hidden_visuals is None:
return
for element, reason in _collect_hidden_visuals(root):
result['warnings'].append(
f'Hidden element {_element_label(element)} will not be exported '
f'({reason}, including inherited state); advisory only'
) )
or ''
).strip().lower() def _check_shape_coordinate_ranges(self, root: ET.Element, result: Dict) -> None:
return visibility in {'hidden', 'collapse'} """Check ordinary shape frames with the exporter's OOXML range validator."""
if _rect_to_dml_xfrm is None or _parse_project_geometry_length is None:
return
parents = {id(child): parent for parent in root.iter() for child in parent}
def visit(element: ET.Element) -> None:
tag = _local_name(element)
if tag in {'defs', 'metadata', 'title', 'desc', 'style'}:
return
if (
tag in {'rect', 'image', 'circle', 'ellipse'}
and element.get('data-pptx-frame') is None
and not self._is_hidden_element(element, parents)
):
styles = _parse_inline_style(element.get('style')) if _parse_inline_style else {}
def length(name: str) -> float:
return _parse_project_geometry_length(styles.get(name, element.get(name, '0')), name)
try:
if tag in {'rect', 'image'}:
frame = (length('x'), length('y'), length('width'), length('height'))
else:
rx = length('r' if tag == 'circle' else 'rx')
ry = rx if tag == 'circle' else length('ry')
frame = (length('cx') - rx, length('cy') - ry, rx * 2, ry * 2)
matrix = self._accumulated_transform_matrix(element, parents)
if matrix is not None and frame[2] > 0 and frame[3] > 0:
_rect_to_dml_xfrm(*frame, matrix)
except ValueError as exc:
# Other geometry/transform grammar errors have their own checks.
if 'OOXML' in str(exc):
result['errors'].append(
f'{_element_label(element)}: {exc}; reduce the shape '
'coordinates or dimensions before export'
)
for child in element:
visit(child)
visit(root)
@staticmethod @staticmethod
def _has_zero_opacity( def _has_zero_opacity(
@@ -5410,6 +5519,11 @@ class SVGQualityChecker:
"horzBrick (others); see references/native-data-interface.md §1 " "horzBrick (others); see references/native-data-interface.md §1 "
"for the full authoring enum." "for the full authoring enum."
) )
elif prst and _parse_pattern_colors is not None:
try:
_parse_pattern_colors(pattern)
except ValueError as exc:
result['errors'].append(str(exc))
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None: def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
"""Validate explicit native replacement markers before PPTX export.""" """Validate explicit native replacement markers before PPTX export."""
@@ -178,13 +178,13 @@ _CANONICAL_PAINT_ALPHA_PROPERTY = {
"flood-color": "flood-opacity", "flood-color": "flood-opacity",
} }
_SUPPORTED_INLINE_STYLE_PROPERTIES = frozenset({ _SUPPORTED_INLINE_STYLE_PROPERTIES = frozenset({
"cx", "cy", "fill", "fill-opacity", "filter", "flood-color", "cx", "cy", "display", "fill", "fill-opacity", "filter", "flood-color",
"flood-opacity", "font-family", "font-size", "font-style", "font-weight", "flood-opacity", "font-family", "font-size", "font-style", "font-weight",
"height", "letter-spacing", "opacity", "r", "rx", "ry", "height", "letter-spacing", "opacity", "r", "rx", "ry",
"shape-rendering", "stop-color", "stop-opacity", "stroke", "shape-rendering", "stop-color", "stop-opacity", "stroke",
"stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-opacity", "stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-opacity",
"stroke-width", "text-anchor", "text-decoration", "vector-effect", "stroke-width", "text-anchor", "text-decoration", "vector-effect",
"width", "x", "y", "visibility", "width", "x", "y",
}) })
_BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({ _BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({
"backdrop-filter", "backdrop-filter",
@@ -81,6 +81,7 @@ class GroupTarget:
chrome: bool = False chrome: bool = False
structurally_static: bool = False structurally_static: bool = False
has_hyperlink: bool = False has_hyperlink: bool = False
hidden_reason: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -197,9 +198,17 @@ def usable_animation_group_id(raw: str | None) -> str | None:
return raw if raw and raw.strip() else None return raw if raw and raw.strip() else None
def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]: def scan_svg_targets(
svg_path: Path,
*,
include_hidden: bool = False,
) -> tuple[list[GroupTarget], list[str]]:
"""Scan one SVG for top-level visible group ids and anonymous groups.""" """Scan one SVG for top-level visible group ids and anonymous groups."""
# The converter imports animation policy; defer this shared visibility scan.
from .drawingml.converter import collect_hidden_visuals
root = ET.parse(str(svg_path)).getroot() root = ET.parse(str(svg_path)).getroot()
hidden_by_id = {id(element): reason for element, reason in collect_hidden_visuals(root)}
targets: list[GroupTarget] = [] targets: list[GroupTarget] = []
anonymous_groups: list[str] = [] anonymous_groups: list[str] = []
visual_index = 0 visual_index = 0
@@ -212,9 +221,13 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
visual_index += 1 visual_index += 1
if tag != 'g': if tag != 'g':
continue continue
hidden_reason = hidden_by_id.get(id(child))
if hidden_reason and not include_hidden:
continue
group_id = usable_animation_group_id(child.get('id')) group_id = usable_animation_group_id(child.get('id'))
if group_id is None: if group_id is None:
anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}') if hidden_reason is None:
anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}')
continue continue
role = child.get('data-pptx-role') role = child.get('data-pptx-role')
placeholder = child.get('data-pptx-placeholder') placeholder = child.get('data-pptx-placeholder')
@@ -248,6 +261,7 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
order=visual_index, order=visual_index,
chrome=chrome, chrome=chrome,
structurally_static=structurally_static, structurally_static=structurally_static,
hidden_reason=hidden_reason,
has_hyperlink=any( has_hyperlink=any(
_tag_name(descendant) == 'a' _tag_name(descendant) == 'a'
or descendant.get(SHAPE_HYPERLINK_ATTR) is not None or descendant.get(SHAPE_HYPERLINK_ATTR) is not None
@@ -288,6 +302,7 @@ def scan_project_targets(
project_path: Path, project_path: Path,
*, *,
svg_files: list[Path] | None = None, svg_files: list[Path] | None = None,
include_hidden: bool = False,
) -> tuple[dict[str, list[GroupTarget]], list[str]]: ) -> tuple[dict[str, list[GroupTarget]], list[str]]:
"""Scan selected SVG files, defaulting to ``svg_output/*.svg``.""" """Scan selected SVG files, defaulting to ``svg_output/*.svg``."""
targets_by_slide: dict[str, list[GroupTarget]] = {} targets_by_slide: dict[str, list[GroupTarget]] = {}
@@ -299,7 +314,7 @@ def scan_project_targets(
svg_files = discover_slide_svgs(svg_dir) svg_files = discover_slide_svgs(svg_dir)
for svg_path in svg_files: for svg_path in svg_files:
targets, anonymous = scan_svg_targets(svg_path) targets, anonymous = scan_svg_targets(svg_path, include_hidden=include_hidden)
targets_by_slide[svg_path.stem] = targets targets_by_slide[svg_path.stem] = targets
anonymous_groups.extend(anonymous) anonymous_groups.extend(anonymous)
@@ -1469,6 +1484,7 @@ def validate_animation_config(
targets_by_slide, anonymous_groups = scan_project_targets( targets_by_slide, anonymous_groups = scan_project_targets(
project_path, project_path,
svg_files=svg_files, svg_files=svg_files,
include_hidden=True,
) )
for item in anonymous_groups: for item in anonymous_groups:
warnings.append(f'{item} has no id and cannot be customized in animations.json') warnings.append(f'{item} has no id and cannot be customized in animations.json')
@@ -1536,6 +1552,12 @@ def validate_animation_config(
) )
continue continue
target = known_groups[group_id] target = known_groups[group_id]
if target.hidden_reason:
warnings.append(
f'animations.json {path} references a group that is '
f'hidden, not exported ({target.hidden_reason})'
)
continue
if not isinstance(group_cfg, dict): if not isinstance(group_cfg, dict):
continue continue
try: try:
@@ -1583,6 +1605,12 @@ def validate_animation_config(
f'animations.json {effect_path}.trigger_shape ' f'animations.json {effect_path}.trigger_shape '
f'references missing group {trigger_shape!r}' f'references missing group {trigger_shape!r}'
) )
elif trigger_target.hidden_reason:
warnings.append(
f'animations.json {effect_path}.trigger_shape '
f'references group {trigger_shape!r} that is hidden, '
f'not exported ({trigger_target.hidden_reason})'
)
elif trigger_target.structurally_static: elif trigger_target.structurally_static:
warnings.append( warnings.append(
f'animations.json {effect_path}.trigger_shape ' f'animations.json {effect_path}.trigger_shape '
@@ -1629,6 +1657,11 @@ def validate_animation_config(
'animations.json Morph references missing or ambiguous group: ' 'animations.json Morph references missing or ambiguous group: '
f'{slide_name}/{group_id}' f'{slide_name}/{group_id}'
) )
elif target.hidden_reason:
warnings.append(
f'animations.json Morph endpoint {slide_name}/{group_id} '
f'is hidden, not exported ({target.hidden_reason})'
)
elif target.structurally_static: elif target.structurally_static:
warnings.append( warnings.append(
'animations.json Morph references structural group: ' 'animations.json Morph references structural group: '
@@ -1692,12 +1725,13 @@ def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]:
listing reflects exactly what an editor can override. Returns listing reflects exactly what an editor can override. Returns
``(lines, anonymous_warnings)``. ``(lines, anonymous_warnings)``.
""" """
targets_by_slide, anonymous = scan_project_targets(project_path) targets_by_slide, anonymous = scan_project_targets(project_path, include_hidden=True)
lines: list[str] = [] lines: list[str] = []
for slide_name, targets in targets_by_slide.items(): for slide_name, targets in targets_by_slide.items():
_require_unique_target_ids(slide_name, targets) _require_unique_target_ids(slide_name, targets)
ids = [t.group_id for t in targets if not t.chrome] ids = [t.group_id for t in targets if not t.chrome and not t.hidden_reason]
chrome_ids = [t.group_id for t in targets if t.chrome] chrome_ids = [t.group_id for t in targets if t.chrome and not t.hidden_reason]
hidden_ids = [t.group_id for t in targets if t.hidden_reason]
if not ids: if not ids:
line = f'{slide_name}: (no animatable groups)' line = f'{slide_name}: (no animatable groups)'
else: else:
@@ -1706,6 +1740,8 @@ def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]:
# Name what was dropped so an id like ``takeaway-rule`` is seen # Name what was dropped so an id like ``takeaway-rule`` is seen
# as chrome-by-token rather than silently missing. # as chrome-by-token rather than silently missing.
line += f' [chrome, animates only when named: {", ".join(chrome_ids)}]' line += f' [chrome, animates only when named: {", ".join(chrome_ids)}]'
if hidden_ids:
line += f' [hidden, not exported: {", ".join(hidden_ids)}]'
lines.append(line) lines.append(line)
return lines, anonymous return lines, anonymous
@@ -90,6 +90,8 @@ class ConvertContext:
# conversion entry points require it; recursive child contexts retain it. # conversion entry points require it; recursive child contexts retain it.
resource_root: Path | None = None resource_root: Path | None = None
inherited_styles: dict[str, str] = field(default_factory=dict) inherited_styles: dict[str, str] = field(default_factory=dict)
# Shared ancestry for visibility overrides and native geometry carriers.
parent_by_id: dict[int, ET.Element] = field(default_factory=dict)
# Effective SVG font sizes keyed by element identity. Shared resolution # Effective SVG font sizes keyed by element identity. Shared resolution
# keeps relative sizes and em tracking identical across checker/exporter. # keeps relative sizes and em tracking identical across checker/exporter.
text_font_sizes: dict[int, float] = field(default_factory=dict) text_font_sizes: dict[int, float] = field(default_factory=dict)
@@ -268,6 +270,7 @@ class ConvertContext:
svg_dir=self.svg_dir, svg_dir=self.svg_dir,
resource_root=self.resource_root, resource_root=self.resource_root,
inherited_styles=merged, inherited_styles=merged,
parent_by_id=self.parent_by_id,
text_font_sizes=self.text_font_sizes, text_font_sizes=self.text_font_sizes,
text_letter_spacings=self.text_letter_spacings, text_letter_spacings=self.text_letter_spacings,
opacity_multiplier=self.opacity_multiplier * local_opacity, opacity_multiplier=self.opacity_multiplier * local_opacity,
@@ -84,6 +84,7 @@ from .utils import (
project_stroke_style_errors, project_stroke_style_errors,
project_transform_errors, project_transform_errors,
resolve_url_id, resolve_url_id,
svg_hidden_reason,
supports_full_project_transform, supports_full_project_transform,
validate_dml_shape_matrix, validate_dml_shape_matrix,
) )
@@ -93,6 +94,7 @@ from .styles import (
) )
from .elements import ( from .elements import (
complete_preset_adjustments, complete_preset_adjustments,
empty_clip_path_reason,
convert_rect, convert_circle, convert_ellipse, convert_rect, convert_circle, convert_ellipse,
convert_line, convert_path, convert_line, convert_path,
convert_polygon, convert_polyline, convert_polygon, convert_polyline,
@@ -1144,7 +1146,11 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes
keep their absolute slide coordinates unchanged. keep their absolute slide coordinates unchanged.
""" """
exact_graphic_frame = _roundtrip_graphic_frame(elem, ctx) ctx.parent_by_id.update({id(child): elem for child in elem})
hidden_reason = svg_hidden_reason(elem, ctx.parent_by_id, preserve_native_carriers=True)
if hidden_reason == 'display:none':
return None
exact_graphic_frame = _roundtrip_graphic_frame(elem, ctx) if hidden_reason is None else None
if exact_graphic_frame is not None: if exact_graphic_frame is not None:
return exact_graphic_frame return exact_graphic_frame
@@ -1244,7 +1250,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"the subtree as ordinary SVG" "the subtree as ordinary SVG"
) )
if _native_replacement_enabled(elem, child_ctx): if hidden_reason is None and _native_replacement_enabled(elem, child_ctx):
native_result = convert_native_object(elem, child_ctx) native_result = convert_native_object(elem, child_ctx)
if native_result: if native_result:
ctx.sync_from_child(child_ctx) ctx.sync_from_child(child_ctx)
@@ -1254,7 +1260,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id)) ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return native_result return native_result
if elem.get(SEMANTIC_OBJECT_ATTRIBUTE) == SEMANTIC_SHAPE_KIND: if hidden_reason is None and elem.get(SEMANTIC_OBJECT_ATTRIBUTE) == SEMANTIC_SHAPE_KIND:
geometry_ctx = child_ctx geometry_ctx = child_ctx
if transform and not native_subtree_active: if transform and not native_subtree_active:
geometry_ctx = ctx.child( geometry_ctx = ctx.child(
@@ -1277,12 +1283,13 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
return semantic_result return semantic_result
if ( if (
elem.get('data-pptx-object') in {'shape', 'connector'} hidden_reason is None
and elem.get('data-pptx-object') in {'shape', 'connector'}
and elem.get('data-pptx-prst') is not None and elem.get('data-pptx-prst') is not None
): ):
_require_unchanged_preset_preview(elem) _require_unchanged_preset_preview(elem)
preserved_text = preserved_native_text_body(elem) preserved_text = preserved_native_text_body(elem) if hidden_reason is None else None
if preserved_text is not None: if preserved_text is not None:
geometry_carrier, native_text = preserved_text geometry_carrier, native_text = preserved_text
geometry_ctx = child_ctx geometry_ctx = child_ctx
@@ -1341,6 +1348,9 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
explicit_native_group = elem.get('data-pptx-object') == 'group' explicit_native_group = elem.get('data-pptx-object') == 'group'
if ( if (
len(child_results) == 1 len(child_results) == 1
# A fallback rotation still belongs to this group. Keep its container
# so the pivot compensation below runs even for a single text child.
and (matrix_supported or not angle_deg)
and not explicit_native_group and not explicit_native_group
and ( and (
not should_animate_group not should_animate_group
@@ -1573,6 +1583,9 @@ def _extract_background_candidate(
tag = child.tag.replace(f'{{{SVG_NS}}}', '') tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag in _NON_VISUAL_TAGS: if tag in _NON_VISUAL_TAGS:
continue continue
hidden_reason = svg_hidden_reason(child, ctx.parent_by_id, preserve_native_carriers=True)
if hidden_reason and (tag != 'g' or hidden_reason == 'display:none'):
continue
if tag == 'rect' and _is_full_canvas_rect(child, ctx, canvas): if tag == 'rect' and _is_full_canvas_rect(child, ctx, canvas):
bg_xml = _background_xml_from_rect(child, ctx) bg_xml = _background_xml_from_rect(child, ctx)
@@ -1597,6 +1610,8 @@ def _extract_background_candidate(
if len(visual_children) != 1: if len(visual_children) != 1:
return '', None return '', None
only_child = visual_children[0] only_child = visual_children[0]
if svg_hidden_reason(only_child, ctx.parent_by_id, preserve_native_carriers=True):
return '', None
only_tag = only_child.tag.replace(f'{{{SVG_NS}}}', '') only_tag = only_child.tag.replace(f'{{{SVG_NS}}}', '')
if only_tag == 'rect' and _is_full_canvas_rect(only_child, child_ctx, canvas): if only_tag == 'rect' and _is_full_canvas_rect(only_child, child_ctx, canvas):
bg_xml = _background_xml_from_rect(only_child, child_ctx) bg_xml = _background_xml_from_rect(only_child, child_ctx)
@@ -1666,6 +1681,8 @@ def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str,
"""Describe the native geometry decision for conversion diagnostics.""" """Describe the native geometry decision for conversion diagnostics."""
xml = result.xml.lstrip() xml = result.xml.lstrip()
if xml.startswith('<p:grpSp>'): if xml.startswith('<p:grpSp>'):
if native_replacement_kind(elem) == 'chart' and '<p:graphicFrame>' in xml:
return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'}
return {'output_geometry': 'group', 'fidelity': 'visual-only'} return {'output_geometry': 'group', 'fidelity': 'visual-only'}
if xml.startswith('<p:pic>'): if xml.startswith('<p:pic>'):
return {'output_geometry': 'picture', 'fidelity': 'native-normalized'} return {'output_geometry': 'picture', 'fidelity': 'native-normalized'}
@@ -1742,6 +1759,35 @@ def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str,
return {'output_geometry': 'unknown', 'fidelity': 'visual-only'} return {'output_geometry': 'unknown', 'fidelity': 'visual-only'}
def collect_hidden_visuals(root: ET.Element) -> list[tuple[ET.Element, str]]:
"""List suppressed visual objects while preserving native transport carriers."""
parents = {id(child): parent for parent in root.iter() for child in parent}
definitions, _duplicates = project_definition_index(root)
hidden: list[tuple[ET.Element, str]] = []
def visit(element: ET.Element) -> bool:
tag = _local_tag(element)
if tag in _NON_VISUAL_TAGS:
return False
reason = svg_hidden_reason(element, parents, preserve_native_carriers=True)
clip_reason = empty_clip_path_reason(element, definitions, parents)
reason = reason or clip_reason
hidden_before_children = len(hidden)
children_visible = [visit(child) for child in element]
container = tag in {'g', 'a', 'svg'}
if container and reason != 'display:none' and clip_reason is None:
if any(children_visible):
return True
if reason is None and tag != 'svg' and len(hidden) > hidden_before_children:
reason = 'hidden descendants'
if reason:
hidden.append((element, reason))
return reason is None and (not container or any(children_visible))
visit(root)
return hidden
def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Dispatch an SVG element to the appropriate converter.""" """Dispatch an SVG element to the appropriate converter."""
tag = elem.tag.replace(f'{{{SVG_NS}}}', '') tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
@@ -1796,6 +1842,15 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
trace('skip', reason='render-only-preset-geometry-detail') trace('skip', reason='render-only-preset-geometry-detail')
return None return None
hidden_reason = svg_hidden_reason(elem, ctx.parent_by_id, preserve_native_carriers=True)
clip_reason = empty_clip_path_reason(elem, ctx.defs, ctx.parent_by_id)
if clip_reason:
trace('skip', reason=clip_reason)
return None
if hidden_reason and (tag not in {'g', 'a'} or hidden_reason == 'display:none'):
trace('skip', reason=hidden_reason)
return None
converter = _CONVERTERS.get(tag) converter = _CONVERTERS.get(tag)
if converter: if converter:
try: try:
@@ -1805,7 +1860,8 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
result = apply_shape_hyperlink(result, ctx, shape_hyperlink) result = apply_shape_hyperlink(result, ctx, shape_hyperlink)
except Exception as e: except Exception as e:
trace('error', error=str(e)) trace('error', error=str(e))
raise SvgNativeConversionError(f'Failed to convert <{tag}>: {e}') from e label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
raise SvgNativeConversionError(f'Failed to convert {label}: {e}') from e
if result: if result:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', result.xml) shape_match = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
@@ -2286,6 +2342,7 @@ def convert_svg_to_slide_shapes(
defs = collect_defs(root) defs = collect_defs(root)
source_shape_id_map = _build_source_shape_id_map(root) source_shape_id_map = _build_source_shape_id_map(root)
root_opacity = get_element_opacity(root)
ctx = ConvertContext( ctx = ConvertContext(
defs=defs, defs=defs,
reserved_shape_ids=frozenset(source_shape_id_map.values()), reserved_shape_ids=frozenset(source_shape_id_map.values()),
@@ -2309,6 +2366,8 @@ def convert_svg_to_slide_shapes(
theme_color_spec=theme_color_spec, theme_color_spec=theme_color_spec,
primary_language=primary_language, primary_language=primary_language,
inherited_styles=_extract_inheritable_styles(root), inherited_styles=_extract_inheritable_styles(root),
opacity_multiplier=1.0 if root_opacity is None else root_opacity,
parent_by_id={id(child): parent for parent in root.iter() for child in parent},
text_font_sizes=text_font_sizes, text_font_sizes=text_font_sizes,
text_letter_spacings=text_letter_spacings, text_letter_spacings=text_letter_spacings,
) )
@@ -2342,7 +2401,10 @@ def convert_svg_to_slide_shapes(
continue continue
if id(child) == background_skip_id: if id(child) == background_skip_id:
continue continue
result = convert_element(child, ctx) try:
result = convert_element(child, ctx)
except SvgNativeConversionError as exc:
raise SvgNativeConversionError(f'{svg_path.name}: {exc}') from exc
if result: if result:
shapes.append(result.xml) shapes.append(result.xml)
converted += 1 converted += 1
@@ -73,6 +73,7 @@ from .utils import (
resolve_project_text_image_fill, resolve_url_id, get_effective_filter_id, resolve_project_text_image_fill, resolve_url_id, get_effective_filter_id,
parse_inline_style, parse_font_family, is_cjk_char, parse_inline_style, parse_font_family, is_cjk_char,
detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt, detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt,
get_font_advances, primary_font_family,
resolve_text_run_fonts, split_project_text_clusters, resolve_text_run_fonts, split_project_text_clusters,
text_has_rtl_characters, text_uses_rtl, text_has_rtl_characters, text_uses_rtl,
is_thick_circle_shorthand, parse_project_geometry_length, is_thick_circle_shorthand, parse_project_geometry_length,
@@ -82,6 +83,7 @@ from .utils import (
parse_project_stroke_dasharray, parse_project_stroke_dasharray,
quantize_ooxml_alpha, quantize_ooxml_alpha,
project_definition_index, project_definition_index,
svg_hidden_reason,
matrix_multiply, parse_transform_matrix, parse_transform_operations, matrix_multiply, parse_transform_matrix, parse_transform_operations,
transform_point, _xml_escape, transform_point, _xml_escape,
) )
@@ -765,6 +767,7 @@ def _shape_xfrm_from_svg_rect(
off_y = px_to_emu(resolved_y) off_y = px_to_emu(resolved_y)
ext_cx = px_to_emu(resolved_w) ext_cx = px_to_emu(resolved_w)
ext_cy = px_to_emu(resolved_h) ext_cy = px_to_emu(resolved_h)
validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy) return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
@@ -2098,7 +2101,21 @@ def _normalize_text_run_whitespace(
(str(run.get('_xml_space', 'default')), str(run.get('text', ''))) (str(run.get('_xml_space', 'default')), str(run.get('text', '')))
for run in runs for run in runs
] ]
for index, text in normalize_project_text_segments(segments): text_by_index = dict(normalize_project_text_segments(segments))
for index, source_run in enumerate(runs):
if '_inline_dx' in source_run:
# Insert after whitespace normalization so dx neither preserves
# indentation nor disappears with an empty/whitespace-only tspan.
run = {**source_run, 'text': ' ', 'letter_spacing': 0.0, 'text_decoration': 'none'}
dx = run.pop('_inline_dx')
run['letter_spacing'] = dx - _estimate_run_text_width(run)
run.update(text='\u00a0', _inline_dx=dx)
run.pop('_xml_space', None)
normalized.append(run)
continue
text = text_by_index.get(index)
if text is None:
continue
run = {**runs[index], 'text': text} run = {**runs[index], 'text': text}
run.pop('_xml_space', None) run.pop('_xml_space', None)
normalized.append(run) normalized.append(run)
@@ -2138,10 +2155,73 @@ _WIDE_FAMILY_WIDTH_FACTORS = {
'verdana': (1.02, 1.08), 'verdana': (1.02, 1.08),
} }
# Monospaced faces advance every Latin glyph by one fixed em fraction, so the
# per-glyph table (tuned for proportional sans faces) undershoots them by
# 1422%: measured 2026-09-05 at 20px, Courier New, DejaVu Sans Mono and Noto
# Sans Mono all render 0.600 em per character where the generic estimate gives
# 0.460.50. A monospaced run is therefore measured as characters × advance
# instead of scaled by a factor; CJK glyphs in these faces stay full-width and
# keep the generic estimate. Advances are the faces' published hmtx values.
_MONOSPACE_ADVANCE_EM = {
'andale mono': 0.60,
'cascadia code': 0.586,
'cascadia mono': 0.586,
'consolas': 0.55,
'courier': 0.60,
'courier new': 0.60,
'dejavu sans mono': 0.602,
'fira code': 0.60,
'fira mono': 0.60,
'hack': 0.602,
'ibm plex mono': 0.60,
'inconsolata': 0.50,
'jetbrains mono': 0.60,
'liberation mono': 0.60,
'lucida console': 0.60,
'menlo': 0.602,
'monaco': 0.60,
'monospace': 0.60,
'noto sans mono': 0.60,
'pt mono': 0.60,
'roboto mono': 0.60,
'sf mono': 0.602,
'source code pro': 0.60,
'ubuntu mono': 0.50,
}
def _run_primary_family(run: dict[str, Any]) -> str:
return primary_font_family(run.get('font_family'))
# Fixed-pitch faces outside the table almost always say so in their name
# (Cascadia Mono, Fira Code, Victor Mono, Noto Sans Mono CJK); 0.60 em is the
# common advance of the Courier-derived and modern coding families alike.
_MONOSPACE_NAME_HINTS = ('mono', 'code', 'courier', 'consol', 'typewriter')
_MONOSPACE_DEFAULT_ADVANCE_EM = 0.60
def _monospace_advance_em(run: dict[str, Any]) -> float | None:
"""Return the fixed per-character advance of a monospaced run, if any."""
family = _run_primary_family(run)
if not family:
return None
advance = _MONOSPACE_ADVANCE_EM.get(family)
if advance is not None:
return advance
if any(hint in family for hint in _MONOSPACE_NAME_HINTS):
return _MONOSPACE_DEFAULT_ADVANCE_EM
return None
def _family_width_factor(run: dict[str, Any]) -> float: def _family_width_factor(run: dict[str, Any]) -> float:
family = str(run.get('font_family') or '').split(',')[0].strip().strip('\'"').lower() if get_font_advances(
factors = _WIDE_FAMILY_WIDTH_FACTORS.get(family) run.get('font_family'),
str(run.get('font_weight', '400')),
str(run.get('font_style', 'normal')),
) is not None:
return 1.0
factors = _WIDE_FAMILY_WIDTH_FACTORS.get(_run_primary_family(run))
if factors is None: if factors is None:
return 1.0 return 1.0
base, caps = factors base, caps = factors
@@ -2150,6 +2230,8 @@ def _family_width_factor(run: dict[str, Any]) -> float:
def _estimate_run_text_width(run: dict[str, Any]) -> float: def _estimate_run_text_width(run: dict[str, Any]) -> float:
"""Estimate one run using the metrics actually emitted to DrawingML.""" """Estimate one run using the metrics actually emitted to DrawingML."""
if '_inline_dx' in run:
return float(run['_inline_dx'])
text = str(run.get('text', '')) text = str(run.get('text', ''))
font_size_px = ( font_size_px = (
font_px_to_hpt(float(run.get('font_size', 16))) font_px_to_hpt(float(run.get('font_size', 16)))
@@ -2159,7 +2241,21 @@ def _estimate_run_text_width(run: dict[str, Any]) -> float:
text, text,
font_size_px, font_size_px,
str(run.get('font_weight', '400')), str(run.get('font_weight', '400')),
font_family=run.get('font_family'),
font_style=str(run.get('font_style', 'normal')),
) )
monospace_advance = _monospace_advance_em(run)
if monospace_advance is not None:
# Fixed-pitch faces ignore weight and glyph shape for Latin text.
cluster_widths = [
width
if any(is_cjk_char(ch) for ch in cluster)
else monospace_advance * font_size_px
for cluster, width in zip(
split_project_text_clusters(text),
cluster_widths,
)
]
letter_spacing_px = ( letter_spacing_px = (
drawingml_letter_spacing( drawingml_letter_spacing(
float(run.get('letter_spacing', 0.0) or 0.0) float(run.get('letter_spacing', 0.0) or 0.0)
@@ -2223,6 +2319,17 @@ def _estimate_text_runs_width(
so adding headroom there stretches the merged text frame beyond the so adding headroom there stretches the merged text frame beyond the
author's source line width. author's source line width.
""" """
if any('_inline_dx' in run for run in runs):
# A negative dx moves the cursor back; it must not subtract space
# already occupied by earlier glyphs or make the frame extent negative.
advance = right = 0.0
for run in runs:
if '_inline_dx' in run:
advance += float(run['_inline_dx'])
continue
advance += _estimate_text_runs_width([run], include_headroom=include_headroom)
right = max(right, advance)
return right
if not include_headroom: if not include_headroom:
return sum(_estimate_run_text_width(run) for run in runs) return sum(_estimate_run_text_width(run) for run in runs)
@@ -2324,6 +2431,10 @@ def _extract_text_bullet(
runs: list[dict[str, Any]], runs: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""Convert a leading text bullet marker into paragraph metadata.""" """Convert a leading text bullet marker into paragraph metadata."""
if any('_inline_dx' in run for run in runs):
# Keep positioned markers literal so bullet normalization cannot
# discard or relocate an authored displacement.
return runs, None
first_nonspace = _first_nonspace_run(runs) first_nonspace = _first_nonspace_run(runs)
if first_nonspace and ( if first_nonspace and (
first_nonspace.get(_INLINE_FORMULA_KEY) is not None first_nonspace.get(_INLINE_FORMULA_KEY) is not None
@@ -2576,6 +2687,15 @@ def _collect_inline_runs(
svg_hyperlink_href(container), svg_hyperlink_href(container),
) )
if container_tag == 'tspan' and container.get('dx') is not None:
dx = parse_svg_length(
container.get('dx'),
percent_base=ctx.viewport_width,
font_size=float(own_attrs.get('font_size', 16)) / (ctx.scale_y or 1.0),
) * ctx.scale_x
if dx:
runs.append({**own_attrs, 'text': '', '_inline_dx': dx})
if container.text: if container.text:
run = { run = {
**own_attrs, **own_attrs,
@@ -2840,7 +2960,7 @@ def _coalesce_text_runs(
text = str(run.get('text', '')) text = str(run.get('text', ''))
if not text: if not text:
continue continue
if run.get(_INLINE_FORMULA_KEY) is not None: if run.get(_INLINE_FORMULA_KEY) is not None or '_inline_dx' in run:
merged.append({**run, 'text': text}) merged.append({**run, 'text': text})
previous_properties = None previous_properties = None
continue continue
@@ -3784,6 +3904,35 @@ def _nested_crop_clip_preset_geometry_error(
) )
def _visible_clip_shapes(
clip: ET.Element,
parent_by_id: dict[int, ET.Element],
) -> list[ET.Element]:
"""Resolve clip children with the same inherited visibility as visuals."""
return [
child for child in clip
if child.tag not in _CLIP_NON_VISUAL_ELEMENTS
and svg_hidden_reason(child, parent_by_id) is None
]
def empty_clip_path_reason(
element: ET.Element,
definitions: dict[str, ET.Element],
parent_by_id: dict[int, ET.Element],
) -> str | None:
"""Identify a valid clip reference whose children are all hidden."""
clip_id = resolve_url_id(element.get('clip-path', ''))
clip = definitions.get(clip_id)
if clip is not None and clip.tag == f'{{{SVG_NS}}}clipPath':
if (
any(child.tag not in _CLIP_NON_VISUAL_ELEMENTS for child in clip)
and not _visible_clip_shapes(clip, parent_by_id)
):
return f'empty clip: url(#{clip_id})'
return None
def project_clip_path_errors(root: ET.Element) -> list[str]: def project_clip_path_errors(root: ET.Element) -> list[str]:
"""Return clip-path errors that would otherwise degrade picture geometry.""" """Return clip-path errors that would otherwise degrade picture geometry."""
definitions, duplicates = project_definition_index(root) definitions, duplicates = project_definition_index(root)
@@ -3851,10 +4000,9 @@ def project_clip_path_errors(root: ET.Element) -> list[str]:
f'{clip_label} cannot use {", ".join(clip_rules)}; native ' f'{clip_label} cannot use {", ".join(clip_rules)}; native '
'picture geometry has no equivalent winding-rule control' 'picture geometry has no equivalent winding-rule control'
) )
visual_children = [ visual_children = _visible_clip_shapes(clip, parent_by_id)
child for child in list(clip) if not visual_children and empty_clip_path_reason(elem, definitions, parent_by_id):
if child.tag not in _CLIP_NON_VISUAL_ELEMENTS continue
]
if len(visual_children) != 1: if len(visual_children) != 1:
errors.add( errors.add(
f'{clip_label} must contain exactly one direct supported shape' f'{clip_label} must contain exactly one direct supported shape'
@@ -3937,16 +4085,12 @@ def _resolve_clip_geometry(
if clip_tag != 'clipPath': if clip_tag != 'clipPath':
return DEFAULT return DEFAULT
# Find the first shape child of the clipPath shapes = _visible_clip_shapes(clip_elem, ctx.parent_by_id)
shape = None if not shapes:
for child in clip_elem:
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if child_tag in ('circle', 'ellipse', 'rect', 'path', 'polygon'):
shape = child
break
if shape is None:
return DEFAULT return DEFAULT
if len(shapes) != 1:
raise ValueError('clipPath must contain exactly one direct supported shape')
shape = shapes[0]
shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '') shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '')
is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox' is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox'
@@ -5191,17 +5335,12 @@ def _resolve_nested_svg_clip_geometry(
if not clip_id or clip_id not in ctx.defs: if not clip_id or clip_id not in ctx.defs:
return default return default
clip_elem = ctx.defs[clip_id] clip_elem = ctx.defs[clip_id]
shape = next( shapes = _visible_clip_shapes(clip_elem, ctx.parent_by_id)
( if not shapes:
child
for child in clip_elem
if child.tag.rsplit('}', 1)[-1]
in {'circle', 'ellipse', 'rect', 'path', 'polygon'}
),
None,
)
if shape is None:
return default return default
if len(shapes) != 1:
raise ValueError('clipPath must contain exactly one direct supported shape')
shape = shapes[0]
if ( if (
clip_elem.get('clipPathUnits', 'userSpaceOnUse') clip_elem.get('clipPathUnits', 'userSpaceOnUse')
!= 'userSpaceOnUse' != 'userSpaceOnUse'
@@ -5259,7 +5398,7 @@ def _resolve_nested_svg_clip_geometry(
) )
def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult: def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert a nested <svg> sprite-crop wrapper to a DrawingML picture. """Convert a nested <svg> sprite-crop wrapper to a DrawingML picture.
Pattern produced by pptx_to_svg:: Pattern produced by pptx_to_svg::
@@ -5273,6 +5412,8 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
""" """
crop = parse_project_nested_svg_crop(elem) crop = parse_project_nested_svg_crop(elem)
image_elem = crop.image image_elem = crop.image
if empty_clip_path_reason(image_elem, ctx.defs, ctx.parent_by_id):
return None
source = load_project_image_source( source = load_project_image_source(
image_elem, image_elem,
ctx.svg_dir, ctx.svg_dir,
@@ -50,7 +50,19 @@ def build_gradient_fill(
"""Build <a:gradFill> from SVG linearGradient or radialGradient element.""" """Build <a:gradFill> from SVG linearGradient or radialGradient element."""
native = preserved_native_gradient_xml(grad_elem) native = preserved_native_gradient_xml(grad_elem)
if native is not None: if native is not None:
return native if opacity is None or opacity == 1.0:
return native
# Parse a fresh copy; other shapes may reuse the same gradient payload.
gradient = ET.fromstring(native)
namespace = '{http://schemas.openxmlformats.org/drawingml/2006/main}'
for stop in gradient.findall(f'{namespace}gsLst/{namespace}gs'):
for color in stop:
alpha = color.find(f'{namespace}alpha')
existing = 1.0 if alpha is None else int(alpha.get('val')) / 100000
if alpha is None:
alpha = ET.SubElement(color, f'{namespace}alpha')
alpha.set('val', str(quantize_ooxml_alpha(existing * opacity)))
return ET.tostring(gradient, encoding='unicode')
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '') tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
stops_xml = [] stops_xml = []
@@ -196,20 +208,11 @@ def build_fill_xml(
return '<a:noFill/>' return '<a:noFill/>'
def build_pattern_fill( def parse_pattern_colors(
pattern_elem: ET.Element, pattern_elem: ET.Element,
opacity: float | None = None, opacity: float | None = None,
theme_color_spec: ThemeColorSpec | None = None, ) -> tuple[str | None, str | None, float | None, float | None]:
usage: str = "fill", """Resolve native pattern colors and alpha from metadata or child paints."""
) -> str:
"""Build <a:pattFill> from an SVG <pattern> emitted by pptx_to_svg.
Reads the round-trip annotations (data-pptx-pattern / data-pptx-fg /
data-pptx-bg) when present. Falls back to inspecting the inner stroke /
rect colors when annotations are absent (hand-authored SVG).
"""
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
paint_entries = [] paint_entries = []
for child in pattern_elem: for child in pattern_elem:
tag = child.tag.replace(f'{{{SVG_NS}}}', '') tag = child.tag.replace(f'{{{SVG_NS}}}', '')
@@ -262,7 +265,14 @@ def build_pattern_fill(
fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0) fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0)
bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0) bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0)
if not fg_hex: if not fg_hex:
return '' if pattern_elem.get('data-pptx-pattern') is not None:
pattern_id = pattern_elem.get('id') or '<unnamed>'
raise ValueError(
f'<pattern id="{pattern_id}"> is missing a valid foreground color; '
'set data-pptx-fg or add a child stroke/fill after the first '
'background rect fill'
)
return None, bg_hex, None, None
bg_source = next(( bg_source = next((
entry entry
@@ -298,6 +308,20 @@ def build_pattern_fill(
fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity) fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity)
bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity) bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity)
return fg_hex, bg_hex, fg_opacity, bg_opacity
def build_pattern_fill(
pattern_elem: ET.Element,
opacity: float | None = None,
theme_color_spec: ThemeColorSpec | None = None,
usage: str = "fill",
) -> str:
"""Build native pattFill, retaining the unmarked-pattern compatibility fallback."""
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
fg_hex, bg_hex, fg_opacity, bg_opacity = parse_pattern_colors(pattern_elem, opacity)
if not fg_hex:
return ''
fg_alpha_xml = ( fg_alpha_xml = (
f'<a:alpha val="{quantize_ooxml_alpha(fg_opacity)}"/>' f'<a:alpha val="{quantize_ooxml_alpha(fg_opacity)}"/>'
if fg_opacity is not None else '' if fg_opacity is not None else ''
@@ -478,6 +502,8 @@ def build_stroke_xml(
return '<a:ln><a:noFill/></a:ln>' return '<a:ln><a:noFill/></a:ln>'
source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0) source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
if source_width <= 0:
return '<a:ln><a:noFill/></a:ln>'
width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx)) width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx))
validate_ooxml_line_width(width_emu) validate_ooxml_line_width(width_emu)
@@ -8,12 +8,14 @@ and transform authoring contracts.
from __future__ import annotations from __future__ import annotations
import colorsys import colorsys
import json
import math import math
import re import re
import unicodedata import unicodedata
from collections import Counter from collections import Counter
from collections.abc import Iterator from collections.abc import Iterator
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from pptx_gradients import ( from pptx_gradients import (
@@ -1470,6 +1472,36 @@ def parse_inline_style(style_str: str | None) -> dict[str, str]:
return styles return styles
def svg_hidden_reason(
element: ET.Element,
parent_by_id: dict[int, ET.Element],
*,
preserve_native_carriers: bool = False,
) -> str | None:
"""Resolve display suppression and inherited visibility, including overrides."""
visibility = None
current: ET.Element | None = element
while current is not None:
styles = parse_inline_style(current.get('style'))
display = styles.get('display', current.get('display', '')).strip().lower()
if display == 'none':
return 'display:none'
native_carrier = (
preserve_native_carriers
and current is element
and current.get('data-pptx-part') == 'geometry'
and current.get('data-pptx-object') in {'shape', 'connector'}
)
if visibility is None and not native_carrier:
value = styles.get('visibility', current.get('visibility', '')).strip().lower()
if value and value not in {'inherit', 'unset'}:
visibility = value
current = parent_by_id.get(id(current))
if visibility in {'hidden', 'collapse'}:
return f'visibility:{visibility}'
return None
def iter_project_geometry_lengths( def iter_project_geometry_lengths(
root: ET.Element, root: ET.Element,
) -> Iterator[tuple[ET.Element, str, str, str]]: ) -> Iterator[tuple[ET.Element, str, str, str]]:
@@ -3571,22 +3603,60 @@ def _estimate_grapheme_width(cluster: str, font_size: float) -> float:
return max(_estimate_character_width(ch, font_size) for ch in bases) return max(_estimate_character_width(ch, font_size) for ch in bases)
_FONT_ADVANCES_CACHE = None
def primary_font_family(font_family: str | None) -> str:
"""Normalize the first family in a font stack."""
return str(font_family or '').split(',')[0].strip().strip('\'"').lower()
def get_font_advances(
font_family: str | None,
font_weight: str = '400',
font_style: str = 'normal',
) -> dict[str, float] | None:
"""Return bundled glyph advances for the primary family and style."""
family = primary_font_family(font_family)
if not family:
return None
global _FONT_ADVANCES_CACHE
if _FONT_ADVANCES_CACHE is None:
with Path(__file__).with_name('font_advances.json').open(encoding='utf-8') as handle:
_FONT_ADVANCES_CACHE = json.load(handle)['families']
bold = font_weight in ('bold', '600', '700', '800', '900')
italic = font_style in ('italic', 'oblique')
style = 'bold' if bold else 'regular'
if italic:
style = 'bold-italic' if bold else 'italic'
entry = _FONT_ADVANCES_CACHE.get(family, {}).get(style)
return entry['advances'] if entry is not None else None
def estimate_text_cluster_widths( def estimate_text_cluster_widths(
text: str, text: str,
font_size: float, font_size: float,
font_weight: str = '400', font_weight: str = '400',
*,
font_family: str | None = None,
font_style: str = 'normal',
) -> list[float]: ) -> list[float]:
"""Estimate each project text cluster without inserting tracking.""" """Estimate each project text cluster without inserting tracking."""
clusters = split_project_text_clusters(text) advances = get_font_advances(font_family, font_weight, font_style)
widths = [ bold = font_weight in ('bold', '600', '700', '800', '900')
_estimate_grapheme_width(cluster, font_size) widths = []
for cluster in clusters for cluster in split_project_text_clusters(text):
] cjk = any(is_cjk_char(ch) for ch in cluster)
if font_weight in ('bold', '600', '700', '800', '900'): if advances is not None and not cjk and all(
widths = [ ch in advances and not _is_emoji_base(ch) and not _is_grapheme_extend(ch)
width if any(is_cjk_char(ch) for ch in cluster) else width * 1.05 for ch in cluster
for cluster, width in zip(clusters, widths) ):
] widths.append(sum(advances[ch] for ch in cluster) * font_size)
continue
width = _estimate_grapheme_width(cluster, font_size)
widths.append(width * 1.05 if bold and not cjk else width)
return widths return widths
@@ -10,13 +10,16 @@ import sys
from typing import Any from typing import Any
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from pptx_shapes import validate_ooxml_xfrm
from ..drawingml.utils import px_to_emu
from ..drawingml.context import ConvertContext, ShapeResult from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import _xml_escape from ..drawingml.utils import _xml_escape
from .chart_data import _chart_data, _chart_plot_area_layout from .chart_data import _chart_data, _chart_plot_area_layout
from .chart_style import ( from .chart_style import (
_axis_titles, _axis_titles,
_chart_companion_entries, _chart_companion_entries,
_chart_companion_text_xml, _chart_companion_shapes,
_chart_text_sizes, _chart_text_sizes,
_chart_title_is_bounded, _chart_title_is_bounded,
_classic_chart_style, _classic_chart_style,
@@ -45,21 +48,22 @@ from .inline_formula import (
inline_formula_marker_errors, inline_formula_marker_errors,
) )
from .marker_common import ( from .marker_common import (
CHART_CONTENT_TYPE, _bounds,
CHARTEX_CONTENT_TYPE,
CHARTEX_REL_TYPE,
CHARTEX_URI,
CHART_COLOR_STYLE_CONTENT_TYPE, CHART_COLOR_STYLE_CONTENT_TYPE,
CHART_CONTENT_TYPE,
CHART_REL_TYPE, CHART_REL_TYPE,
CHART_STYLE_CONTENT_TYPE, CHART_STYLE_CONTENT_TYPE,
CHART_URI, CHART_URI,
_NATIVE_KINDS, CHARTEX_CONTENT_TYPE,
_bounds, CHARTEX_REL_TYPE,
CHARTEX_URI,
_load_payload, _load_payload,
_local_tag, _local_tag,
_native_marker_validation_context, _NATIVE_KINDS,
_validate_bounds_inputs,
native_marker_transform, native_marker_transform,
_native_marker_validation_context,
_powerpoint_emu,
_validate_bounds_inputs,
) )
from .marker_attributes import ( from .marker_attributes import (
JSON_NATIVE_AUTHORITY, JSON_NATIVE_AUTHORITY,
@@ -269,10 +273,79 @@ def _source_chart_frame_xml(
return ET.tostring(frame, encoding="unicode") return ET.tostring(frame, encoding="unicode")
_AXIS_LABEL_MARGIN_EM = 2.0 # PowerPoint's own auto layout reserves about this much for tick labels
def _vertical_label_sides(chart_data: dict[str, Any]) -> set[str]:
"""Frame sides (top/bottom) where a classic chart draws tick labels."""
kind = chart_data.get("kind")
chart_type = chart_data.get("type")
if kind == "combo":
horizontal_roles = ["category"]
elif kind == "category" and chart_type in {"area", "column", "line"}:
horizontal_roles = ["category"]
elif kind == "category" and chart_type == "bar":
horizontal_roles = ["value"]
else:
return set()
axes = chart_data.get("axes") if isinstance(chart_data.get("axes"), dict) else {}
sides: set[str] = set()
for role in horizontal_roles:
config = axes.get(role) if isinstance(axes.get(role), dict) else {}
if config.get("visible") is False or config.get("label_position") == "none":
continue
if role == "value" and not chart_data.get("show_value_axis_labels", True):
continue
sides.add(str(config.get("position") or "bottom"))
return sides
def _grow_chart_frame_for_axis_labels(
chart_data: dict[str, Any] | None,
bounds: tuple[int, int, int, int],
*,
axis_font_px: float,
) -> tuple[int, int, int, int]:
"""Extend the chart frame past the plot so PowerPoint keeps the manual layout.
PowerPoint reads the plot area's manual y/h but discards them when the
strip between the plot edge and the frame edge cannot hold the tick
labels it lays out (about two em); it then auto-fits the plot to the
frame top and covers whatever was drawn above the plot. The frame is
invisible, so growing it outward on the label side costs nothing and
keeps the plot where the fallback drew it.
"""
off_x, off_y, ext_cx, ext_cy = bounds
if chart_data is None:
return bounds
plot_area = chart_data.get("plot_area")
if not isinstance(plot_area, dict):
return bounds
required = px_to_emu(axis_font_px * _AXIS_LABEL_MARGIN_EM)
plot_top = _powerpoint_emu(plot_area["y"], "chart plot_area.y")
plot_bottom = plot_top + _powerpoint_emu(plot_area["height"], "chart plot_area.height", positive=True)
for side in _vertical_label_sides(chart_data):
if side == "bottom":
deficit = required - ((off_y + ext_cy) - plot_bottom)
if deficit > 0:
ext_cy += deficit
elif side == "top":
deficit = required - (plot_top - off_y)
if deficit > 0:
off_y -= deficit
ext_cy += deficit
return off_x, off_y, ext_cx, ext_cy
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult: def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
source_package = _decode_source_chart_package(payload) source_package = _decode_source_chart_package(payload)
chart_data = None if source_package is not None else _chart_data(payload) chart_data = None if source_package is not None else _chart_data(payload)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx) text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
off_x, off_y, ext_cx, ext_cy = _grow_chart_frame_for_axis_labels(
chart_data,
_bounds(elem, payload, ctx),
axis_font_px=text_sizes["axis"] / 75,
)
shape_id = ( shape_id = (
ctx.claim_shape_id( ctx.claim_shape_id(
@@ -389,27 +462,54 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
</a:graphic> </a:graphic>
</p:graphicFrame>''' </p:graphicFrame>'''
) )
chart_bounds = (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
if source_package is not None: if source_package is not None:
companion_xml = "" return ShapeResult(xml=chart_frame_xml, bounds_emu=chart_bounds)
else:
assert chart_data is not None assert chart_data is not None
text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles) text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles) chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles)
companion_xml = _chart_companion_text_xml( companions = _chart_companion_shapes(
ctx, ctx,
payload, payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy), chart_bounds=(off_x, off_y, ext_cx, ext_cy),
chart_style=chart_style, chart_style=chart_style,
note_font_size=text_sizes["note"], note_font_size=text_sizes["note"],
title_font_size=text_sizes["title"], title_font_size=text_sizes["title"],
include_title=( include_title=(
chart_data["kind"] == "chartex" chart_data["kind"] == "chartex"
or _chart_title_is_bounded(payload) or _chart_title_is_bounded(payload)
), ),
include_subtitle_as_caption=chart_data["kind"] == "chartex", include_subtitle_as_caption=chart_data["kind"] == "chartex",
) fallback=None if native_json_is_authoritative(elem) else elem,
xml = chart_frame_xml + companion_xml )
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy)) if not companions:
return ShapeResult(xml=chart_frame_xml, bounds_emu=chart_bounds)
min_x, min_y, max_x, max_y = chart_bounds
for companion in companions:
assert companion.bounds_emu is not None
x0, y0, x1, y1 = companion.bounds_emu
min_x, min_y = min(min_x, x0), min(min_y, y0)
max_x, max_y = max(max_x, x1), max(max_y, y1)
group_w, group_h = max_x - min_x, max_y - min_y
validate_ooxml_xfrm(min_x, min_y, group_w, group_h)
group_id = ctx.next_id()
companion_xml = "".join(companion.xml for companion in companions)
# The marker owns one selectable/animatable unit. Identity mapping keeps
# the chart and labels at their already resolved slide coordinates.
xml = f'''<p:grpSp>
<p:nvGrpSpPr>
<p:cNvPr id="{group_id}" name="{name}"/>
<p:cNvGrpSpPr/><p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr><a:xfrm>
<a:off x="{min_x}" y="{min_y}"/><a:ext cx="{group_w}" cy="{group_h}"/>
<a:chOff x="{min_x}" y="{min_y}"/><a:chExt cx="{group_w}" cy="{group_h}"/>
</a:xfrm></p:grpSpPr>
{chart_frame_xml}{companion_xml}
</p:grpSp>'''
return ShapeResult(xml=xml, bounds_emu=(min_x, min_y, max_x, max_y))
def _validate_native_object_marker_payload( def _validate_native_object_marker_payload(
@@ -29,6 +29,13 @@ def _chart_number(value: Any) -> int | float:
return int(number) if number.is_integer() else number return int(number) if number.is_integer() else number
def _chart_point_value(value: Any) -> int | float | None:
"""A series point: a finite number, or ``null`` for a gap in the series."""
if value is None:
return None
return _chart_number(value)
def _chart_list(value: Any, field_name: str) -> list[Any]: def _chart_list(value: Any, field_name: str) -> list[Any]:
if value is None: if value is None:
return [] return []
@@ -290,8 +297,9 @@ def _chart_axes(
if not isinstance(raw_config, dict): if not isinstance(raw_config, dict):
raise RuntimeError(f"Native PPTX chart axes.{role} must be an object") raise RuntimeError(f"Native PPTX chart axes.{role} must be an object")
allowed_fields = { allowed_fields = {
"kind", "label_position", "major_gridlines", "major_unit", "color", "cross_between", "font_family", "font_size", "kind",
"maximum", "minimum", "number_format", "position", "reverse", "label_position", "major_gridlines", "major_unit", "maximum",
"minimum", "number_format", "position", "reverse", "tick_marks",
"visible", "visible",
} }
unknown_fields = set(raw_config) - allowed_fields unknown_fields = set(raw_config) - allowed_fields
@@ -375,6 +383,55 @@ def _chart_axes(
) )
config["label_position"] = label_position config["label_position"] = label_position
raw_color = raw_config.get("color")
if raw_color is not None:
color = _hex_or_none(raw_color)
if color is None:
raise RuntimeError(f"Native PPTX chart axes.{role}.color must be a colour")
config["color"] = color
raw_font_family = raw_config.get("font_family")
if raw_font_family is not None:
if not isinstance(raw_font_family, str) or not raw_font_family.strip():
raise RuntimeError(
f"Native PPTX chart axes.{role}.font_family must be a non-empty string"
)
config["font_family"] = raw_font_family.strip()
raw_font_size = raw_config.get("font_size")
if raw_font_size is not None:
font_size = _number(raw_font_size, f"chart axes.{role}.font_size")
if font_size <= 0:
raise RuntimeError(f"Native PPTX chart axes.{role}.font_size must be positive")
config["font_size"] = font_size
raw_tick_marks = raw_config.get("tick_marks")
if raw_tick_marks is not None:
tick_marks = _compact_key(raw_tick_marks)
if tick_marks not in {"cross", "in", "none", "out"}:
raise RuntimeError(
f"Native PPTX chart axes.{role}.tick_marks must be one of: "
"cross, in, none, out"
)
config["tick_marks"] = tick_marks
raw_cross_between = raw_config.get("cross_between")
if raw_cross_between is not None:
if role not in {"value", "secondary_value"}:
raise RuntimeError(
f"Native PPTX chart axes.{role}.cross_between is unsupported"
)
cross_between = {
"between": "between",
"midcat": "midCat",
"midcategory": "midCat",
"oncategories": "midCat",
"onticks": "midCat",
}.get(_compact_key(raw_cross_between))
if cross_between is None:
raise RuntimeError(
f"Native PPTX chart axes.{role}.cross_between must be between or mid_category"
)
config["cross_between"] = cross_between
raw_number_format = raw_config.get("number_format") raw_number_format = raw_config.get("number_format")
if raw_number_format is not None: if raw_number_format is not None:
if not isinstance(raw_number_format, str): if not isinstance(raw_number_format, str):
@@ -414,6 +471,14 @@ def _chart_axes(
return axes return axes
def _category_axis_reversed(config: dict[str, Any], chart_type: str) -> bool:
"""Bar categories read top-down in payload order unless ``reverse`` says otherwise."""
reverse = config.get("reverse")
if reverse is None:
return chart_type == "bar"
return bool(reverse)
def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool: def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool:
return axes.get("category", {}).get("kind") == "date" return axes.get("category", {}).get("kind") == "date"
@@ -730,6 +795,52 @@ def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str,
return style return style
def _point_color(color: Any, chart_type: str) -> str | None:
"""Hex per-point colour; line series may leave a point unmarked with ``null``."""
if chart_type == "line" and (color is None or _compact_key(color) == "none"):
return None
if color is None:
raise RuntimeError("Native PPTX chart series point_colors entries must be colours")
return _clean_hex(color, "#4472C4")
def _marker_size(source: dict[str, Any], chart_type: str) -> float | None:
"""Line marker diameter in SVG px (``marker_size``), validated for the 2..72pt range."""
raw = _first_present(source.get("marker_size"), source.get("markerSize"))
if raw is None:
return None
if chart_type != "line":
raise RuntimeError("Native PPTX chart marker_size applies to line series only")
size = _number(raw, "chart marker_size")
if not 2 <= size * 0.75 <= 72:
raise RuntimeError("Native PPTX chart marker_size must resolve to 2..72pt")
return size
def _gap_width(source: dict[str, Any], chart_type: str) -> int | None:
"""Bar/column gap width as a percentage of one bar (``gap_width``, 0..500)."""
raw = _first_present(source.get("gap_width"), source.get("gapWidth"))
if raw is None:
return None
if chart_type not in {"bar", "column"}:
raise RuntimeError("Native PPTX chart gap_width applies to bar and column charts only")
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not 0 <= raw <= 500:
raise RuntimeError("Native PPTX chart gap_width must be a number between 0 and 500")
return int(round(raw))
def _overlap(source: dict[str, Any], chart_type: str) -> int | None:
"""Bar/column series overlap as a percentage of one bar (``overlap``, -100..100)."""
raw = source.get("overlap")
if raw is None:
return None
if chart_type not in {"bar", "column"}:
raise RuntimeError("Native PPTX chart overlap applies to bar and column charts only")
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not -100 <= raw <= 100:
raise RuntimeError("Native PPTX chart overlap must be a number between -100 and 100")
return int(round(raw))
def _category_series( def _category_series(
payload: dict[str, Any], payload: dict[str, Any],
categories: list[Any], categories: list[Any],
@@ -752,18 +863,24 @@ def _category_series(
if not isinstance(item, dict): if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart series entries must be objects") raise RuntimeError("Native PPTX chart series entries must be objects")
values = [ values = [
_chart_number(value) _chart_point_value(value)
for value in _chart_list(item.get("values", []), "series[].values") for value in _chart_list(item.get("values", []), "series[].values")
] ]
if len(values) != len(categories): if len(values) != len(categories):
raise RuntimeError("Native PPTX chart series values must match categories length") raise RuntimeError("Native PPTX chart series values must match categories length")
if all(value is None for value in values):
raise RuntimeError("Native PPTX chart series values must contain a number")
if chart_type not in {"area", "bar", "column", "line"} and None in values:
raise RuntimeError(
f"Native PPTX {chart_type} chart series values cannot contain null gaps"
)
raw_point_colors = _first_present( raw_point_colors = _first_present(
item.get("point_colors"), item.get("point_colors"),
item.get("pointColors"), item.get("pointColors"),
root_point_colors if idx == 1 else None, root_point_colors if idx == 1 else None,
) )
point_colors = [ point_colors = [
_clean_hex(color, "#4472C4") _point_color(color, chart_type)
for color in _chart_list(raw_point_colors, "series[].point_colors") for color in _chart_list(raw_point_colors, "series[].point_colors")
] ]
if point_colors and len(point_colors) != len(values): if point_colors and len(point_colors) != len(values):
@@ -771,6 +888,9 @@ def _category_series(
series_item = {"name": str(item.get("name") or f"Series {idx}"), "values": values} series_item = {"name": str(item.get("name") or f"Series {idx}"), "values": values}
if point_colors: if point_colors:
series_item["point_colors"] = point_colors series_item["point_colors"] = point_colors
marker_size = _marker_size(item, chart_type)
if marker_size is not None:
series_item["marker_size"] = marker_size
fill_opacity = _first_present( fill_opacity = _first_present(
item.get("fill_opacity"), item.get("fill_opacity"),
item.get("fillOpacity"), item.get("fillOpacity"),
@@ -882,6 +1002,9 @@ def _category_chart_data(
"of_pie_type": of_pie_type, "of_pie_type": of_pie_type,
"hole_size": _doughnut_hole_size(payload, chart_type), "hole_size": _doughnut_hole_size(payload, chart_type),
"line_style": line_style, "line_style": line_style,
"marker_size": _marker_size(payload, chart_type),
"gap_width": _gap_width(payload, chart_type),
"overlap": _overlap(payload, chart_type),
"radar_marker_style": radar_marker_style, "radar_marker_style": radar_marker_style,
"radar_style": radar_style, "radar_style": radar_style,
"show_value_axis_labels": _chart_bool( "show_value_axis_labels": _chart_bool(
@@ -1036,6 +1159,10 @@ def _combo_plot_entry(
entry["series_indices"] = series_indices entry["series_indices"] = series_indices
if chart_type == "line": if chart_type == "line":
entry["line_style"] = _line_style(plot_payload, alias_style) entry["line_style"] = _line_style(plot_payload, alias_style)
entry["marker_size"] = _marker_size(plot_payload, chart_type)
if chart_type == "column":
entry["gap_width"] = _gap_width(plot_payload, chart_type)
entry["overlap"] = _overlap(plot_payload, chart_type)
return entry return entry
@@ -9,9 +9,11 @@ from xml.etree import ElementTree as ET
from .marker_attributes import native_import_source, native_json_is_authoritative from .marker_attributes import native_import_source, native_json_is_authoritative
from ..drawingml.context import ConvertContext from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import ( from ..drawingml.utils import (
_xml_escape, _xml_escape,
ctx_x,
ctx_y,
detect_text_lang, detect_text_lang,
parse_font_family, parse_font_family,
px_to_emu, px_to_emu,
@@ -19,7 +21,7 @@ from ..drawingml.utils import (
text_has_rtl_characters, text_has_rtl_characters,
text_uses_rtl, text_uses_rtl,
) )
from .chart_data import _DEFAULT_CHART_COLORS, _chart_data from .chart_data import _DEFAULT_CHART_COLORS, _category_axis_reversed, _chart_data
from .marker_common import ( from .marker_common import (
_bool_attr, _bool_attr,
_bounds, _bounds,
@@ -777,6 +779,322 @@ def _native_chart_point_color_warnings(
] ]
def _fallback_data_shapes(elem: ET.Element) -> list[Any]:
"""Painted fallback geometry that is not axis, grid, or legend chrome."""
return [
record
for record in _fallback_shape_records(elem)
if not _record_has_label(record, "axis", "grid", "legend")
]
def _series_palette(payload: dict[str, Any], series_count: int) -> set[str]:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
raw_colors = _first_present(style.get("colors"), payload.get("colors"))
palette = (
[_clean_hex(color, "#4472C4") for color in raw_colors]
if isinstance(raw_colors, list) and raw_colors
else list(_DEFAULT_CHART_COLORS)
)
return {palette[idx % len(palette)] for idx in range(series_count)}
def _typed_series(chart_data: dict[str, Any], chart_type: str) -> list[dict[str, Any]]:
"""Series of one plot type from a category chart or the matching combo plots."""
if chart_data.get("kind") == "category" and chart_data.get("type") == chart_type:
return list(chart_data.get("series") or [])
if chart_data.get("kind") == "combo":
return [
item
for plot in chart_data.get("plots") or []
if plot.get("type") == chart_type
for item in plot.get("series") or []
]
return []
def _native_chart_line_marker_warnings(
elem: ET.Element,
payload: dict[str, Any],
chart_data: dict[str, Any],
) -> list[str]:
line_series = _typed_series(chart_data, "line")
if not line_series:
return []
dots = [
record
for record in _fallback_data_shapes(elem)
if record.tag in {"circle", "ellipse"} and record.fill is not None
]
if not dots:
return []
if chart_data.get("kind") == "category":
marker_styles = [chart_data.get("line_style")]
else:
marker_styles = [
plot.get("line_style")
for plot in chart_data.get("plots") or []
if plot.get("type") == "line"
]
has_markers = "lineMarker" in marker_styles or any(
item.get("point_colors") for item in line_series
)
warnings: list[str] = []
if not has_markers:
warnings.append(
f"Native PPTX line chart fallback draws {len(dots)} point marker(s) "
"but the payload has none; set line_style \"lineMarker\" (marker_size in px) "
"for every point, or series point_colors with a colour per marked point "
"and null elsewhere"
)
series_count = (
len(chart_data.get("series") or [])
if chart_data.get("kind") == "category"
else sum(len(plot.get("series") or []) for plot in chart_data.get("plots") or [])
)
known = _series_palette(payload, series_count) | {
color
for item in line_series
for color in item.get("point_colors", [])
if color is not None
}
deviations = sorted({
record.fill for record in dots if record.fill not in known
})
warnings.extend(
f"Native PPTX line marker fill #{color} deviates from its series color, "
"but series point_colors is absent"
for color in deviations
)
return warnings
def _native_chart_area_warnings(
elem: ET.Element,
payload: dict[str, Any],
chart_data: dict[str, Any],
) -> list[str]:
area_series = _typed_series(chart_data, "area")
if not area_series:
return []
shapes = _fallback_data_shapes(elem)
regions = [
record
for record in shapes
if record.tag in {"path", "polygon"} and record.fill is not None
]
warnings: list[str] = []
translucent = sorted({
record.fill_opacity
for record in regions
if record.fill_opacity is not None and record.fill_opacity < 1
})
if translucent and not any(item.get("fill_opacity") is not None for item in area_series):
sample = ", ".join(f"{value:g}" for value in translucent[:3])
warnings.append(
f"Native PPTX area chart fallback fills are translucent (fill-opacity {sample}) "
"but no area series sets fill_opacity; native areas export opaque"
)
if chart_data.get("kind") != "category":
return warnings
strokes = [
record
for record in shapes
if record.tag in {"path", "polyline"} and record.fill is None and record.stroke is not None
]
dots = [
record
for record in shapes
if record.tag in {"circle", "ellipse"} and record.fill is not None
]
outlined = any(item.get("line_width") is not None for item in area_series)
if (strokes and not outlined) or dots:
warnings.append(
"Native PPTX area chart fallback draws a line"
+ (" and point markers" if dots else "")
+ " over the filled region, but an area series exports as a fill without "
"either; give the series line_width for a same-colour outline, or use type "
"\"combo\" with an area plot (fill_opacity) under a line plot (line_width, "
"line_style \"lineMarker\")"
)
return warnings
def _native_chart_category_order_warnings(
elem: ET.Element,
payload: dict[str, Any],
chart_data: dict[str, Any],
) -> list[str]:
chart_type = chart_data.get("type")
if chart_data.get("kind") != "category" or chart_type not in {"bar", "column"}:
return []
categories = [str(item) for item in chart_data.get("categories") or []]
if len(categories) < 2:
return []
first_text = _normalized_fallback_text(categories[0])
last_text = _normalized_fallback_text(categories[-1])
records = [
record
for record in _fallback_text_records(elem)
if not _record_has_label(record, "legend")
and record.x is not None
and record.y is not None
]
firsts = [record for record in records if record.text == first_text]
lasts = [record for record in records if record.text == last_text]
if len(firsts) != 1 or len(lasts) != 1 or first_text == last_text:
return []
first, last = firsts[0], lasts[0]
axes = chart_data.get("axes") if isinstance(chart_data.get("axes"), dict) else {}
category = axes.get("category") if isinstance(axes.get("category"), dict) else {}
resolved = _category_axis_reversed(category, chart_type)
if chart_type == "bar":
expected = first.y < last.y
drawn = "top-down" if expected else "bottom-up"
else:
expected = first.x > last.x
drawn = "right-to-left" if expected else "left-to-right"
if expected == resolved:
return []
return [
f"Native PPTX {chart_type} chart fallback lists categories {drawn} "
f"({categories[0]!r} before {categories[-1]!r}) but the payload resolves "
f"axes.category.reverse to {str(resolved).lower()}; set axes.category.reverse: "
f"{str(expected).lower()}"
]
def _fallback_bar_clusters(
elem: ET.Element,
chart_data: dict[str, Any],
) -> tuple[float, dict[int, list[tuple[float, float]]]] | None:
"""Fallback bars grouped by category slot: slot size plus (low, high) edges per slot."""
chart_type = chart_data.get("type")
plot_area = chart_data.get("plot_area")
if chart_data.get("kind") != "category" or chart_type not in {"bar", "column"}:
return None
if not isinstance(plot_area, dict):
return None
categories = chart_data.get("categories") or []
if not categories or not chart_data.get("series"):
return None
px, py = float(plot_area["x"]), float(plot_area["y"])
pw, ph = float(plot_area["width"]), float(plot_area["height"])
along = 0 if chart_type == "column" else 1 # axis index that carries the categories
origin = px if along == 0 else py
extent = pw if along == 0 else ph
slot = extent / len(categories)
clusters: dict[int, list[tuple[float, float]]] = {}
for record in _fallback_data_shapes(elem):
if record.tag != "rect" or record.fill is None:
continue
x0, y0, x1, y1 = record.bounds
if (x1 - x0) * (y1 - y0) >= 0.9 * pw * ph:
continue
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
if not (px <= cx <= px + pw and py <= cy <= py + ph):
continue
low, high = (x0, x1) if along == 0 else (y0, y1)
index = min(len(categories) - 1, max(0, int((((low + high) / 2) - origin) // slot)))
clusters.setdefault(index, []).append((low, high))
if not clusters:
return None
return slot, clusters
def _median(values: list[float]) -> float:
ordered = sorted(values)
return ordered[len(ordered) // 2]
def _inferred_bar_gap_width(
elem: ET.Element,
chart_data: dict[str, Any],
) -> int | None:
"""Read the bar/column gap width from fallback bars inside a known plot area.
PowerPoint's gapWidth is the empty slot share as a percentage of one bar
width; the fallback slot is the plot extent divided by the category count.
"""
grouped = _fallback_bar_clusters(elem, chart_data)
if grouped is None:
return None
slot, clusters = grouped
cluster = _median([
max(high for _, high in edges) - min(low for low, _ in edges)
for edges in clusters.values()
])
if cluster <= 0:
return None
if cluster >= slot:
return 0
clustered = chart_data.get("grouping") == "clustered"
bars_per_cluster = len(chart_data.get("series") or []) if clustered else 1
bar_width = cluster / bars_per_cluster
return max(0, min(500, int(round((slot - cluster) / bar_width * 100))))
def _inferred_bar_overlap(
elem: ET.Element,
chart_data: dict[str, Any],
) -> int | None:
"""Read clustered bar overlap: the share of one bar that the next bar covers (negative = gap)."""
if chart_data.get("grouping") != "clustered" or len(chart_data.get("series") or []) < 2:
return None
grouped = _fallback_bar_clusters(elem, chart_data)
if grouped is None:
return None
_, clusters = grouped
pitches: list[float] = []
widths: list[float] = []
for edges in clusters.values():
ordered = sorted(edges)
if len(ordered) < 2:
continue
widths.extend(high - low for low, high in ordered)
pitches.extend(
ordered[idx + 1][0] - ordered[idx][0] for idx in range(len(ordered) - 1)
)
if not pitches or not widths:
return None
bar_width = _median(widths)
if bar_width <= 0:
return None
return max(-100, min(100, int(round((bar_width - _median(pitches)) / bar_width * 100))))
def _inferred_cross_between(
elem: ET.Element,
chart_data: dict[str, Any],
) -> str | None:
"""Read whether fallback line/area points sit between ticks or on the axis edge.
The first data point of a category line/area starts either half a slot
inside the plot (``between``) or on the plot edge (``midCat``).
"""
chart_type = chart_data.get("type")
plot_area = chart_data.get("plot_area")
if chart_data.get("kind") != "category" or chart_type not in {"area", "line"}:
return None
if not isinstance(plot_area, dict):
return None
categories = chart_data.get("categories") or []
if len(categories) < 2:
return None
px, pw = float(plot_area["x"]), float(plot_area["width"])
slot = pw / len(categories)
starts = [
record.bounds[0]
for record in _fallback_data_shapes(elem)
if record.tag in {"path", "polygon", "polyline"}
and (record.bounds[2] - record.bounds[0]) >= slot
]
if not starts:
return None
start = min(starts)
return "midCat" if abs(start - px) < abs(start - (px + slot / 2)) else "between"
def _native_chart_radial_warnings( def _native_chart_radial_warnings(
elem: ET.Element, elem: ET.Element,
payload: dict[str, Any], payload: dict[str, Any],
@@ -999,6 +1317,7 @@ def _native_chart_reverse_text_warnings(
message = ( message = (
"Native PPTX chart visible text not projected: " "Native PPTX chart visible text not projected: "
f"{sample}{suffix}. Use categories/data labels/axis labels/legend or companion text." f"{sample}{suffix}. Use categories/data labels/axis labels/legend or companion text."
" For formatted value labels, set data_labels.number_format to match the fallback."
) )
# ``73.0`` in the fallback while the payload value is 73: General format # ``73.0`` in the fallback while the payload value is 73: General format
# renders ``73``, so the decimals need an explicit number_format. # renders ``73``, so the decimals need an explicit number_format.
@@ -1057,6 +1376,9 @@ def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) ->
warnings.extend(_native_chart_radial_warnings(elem, payload, chart_data)) warnings.extend(_native_chart_radial_warnings(elem, payload, chart_data))
warnings.extend(_native_chart_tile_color_warnings(elem, payload, chart_data)) warnings.extend(_native_chart_tile_color_warnings(elem, payload, chart_data))
warnings.extend(_native_chart_reverse_text_warnings(elem, payload, chart_data)) warnings.extend(_native_chart_reverse_text_warnings(elem, payload, chart_data))
warnings.extend(_native_chart_line_marker_warnings(elem, payload, chart_data))
warnings.extend(_native_chart_area_warnings(elem, payload, chart_data))
warnings.extend(_native_chart_category_order_warnings(elem, payload, chart_data))
return warnings return warnings
@@ -1074,6 +1396,7 @@ def _text_box_xml(
align: str = "l", align: str = "l",
bold: bool = False, bold: bool = False,
font_face: str | None = None, font_face: str | None = None,
anchor: str = "t",
) -> str: ) -> str:
shape_id = ctx.next_id() shape_id = ctx.next_id()
align_key = _compact_key(align) align_key = _compact_key(align)
@@ -1116,7 +1439,7 @@ def _text_box_xml(
<a:ln><a:noFill/></a:ln> <a:ln><a:noFill/></a:ln>
</p:spPr> </p:spPr>
<p:txBody> <p:txBody>
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/> <a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="{anchor}" anchorCtr="0"/>
<a:lstStyle/> <a:lstStyle/>
<a:p><a:pPr algn="{algn}"{rtl_attr}/> <a:p><a:pPr algn="{algn}"{rtl_attr}/>
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r> <a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
@@ -1213,7 +1536,7 @@ def _validate_chart_companion_boxes(
below_index += 1 below_index += 1
def _chart_companion_text_xml( def _chart_companion_shapes(
ctx: ConvertContext, ctx: ConvertContext,
payload: dict[str, Any], payload: dict[str, Any],
*, *,
@@ -1223,19 +1546,38 @@ def _chart_companion_text_xml(
title_font_size: int, title_font_size: int,
include_title: bool, include_title: bool,
include_subtitle_as_caption: bool, include_subtitle_as_caption: bool,
) -> str: fallback: ET.Element | None = None,
) -> list[ShapeResult]:
"""Build editable companion text with its resolved slide-space bounds.
With an SVG-first ``fallback``, a companion whose text appears exactly
once in the fallback takes that text's position. The box is bottom
anchored with its bottom edge a quarter em under the SVG baseline, so
the glyph bottom lands where the SVG drew it whatever ascent the
renderer's font has (a taller face moves the text up, never down onto
the plot); ``text-anchor`` decides which edge ``x`` names.
"""
if _chart_title_is_bounded(payload): if _chart_title_is_bounded(payload):
include_title = True include_title = True
fallback_texts = (
[
record
for record in _fallback_text_records(fallback)
if record.x is not None and record.y is not None
]
if fallback is not None
else []
)
entries = _chart_companion_entries( entries = _chart_companion_entries(
payload, payload,
include_title=include_title, include_title=include_title,
include_subtitle_as_caption=include_subtitle_as_caption, include_subtitle_as_caption=include_subtitle_as_caption,
) )
if not entries: if not entries:
return "" return []
chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
parts: list[str] = [] shapes: list[ShapeResult] = []
below_index = 0 below_index = 0
for item in entries: for item in entries:
role = str(item.get("role") or "note") role = str(item.get("role") or "note")
@@ -1269,7 +1611,28 @@ def _chart_companion_text_xml(
ext_cx = chart_ext_cx ext_cx = chart_ext_cx
ext_cy = px_to_emu(16) ext_cy = px_to_emu(16)
below_index += 1 below_index += 1
parts.append(_text_box_xml( anchor = "t"
matches = [
record for record in fallback_texts
if record.text == _normalized_fallback_text(text)
]
if len(matches) == 1 and ctx is not None:
record = matches[0]
font_px = font_size / 100 / 0.75
anchor_x = ctx_x(record.x, ctx)
baseline_y = ctx_y(record.y, ctx)
width_px = ext_cx / px_to_emu(1)
left_px = {
"middle": anchor_x - width_px / 2,
"end": anchor_x - width_px,
}.get(record.anchor, anchor_x)
bottom_px = baseline_y + font_px * 0.25
off_x = _powerpoint_emu_value(px_to_emu(left_px), "companion text x")
off_y = _powerpoint_emu_value(px_to_emu(bottom_px - font_px * 1.6), "companion text y")
ext_cy = px_to_emu(font_px * 1.6)
anchor = "b"
align = {"middle": "ctr", "end": "r"}.get(record.anchor, "l")
text_xml = _text_box_xml(
ctx, ctx,
text=text, text=text,
role=role, role=role,
@@ -1282,5 +1645,10 @@ def _chart_companion_text_xml(
align=align, align=align,
bold=bold, bold=bold,
font_face=font_face, font_face=font_face,
anchor=anchor,
)
shapes.append(ShapeResult(
xml=text_xml,
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
)) ))
return "".join(parts) return shapes
@@ -17,6 +17,7 @@ from ..drawingml.utils import (
from .chart_data import ( from .chart_data import (
_DEFAULT_CHART_COLORS, _DEFAULT_CHART_COLORS,
_category_axis_is_date, _category_axis_is_date,
_category_axis_reversed,
_chart_list, _chart_list,
_chart_plot_area_layout, _chart_plot_area_layout,
_data_label_position, _data_label_position,
@@ -29,17 +30,21 @@ from .chart_style import (
_axis_titles, _axis_titles,
_chart_area_sp_pr_xml, _chart_area_sp_pr_xml,
_chart_line_sp_pr_xml, _chart_line_sp_pr_xml,
_chart_text_entry,
_chart_text_entry_color, _chart_text_entry_color,
_chart_text_entry_font_face, _chart_text_entry_font_face,
_chart_text_entry_font_size, _chart_text_entry_font_size,
_chart_text_entry,
_chart_text_sizes, _chart_text_sizes,
_chart_title_is_bounded, _chart_title_is_bounded,
_chart_tx_pr_xml, _chart_tx_pr_xml,
_classic_chart_style, _classic_chart_style,
_font_face_xml, _font_face_xml,
_inferred_bar_gap_width,
_inferred_bar_overlap,
_inferred_cross_between,
_major_gridlines_xml, _major_gridlines_xml,
) )
from .marker_attributes import native_json_is_authoritative
from .marker_common import ( from .marker_common import (
PACKAGE_REL_TYPE, PACKAGE_REL_TYPE,
_bool_attr, _bool_attr,
@@ -69,6 +74,7 @@ def _number_cache(
points = "".join( points = "".join(
f'<c:pt idx="{idx}"><c:v>{value}</c:v></c:pt>' f'<c:pt idx="{idx}"><c:v>{value}</c:v></c:pt>'
for idx, value in enumerate(values) for idx, value in enumerate(values)
if value is not None
) )
return ( return (
f'<c:numCache><c:formatCode>{_xml_escape(number_format)}</c:formatCode>' f'<c:numCache><c:formatCode>{_xml_escape(number_format)}</c:formatCode>'
@@ -413,12 +419,45 @@ def _data_point_colors_xml(
) )
def _marker_xml(symbol: str | None) -> str: def _marker_xml(
symbol: str | None,
*,
size_pt: int | None = None,
color: str | None = None,
) -> str:
if not symbol: if not symbol:
return "" return ""
if symbol == "none": if symbol == "none":
return '<c:marker><c:symbol val="none"/></c:marker>' return '<c:marker><c:symbol val="none"/></c:marker>'
return f'<c:marker><c:symbol val="{_xml_escape(symbol)}"/></c:marker>' size_xml = f'<c:size val="{size_pt}"/>' if size_pt is not None else ""
sp_pr_xml = ""
if color:
clean = _clean_hex(color, "#4472C4")
sp_pr_xml = (
f'<c:spPr><a:solidFill><a:srgbClr val="{clean}"/></a:solidFill>'
f'<a:ln><a:solidFill><a:srgbClr val="{clean}"/></a:solidFill></a:ln></c:spPr>'
)
return f'<c:marker><c:symbol val="{_xml_escape(symbol)}"/>{size_xml}{sp_pr_xml}</c:marker>'
def _marker_size_pt(size_px: Any) -> int | None:
if size_px is None:
return None
return max(2, min(72, int(round(float(size_px) * 0.75))))
def _line_point_markers_xml(
point_colors: list[str | None],
*,
size_pt: int | None,
) -> str:
"""Per-point line markers: a colour marks the point, ``None`` leaves it bare."""
return "".join(
f'<c:dPt><c:idx val="{idx}"/>'
f'{_marker_xml("none" if color is None else "circle", size_pt=size_pt, color=color)}'
"</c:dPt>"
for idx, color in enumerate(point_colors)
)
def _series_xml( def _series_xml(
@@ -428,6 +467,7 @@ def _series_xml(
chart_type: str, chart_type: str,
grouping: str | None = None, grouping: str | None = None,
line_style: str = "line", line_style: str = "line",
marker_size: Any = None,
radar_marker_style: str | None = None, radar_marker_style: str | None = None,
radar_style: str = "marker", radar_style: str = "marker",
colors: list[str], colors: list[str],
@@ -465,8 +505,12 @@ def _series_xml(
column_index = offset + start_column column_index = offset + start_column
fill_opacity = item.get("fill_opacity") if chart_type == "area" else None fill_opacity = item.get("fill_opacity") if chart_type == "area" else None
line_width = item.get("line_width") if chart_type in {"area", "line"} else None line_width = item.get("line_width") if chart_type in {"area", "line"} else None
series_color = _chart_color(colors, color_index)
# An area is a filled region: it draws an outline only when the payload
# asks for one with line_width, otherwise the fill edge is the shape.
color_xml = _series_color_xml( color_xml = _series_color_xml(
_chart_color(colors, color_index), series_color,
line=chart_type != "area" or line_width is not None,
fill_opacity=fill_opacity, fill_opacity=fill_opacity,
line_width=line_width, line_width=line_width,
) )
@@ -491,7 +535,19 @@ def _series_xml(
disable_negative_invert=True, disable_negative_invert=True,
) )
if chart_type == "line": if chart_type == "line":
marker_xml = _marker_xml("circle" if line_style == "lineMarker" else "none") size_pt = _marker_size_pt(
item.get("marker_size") if item.get("marker_size") is not None else marker_size
)
marker_xml = _marker_xml(
"circle" if line_style == "lineMarker" else "none",
size_pt=size_pt,
color=series_color if line_style == "lineMarker" else None,
)
if item.get("point_colors"):
point_colors_xml = _line_point_markers_xml(
item["point_colors"],
size_pt=size_pt,
)
smooth_xml = '<c:smooth val="0"/>' smooth_xml = '<c:smooth val="0"/>'
if chart_type == "radar": if chart_type == "radar":
if radar_style == "filled": if radar_style == "filled":
@@ -732,6 +788,17 @@ def _xy_series_xml(
return "".join(parts) return "".join(parts)
_DEFAULT_GAP_WIDTH = 150
def _bar_overlap_xml(grouping: str | None, overlap: int | None) -> str:
if grouping in {"stacked", "percentStacked"}:
return '<c:overlap val="100"/>'
if overlap is None:
return ""
return f'<c:overlap val="{overlap}"/>'
def _bar_chart_group_xml( def _bar_chart_group_xml(
chart_type: str, chart_type: str,
grouping: str, grouping: str,
@@ -741,21 +808,19 @@ def _bar_chart_group_xml(
val_ax_id: str, val_ax_id: str,
vary_colors: bool = False, vary_colors: bool = False,
data_labels_xml: str = "", data_labels_xml: str = "",
gap_width: int | None = None,
overlap: int | None = None,
) -> str: ) -> str:
bar_dir = "bar" if chart_type == "bar" else "col" bar_dir = "bar" if chart_type == "bar" else "col"
vary_colors_xml = '<c:varyColors val="1"/>' if vary_colors else '<c:varyColors val="0"/>' vary_colors_xml = '<c:varyColors val="1"/>' if vary_colors else '<c:varyColors val="0"/>'
overlap_xml = ( overlap_xml = _bar_overlap_xml(grouping, overlap)
'<c:overlap val="100"/>'
if grouping in {"stacked", "percentStacked"}
else ""
)
return ( return (
"<c:barChart>" "<c:barChart>"
f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>' f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>'
f"{vary_colors_xml}" f"{vary_colors_xml}"
f"{ser_xml}" f"{ser_xml}"
f"{data_labels_xml}" f"{data_labels_xml}"
'<c:gapWidth val="150"/>' f'<c:gapWidth val="{_DEFAULT_GAP_WIDTH if gap_width is None else gap_width}"/>'
f"{overlap_xml}" f"{overlap_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
"</c:barChart>" "</c:barChart>"
@@ -783,8 +848,10 @@ def _line_area_chart_group_xml(
) )
def _axis_scaling_xml(config: dict[str, Any]) -> str: def _axis_scaling_xml(config: dict[str, Any], *, reverse: bool | None = None) -> str:
orientation = "maxMin" if config.get("reverse") else "minMax" if reverse is None:
reverse = bool(config.get("reverse"))
orientation = "maxMin" if reverse else "minMax"
maximum = ( maximum = (
f'<c:max val="{config["maximum"]}"/>' f'<c:max val="{config["maximum"]}"/>'
if config.get("maximum") is not None else "" if config.get("maximum") is not None else ""
@@ -796,6 +863,11 @@ def _axis_scaling_xml(config: dict[str, Any]) -> str:
return f'<c:scaling><c:orientation val="{orientation}"/>{maximum}{minimum}</c:scaling>' return f'<c:scaling><c:orientation val="{orientation}"/>{maximum}{minimum}</c:scaling>'
def _axis_tick_marks(config: dict[str, Any]) -> str:
"""Axis tick marks default to none: generated fallbacks draw labels, not ticks."""
return str(config.get("tick_marks") or "none")
def _axis_position(config: dict[str, Any], default: str) -> str: def _axis_position(config: dict[str, Any], default: str) -> str:
return { return {
"bottom": "b", "bottom": "b",
@@ -871,12 +943,17 @@ def _axis_pair_xml(
) )
val_number_format = _axis_number_format_xml(value, default_value_format) val_number_format = _axis_number_format_xml(value, default_value_format)
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
axis_tx_pr = _chart_tx_pr_xml(
axis_font_size, def axis_tx_pr(config: dict[str, Any]) -> str:
chart_style.get("text_color"), # Tick-label text per axis: the payload's colour/font/size win over
font_face=chart_style.get("font_face"), # the chart-wide text style (category labels dark, value ticks grey).
language=primary_language, return _chart_tx_pr_xml(
) _font_size_hpt(config["font_size"]) if config.get("font_size") else axis_font_size,
config.get("color") or chart_style.get("text_color"),
font_face=config.get("font_family") or chart_style.get("font_face"),
language=primary_language,
)
cat_title_xml = "" if secondary else _axis_title_xml( cat_title_xml = "" if secondary else _axis_title_xml(
_first_present(axis_titles.get("category"), axis_titles.get("x")), _first_present(axis_titles.get("category"), axis_titles.get("x")),
font_size=axis_title_font_size, font_size=axis_title_font_size,
@@ -912,34 +989,54 @@ def _axis_pair_xml(
'<c:auto val="1"/><c:lblAlgn val="ctr"/><c:lblOffset val="100"/>' '<c:auto val="1"/><c:lblAlgn val="ctr"/><c:lblOffset val="100"/>'
'<c:noMultiLvlLbl val="0"/>' '<c:noMultiLvlLbl val="0"/>'
) )
is_combo = chart_type == "combo" # Written explicitly: renderers disagree on the default (LibreOffice
cross_between = "" # starts an unspecified area on the axis edge, PowerPoint between ticks).
if chart_type == "area" and category_kind == "date": default_cross_between = (
cross_between = '<c:crossBetween val="midCat"/>' "midCat" if chart_type == "area" and category_kind == "date" else "between"
elif chart_type == "stock" or is_combo: )
cross_between = '<c:crossBetween val="between"/>' cross_between = (
f'<c:crossBetween val="{value.get("cross_between") or default_cross_between}"/>'
)
cat_tick_marks = _axis_tick_marks(category)
val_tick_marks = _axis_tick_marks(value)
major_unit = ( major_unit = (
f'<c:majorUnit val="{value["major_unit"]}"/>' f'<c:majorUnit val="{value["major_unit"]}"/>'
if value.get("major_unit") is not None else "" if value.get("major_unit") is not None else ""
) )
value_crosses = "max" if secondary else "autoZero" # ``crosses`` places an axis at the first (autoZero) or last (max) point of
# the axis it crosses; a reversed axis swaps those ends, so the crossing
# follows the requested position XOR the crossing axis orientation. The
# hidden secondary pair keeps autoZero/max: its crossing sets the area
# fill baseline, not a visible axis position.
category_reversed = (
False if secondary else _category_axis_reversed(category, chart_type)
)
value_reversed = False if secondary else bool(value.get("reverse"))
far_val_pos = "t" if chart_type == "bar" else "r"
far_cat_pos = "r" if chart_type == "bar" else "t"
if secondary:
value_crosses = "max"
category_crosses = "autoZero"
else:
value_crosses = "max" if (val_pos == far_val_pos) != category_reversed else "autoZero"
category_crosses = "max" if (cat_pos == far_cat_pos) != value_reversed else "autoZero"
return ( return (
f"<c:{category_tag}>" f"<c:{category_tag}>"
f'<c:axId val="{cat_ax_id}"/>{_axis_scaling_xml(category)}' f'<c:axId val="{cat_ax_id}"/>{_axis_scaling_xml(category, reverse=category_reversed)}'
f'<c:delete val="{cat_delete}"/><c:axPos val="{cat_pos}"/>' f'<c:delete val="{cat_delete}"/><c:axPos val="{cat_pos}"/>'
f"{cat_gridlines}{cat_title_xml}{cat_number_format}" f"{cat_gridlines}{cat_title_xml}{cat_number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>' f'<c:majorTickMark val="{cat_tick_marks}"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{cat_tick_label_pos}"/>' f'<c:tickLblPos val="{cat_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr}" f"{axis_sp_pr}{axis_tx_pr(category)}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/>{category_tail}' f'<c:crossAx val="{val_ax_id}"/><c:crosses val="{category_crosses}"/>{category_tail}'
f"</c:{category_tag}>" f"</c:{category_tag}>"
"<c:valAx>" "<c:valAx>"
f'<c:axId val="{val_ax_id}"/>{_axis_scaling_xml(value)}' f'<c:axId val="{val_ax_id}"/>{_axis_scaling_xml(value, reverse=value_reversed)}'
f'<c:delete val="{val_delete}"/><c:axPos val="{val_pos}"/>' f'<c:delete val="{val_delete}"/><c:axPos val="{val_pos}"/>'
f"{val_gridlines}{val_title_xml}{val_number_format}" f"{val_gridlines}{val_title_xml}{val_number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>' f'<c:majorTickMark val="{val_tick_marks}"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{val_tick_label_pos}"/>' f'<c:tickLblPos val="{val_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr}" f"{axis_sp_pr}{axis_tx_pr(value)}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="{value_crosses}"/>' f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="{value_crosses}"/>'
f"{cross_between}{major_unit}" f"{cross_between}{major_unit}"
"</c:valAx>" "</c:valAx>"
@@ -1028,6 +1125,7 @@ def _combo_plot_xml(
data_label_font_face=chart_style.get("font_face"), data_label_font_face=chart_style.get("font_face"),
language=chart_style.get("primary_language"), language=chart_style.get("primary_language"),
line_style=plot.get("line_style", "line"), line_style=plot.get("line_style", "line"),
marker_size=plot.get("marker_size"),
category_column=int(plot.get("category_column", 1)), category_column=int(plot.get("category_column", 1)),
color_start_index=start_index, color_start_index=start_index,
series_indices=plot.get("series_indices"), series_indices=plot.get("series_indices"),
@@ -1058,6 +1156,8 @@ def _combo_plot_xml(
val_ax_id=val_ax_id, val_ax_id=val_ax_id,
vary_colors=any(item.get("point_colors") for item in plot["series"]), vary_colors=any(item.get("point_colors") for item in plot["series"]),
data_labels_xml=data_labels_xml, data_labels_xml=data_labels_xml,
gap_width=plot.get("gap_width"),
overlap=plot.get("overlap"),
)) ))
elif chart_type in {"area", "line"}: elif chart_type in {"area", "line"}:
parts.append(_line_area_chart_group_xml( parts.append(_line_area_chart_group_xml(
@@ -1209,6 +1309,7 @@ def _chart_plot_xml(
chart_type=chart_type, chart_type=chart_type,
grouping=series_grouping, grouping=series_grouping,
line_style=chart_data.get("line_style", "line"), line_style=chart_data.get("line_style", "line"),
marker_size=chart_data.get("marker_size"),
radar_marker_style=chart_data.get("radar_marker_style"), radar_marker_style=chart_data.get("radar_marker_style"),
radar_style=chart_data.get("radar_style", "marker"), radar_style=chart_data.get("radar_style", "marker"),
colors=colors, colors=colors,
@@ -1248,6 +1349,9 @@ def _chart_plot_xml(
if chart_type in {"bar", "column"}: if chart_type in {"bar", "column"}:
bar_dir = "bar" if chart_type == "bar" else "col" bar_dir = "bar" if chart_type == "bar" else "col"
grouping = series_grouping grouping = series_grouping
gap_width = chart_data.get("gap_width")
if gap_width is None:
gap_width = _DEFAULT_GAP_WIDTH
axes_xml = _axis_xml( axes_xml = _axis_xml(
cat_ax_id, cat_ax_id,
val_ax_id, val_ax_id,
@@ -1260,11 +1364,7 @@ def _chart_plot_xml(
show_value_axis_labels=chart_data.get("show_value_axis_labels", True), show_value_axis_labels=chart_data.get("show_value_axis_labels", True),
axes=chart_data.get("axes") or {}, axes=chart_data.get("axes") or {},
) )
overlap_xml = ( overlap_xml = _bar_overlap_xml(grouping, chart_data.get("overlap"))
'<c:overlap val="100"/>'
if grouping in {"stacked", "percentStacked"}
else ""
)
vary_colors_xml = ( vary_colors_xml = (
'<c:varyColors val="1"/>' '<c:varyColors val="1"/>'
if any(item.get("point_colors") for item in series) if any(item.get("point_colors") for item in series)
@@ -1276,7 +1376,7 @@ def _chart_plot_xml(
f"{vary_colors_xml}" f"{vary_colors_xml}"
f"{ser_xml}" f"{ser_xml}"
f"{data_labels_xml}" f"{data_labels_xml}"
'<c:gapWidth val="150"/>' f'<c:gapWidth val="{gap_width}"/>'
f"{overlap_xml}" f"{overlap_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
"</c:barChart>" "</c:barChart>"
@@ -1524,6 +1624,34 @@ def _stock_axis_xml(
) )
def _chart_data_with_fallback_geometry(
elem: ET.Element,
chart_data: dict[str, Any],
) -> dict[str, Any]:
"""SVG-first only: fill bar spacing and tick crossing from the fallback drawing."""
if chart_data.get("gap_width") is None:
inferred_gap_width = _inferred_bar_gap_width(elem, chart_data)
if inferred_gap_width is not None:
chart_data = {**chart_data, "gap_width": inferred_gap_width}
if chart_data.get("overlap") is None:
inferred_overlap = _inferred_bar_overlap(elem, chart_data)
if inferred_overlap is not None:
chart_data = {**chart_data, "overlap": inferred_overlap}
axes = chart_data.get("axes") or {}
value_axis = axes.get("value") or {}
if value_axis.get("cross_between") is None:
inferred_cross_between = _inferred_cross_between(elem, chart_data)
if inferred_cross_between is not None:
chart_data = {
**chart_data,
"axes": {
**axes,
"value": {**value_axis, "cross_between": inferred_cross_between},
},
}
return chart_data
def _chart_xml( def _chart_xml(
elem: ET.Element, elem: ET.Element,
payload: dict[str, Any], payload: dict[str, Any],
@@ -1544,6 +1672,8 @@ def _chart_xml(
axis_titles = _axis_titles(payload) axis_titles = _axis_titles(payload)
chart_style = _classic_chart_style(payload, elem, inherited_styles) chart_style = _classic_chart_style(payload, elem, inherited_styles)
chart_style["primary_language"] = primary_language chart_style["primary_language"] = primary_language
if not native_json_is_authoritative(elem):
chart_data = _chart_data_with_fallback_geometry(elem, chart_data)
plot_xml = _chart_plot_xml( plot_xml = _chart_plot_xml(
chart_data, chart_data,
colors, colors,
@@ -86,6 +86,7 @@ class _FallbackShapeRecord:
fill: str | None fill: str | None
stroke: str | None stroke: str | None
labels: tuple[str, ...] labels: tuple[str, ...]
fill_opacity: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -158,6 +159,22 @@ def _style_attr(elem: ET.Element, name: str) -> str | None:
return None return None
def _own_fill_opacity(elem: ET.Element) -> float | None:
"""The element's own ``opacity`` × ``fill-opacity``, or None when neither is set."""
result: float | None = None
for name in ("opacity", "fill-opacity"):
raw = _style_attr(elem, name)
if raw is None:
continue
try:
value = float(str(raw).strip().rstrip("%")) / (100.0 if str(raw).strip().endswith("%") else 1.0)
except ValueError:
continue
value = max(0.0, min(1.0, value))
result = value if result is None else result * value
return result
def _paint_visible(elem: ET.Element, paint: str) -> bool: def _paint_visible(elem: ET.Element, paint: str) -> bool:
for name in ("opacity", f"{paint}-opacity"): for name in ("opacity", f"{paint}-opacity"):
raw = _style_attr(elem, name) raw = _style_attr(elem, name)
@@ -579,6 +596,7 @@ def _fallback_shape_records(
fill=fill_color, fill=fill_color,
stroke=stroke_color, stroke=stroke_color,
labels=labels, labels=labels,
fill_opacity=_own_fill_opacity(elem) if fill_color else None,
) )
) )
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@@ -16,6 +17,7 @@ from ..drawingml.utils import (
_xml_escape, _xml_escape,
detect_text_lang, detect_text_lang,
font_px_to_hpt, font_px_to_hpt,
px_to_emu,
text_has_rtl_characters, text_has_rtl_characters,
text_uses_rtl, text_uses_rtl,
) )
@@ -947,12 +949,20 @@ def _native_table_header_warnings(
for record, row_idx, col_idx in header_text_cells for record, row_idx, col_idx in header_text_cells
): ):
missing.append("columns[].bold") missing.append("columns[].bold")
if any( # Header cells export centred unless the payload sets align, so a missing
_cell_payload(table_rows[row_idx][col_idx]).get("align") # align only matches a fallback whose header text is centred.
!= _fallback_table_alignment(record.anchor) unmatched_anchors = sorted({
_fallback_table_alignment(record.anchor)
for record, row_idx, col_idx in header_text_cells for record, row_idx, col_idx in header_text_cells
): if (_cell_payload(table_rows[row_idx][col_idx]).get("align") or "ctr")
missing.append("columns[].align") != _fallback_table_alignment(record.anchor)
})
if unmatched_anchors:
missing.append(
"columns[].align "
+ "/".join(f'"{value}"' for value in unmatched_anchors)
+ " (header cells export centred unless align is set)"
)
if not missing: if not missing:
return [] return []
return [ return [
@@ -1028,8 +1038,14 @@ def _native_table_fill_warnings(
human_start = start + 1 human_start = start + 1
human_end = end human_end = end
span = str(human_start) if human_start == human_end else f"{human_start}-{human_end}" span = str(human_start) if human_start == human_end else f"{human_start}-{human_end}"
hint = (
f" (one fallback rect spanning several {axis}s reads as one whole-{axis} fill;"
" draw one rect per column or row when their payload fills differ)"
if human_start != human_end
else ""
)
warnings.append( warnings.append(
f"Native PPTX table whole {axis} {span} fill #{color} is not projected to cell fill" f"Native PPTX table whole {axis} {span} fill #{color} is not projected to cell fill{hint}"
) )
return warnings return warnings
@@ -1076,7 +1092,15 @@ def _native_table_first_column_warnings(
table_rows: list[list[Any]], table_rows: list[list[Any]],
header_rows: int, header_rows: int,
text_cells: list[tuple[Any, int, int]], text_cells: list[tuple[Any, int, int]],
*,
style_body_text: str | None = None,
) -> list[str]: ) -> list[str]:
"""Report first-column emphasis the payload would not carry.
A first column drawn in the table's own body text colour is carried by
``style.body_text`` even when the other columns are coloured per cell, so
only a colour no layer resolves to is reported.
"""
body_records = [item for item in text_cells if item[1] >= header_rows] body_records = [item for item in text_cells if item[1] >= header_rows]
first_column = [item for item in body_records if item[2] == 0] first_column = [item for item in body_records if item[2] == 0]
if not first_column: if not first_column:
@@ -1102,9 +1126,10 @@ def _native_table_first_column_warnings(
if ( if (
record.fill is not None record.fill is not None
and record.fill != body_color and record.fill != body_color
and _table_cell_parity_text_style( and (
table_rows[row_idx][col_idx] _table_cell_parity_text_style(table_rows[row_idx][col_idx])[1]
)[1] != record.fill or style_body_text
) != record.fill
) )
}) })
if missing_colors: if missing_colors:
@@ -1299,11 +1324,13 @@ def _native_table_warnings(
shape_records, shape_records,
) )
) )
table_style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
warnings.extend( warnings.extend(
_native_table_first_column_warnings( _native_table_first_column_warnings(
table_rows, table_rows,
header_rows, header_rows,
text_cells, text_cells,
style_body_text=_hex_or_none(table_style.get("body_text")),
) )
) )
warnings.extend( warnings.extend(
@@ -1412,7 +1439,44 @@ def _table_padding_value(
return _powerpoint_emu(pixels, f"table {side} padding") return _powerpoint_emu(pixels, f"table {side} padding")
def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str: _TEXT_LINE_FACTOR = 1.4 # PowerPoint line box per em, covering CJK faces' tall metrics
def _table_text_height_emu(paragraphs_xml: str) -> int:
"""Estimate the vertical space the cell's paragraphs need in PowerPoint."""
sizes = [int(value) for value in re.findall(r' sz="(\d+)"', paragraphs_xml)]
paragraph_count = max(1, paragraphs_xml.count("<a:p>"))
if not sizes:
return 0
line_px = max(sizes) / 100 / 0.75 * _TEXT_LINE_FACTOR
return px_to_emu(line_px * paragraph_count)
def _table_padding_attrs(
cell_data: dict[str, Any],
style: dict[str, Any],
*,
row_height: int | None = None,
paragraphs_xml: str | None = None,
) -> str:
values = {
side: _table_padding_value(cell_data, style, side)
for side in ("left", "right", "top", "bottom")
}
# An authored row height is geometry; vertical padding is style. When the
# text line plus both margins would exceed the row, PowerPoint grows the
# row instead, so shrink the margins to what the row can hold.
if row_height is not None and paragraphs_xml is not None:
top = values["top"] or 0
bottom = values["bottom"] or 0
room = row_height - _table_text_height_emu(paragraphs_xml)
if top + bottom > room > 0:
scale = room / (top + bottom)
values["top"] = int(top * scale) if values["top"] is not None else None
values["bottom"] = int(bottom * scale) if values["bottom"] is not None else None
elif top + bottom > room:
values["top"] = 0 if values["top"] is not None else None
values["bottom"] = 0 if values["bottom"] is not None else None
attrs = [] attrs = []
for attr, side in ( for attr, side in (
("marL", "left"), ("marL", "left"),
@@ -1420,7 +1484,7 @@ def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> st
("marT", "top"), ("marT", "top"),
("marB", "bottom"), ("marB", "bottom"),
): ):
value = _table_padding_value(cell_data, style, side) value = values[side]
if value is not None: if value is not None:
attrs.append(f'{attr}="{value}"') attrs.append(f'{attr}="{value}"')
return (" " + " ".join(attrs)) if attrs else "" return (" " + " ".join(attrs)) if attrs else ""
@@ -1893,7 +1957,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"' anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
tc_pr_attrs = ( tc_pr_attrs = (
f'{anchor_attr}' f'{anchor_attr}'
f'{_table_padding_attrs(cell_data, style)}' f'{_table_padding_attrs(cell_data, style, row_height=row_heights[row_idx], paragraphs_xml=paragraphs_xml)}'
f'{_table_cell_extra_attrs(cell_data)}' f'{_table_cell_extra_attrs(cell_data)}'
) )
border_xml = _table_border_xml( border_xml = _table_border_xml(
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import sys
from pathlib import Path from pathlib import Path
from slide_roster import discover_slide_svgs from slide_roster import discover_slide_svgs
@@ -75,10 +76,11 @@ def find_notes_files(
Dict mapping SVG filename stem to notes content. Dict mapping SVG filename stem to notes content.
""" """
notes_dir = project_path / 'notes' notes_dir = project_path / 'notes'
notes: dict[str, str] = {} index_notes: dict[str, tuple[Path, str]] = {}
filename_notes: dict[str, tuple[Path, str]] = {}
if not notes_dir.exists(): if not notes_dir.exists():
return notes return {}
svg_stems_mapping: dict[str, int] = {} svg_stems_mapping: dict[str, int] = {}
svg_index_mapping: dict[int, str] = {} svg_index_mapping: dict[int, str] = {}
@@ -111,10 +113,19 @@ def find_notes_files(
continue continue
if mapped_stem: if mapped_stem:
notes[mapped_stem] = content index_notes[mapped_stem] = (notes_file, content)
# Filename-based matching overrides index-based matching.
if filename_match: if filename_match:
notes[stem] = content filename_notes[stem] = (notes_file, content)
return notes # Apply filename priority after collecting both modes, regardless of glob order.
for stem, (notes_file, _content) in filename_notes.items():
indexed = index_notes.get(stem)
if indexed is not None and indexed[0] != notes_file:
print(
f" Note: {stem}.svg uses filename-matched notes {notes_file} "
f"instead of index-matched {indexed[0]}",
file=sys.stderr,
)
index_notes.update(filename_notes)
return {stem: content for stem, (_path, content) in index_notes.items()}
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Tests for apply_template.py against the bundled template library."""
from __future__ import annotations
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from apply_template import ( # noqa: E402
ApplyTemplateError,
_receipt,
apply_templates,
)
TEMPLATES = SCRIPTS_DIR.parent / "templates"
STYLE_ROOT = TEMPLATES / "styles" / "incident-postmortem"
BRAND_ROOT = TEMPLATES / "brands" / "中国电信"
LAYOUT_ROOT = TEMPLATES / "layouts" / "presentation_core"
DECK_ROOT = TEMPLATES / "decks" / "中国电信"
class ApplyTemplateTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp.name)
self.project = self.tmp / "proj"
(self.project / "templates").mkdir(parents=True)
def tearDown(self) -> None:
self._tmp.cleanup()
def _apply(self, *roots: Path | str, **kwargs):
kwargs.setdefault("validate", False)
return apply_templates(self.project, [str(root) for root in roots], **kwargs)
def test_library_style_installs_one_qualified_spec_with_provenance(self) -> None:
plan = self._apply(STYLE_ROOT, validate=True)
installed = self.project / "templates" / "design_spec.style.incident-postmortem.md"
self.assertTrue(installed.is_file())
text = installed.read_text(encoding="utf-8")
h1 = text.index("# Incident Postmortem")
self.assertIn(
"\n\n> **Installed from**: `skills/ppt-master/templates/styles/"
"incident-postmortem/` (library)\n",
text[h1:],
)
self.assertEqual(text.count("**Installed from**"), 1)
self.assertEqual(sorted(p.name for p in self.project.iterdir()), ["templates"])
receipt = _receipt(plan)
self.assertIn("sources=library", receipt)
self.assertIn("kinds=style", receipt)
self.assertIn("direction:style", receipt)
self.assertIn("structure:free-design", receipt)
self.assertIn("active_roster=none", receipt)
self.assertIn("install=copied", receipt)
def test_rerun_is_idempotent(self) -> None:
self._apply(STYLE_ROOT)
plan = self._apply(STYLE_ROOT)
self.assertEqual([m.status for m in plan.mappings], ["identical"])
def test_brand_assets_travel_with_the_spec(self) -> None:
plan = self._apply(BRAND_ROOT, STYLE_ROOT)
self.assertTrue((self.project / "images" / "logo.png").is_file())
self.assertTrue(
(self.project / "templates" / "design_spec.brand.中国电信.md").is_file()
)
self.assertIn("identity:brand", _receipt(plan))
def test_layout_owns_structure_over_deck(self) -> None:
plan = self._apply(LAYOUT_ROOT, DECK_ROOT)
svgs = sorted(p.name for p in (self.project / "templates").glob("*.svg"))
layout_svgs = sorted(p.name for p in (LAYOUT_ROOT / "templates").glob("*.svg"))
self.assertEqual(svgs, layout_svgs)
self.assertFalse((self.project / "templates" / "01_cover.svg").exists())
self.assertTrue((self.project / "images" / "logo.png").is_file())
receipt = _receipt(plan)
self.assertIn("structure:layout", receipt)
self.assertIn("application_context:deck", receipt)
self.assertIn("active_roster=layout:", receipt)
self.assertIn(
"installed_specs=design_spec.layout.presentation_core.md,"
"design_spec.deck.中国电信.md",
receipt,
)
def test_deck_alone_installs_its_roster(self) -> None:
self._apply(DECK_ROOT)
self.assertTrue((self.project / "templates" / "01_cover.svg").is_file())
spec = self.project / "templates" / "design_spec.deck.中国电信.md"
self.assertIn("(library)", spec.read_text(encoding="utf-8"))
def test_duplicate_kind_is_rejected_before_writing(self) -> None:
with self.assertRaises(ApplyTemplateError) as ctx:
self._apply(STYLE_ROOT, TEMPLATES / "styles" / "consulting-decision")
self.assertIn("one root per kind", str(ctx.exception))
self.assertEqual(list((self.project / "templates").iterdir()), [])
def test_destination_collision_is_rejected_before_writing(self) -> None:
images = self.project / "images"
images.mkdir()
(images / "logo.png").write_bytes(b"not the same logo")
with self.assertRaises(ApplyTemplateError) as ctx:
self._apply(BRAND_ROOT)
self.assertIn("destination collision", str(ctx.exception))
self.assertFalse(
(self.project / "templates" / "design_spec.brand.中国电信.md").exists()
)
def test_explicit_root_keeps_its_path_as_provenance(self) -> None:
explicit = self.tmp / "my_style"
shutil.copytree(STYLE_ROOT, explicit)
plan = self._apply(explicit)
spec = self.project / "templates" / "design_spec.style.incident-postmortem.md"
self.assertIn(
f"> **Installed from**: `{explicit.resolve()}/` (explicit)",
spec.read_text(encoding="utf-8"),
)
self.assertIn("sources=explicit", _receipt(plan))
def test_inner_templates_directory_is_refused(self) -> None:
with self.assertRaises(ApplyTemplateError) as ctx:
self._apply(STYLE_ROOT / "templates")
self.assertIn("workspace root", str(ctx.exception))
def test_dry_run_writes_nothing(self) -> None:
plan = self._apply(BRAND_ROOT, dry_run=True)
self.assertEqual(len(plan.mappings), 2)
self.assertEqual(list((self.project / "templates").iterdir()), [])
self.assertFalse((self.project / "images").exists())
def test_in_place_deck_roster_is_replaced_by_selected_layout(self) -> None:
# The project already consumed a Deck in place; a Layout then owns structure.
self._apply(DECK_ROOT)
plan = self._apply(self.project, LAYOUT_ROOT)
self.assertFalse((self.project / "templates" / "01_cover.svg").exists())
self.assertTrue((self.project / "templates" / "01_title_slide.svg").is_file())
self.assertTrue(
(self.project / "templates" / "design_spec.deck.中国电信.md").is_file()
)
self.assertIn("structure:layout", _receipt(plan))
self.assertIn("install=copied", _receipt(plan))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Regression tests for the Tencent Cloud TokenHub image backend."""
import base64
import io
import os
import sys
import tempfile
import unittest
from contextlib import ExitStack, redirect_stdout
from pathlib import Path
from unittest.mock import Mock, patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import image_gen # noqa: E402
from image_backends import backend_tencent # noqa: E402
class TencentBackendTests(unittest.TestCase):
def setUp(self) -> None:
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(patch.dict(os.environ, {"TENCENT_API_KEY": "test-key"}, clear=True))
self.stdout = stack.enter_context(redirect_stdout(io.StringIO()))
self.response = Mock(status_code=200)
self.response.json.return_value = {
"data": [{"url": "https://example.invalid/result.png"}],
"request_id": "test-request",
}
self.post = stack.enter_context(patch.object(
backend_tencent.requests, "post", return_value=self.response,
))
stack.enter_context(patch.object(
backend_tencent.requests, "get", side_effect=AssertionError("Unexpected network request"),
))
self.download = stack.enter_context(patch.object(
backend_tencent, "download_image", side_effect=lambda url, path: path,
))
self.save = stack.enter_context(patch.object(
backend_tencent, "save_image_bytes", side_effect=lambda data, path, **kwargs: path,
))
self.sleep = stack.enter_context(patch.object(backend_tencent.time, "sleep"))
def test_hy_endpoint_payload_and_download(self) -> None:
result = backend_tencent.generate("A mountain", aspect_ratio="16:9", filename="hero.png")
self.post.assert_called_once_with(
"https://tokenhub.tencentmaas.com/v1/wand/hunyuan-image/v3-generation",
headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"},
json={
"model": "hy-image-v3", "prompt": "A mountain",
"size": "1360x768", "revise": True,
},
timeout=300,
)
self.download.assert_called_once_with("https://example.invalid/result.png", "hero.png")
self.save.assert_not_called()
self.assertEqual(result, "hero.png")
self.assertIn("Resolution: 1360x768", self.stdout.getvalue())
def test_seedream_endpoints_and_payloads(self) -> None:
for model, resolution in (
("seedream-image-v5.0-pro", "1024x1024"),
("seedream-image-v5.0-lite", "2048x2048"),
):
with self.subTest(model=model):
self.post.reset_mock()
backend_tencent.generate("A mountain", model=model)
self.post.assert_called_once_with(
"https://tokenhub.tencentmaas.com/v1/wand/si-image/generation",
headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"},
json={
"model": model, "prompt": "A mountain", "size": resolution,
"watermark": False, "response_format": "url", "output_format": "png",
},
timeout=300,
)
def test_hy_area_limit_and_orientation(self) -> None:
for ratio in ("16:9", "1:1", "9:16"):
with self.subTest(ratio=ratio):
resolution = backend_tencent._resolve_size(ratio, "1K")
width, height = map(int, resolution.split("x"))
expected_width, expected_height = map(int, ratio.split(":"))
self.assertLessEqual(width * height, 1024 * 1024)
self.assertGreater(width * height, 0.97 * 1024 * 1024)
self.assertAlmostEqual(width / height, expected_width / expected_height, delta=0.02)
for dimension in (width, height):
self.assertGreaterEqual(dimension, 512)
self.assertLessEqual(dimension, 2048)
self.assertEqual(dimension % 16, 0)
def test_hy_dimension_boundaries(self) -> None:
for ratio, logical_size, resolution in (
("1:1", "512px", "512x512"),
("4:1", "1K", "2048x512"),
("1:4", "1K", "512x2048"),
):
with self.subTest(ratio=ratio, size=logical_size):
self.assertEqual(backend_tencent._resolve_size(ratio, logical_size), resolution)
def test_hy_rejects_impossible_sizes_before_request(self) -> None:
for ratio, logical_size in (
("1:1", "2K"), ("1:1", "4K"), ("16:9", "512px"),
("1:8", "1K"), ("8:1", "1K"),
):
with self.subTest(ratio=ratio, size=logical_size):
with self.assertRaisesRegex(ValueError, "512-2048.*multiples of 16.*1024x1024"):
backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
self.post.assert_not_called()
def test_seedream_lite_upgrades_small_sizes_once(self) -> None:
for logical_size in ("1K", "512px"):
with self.subTest(size=logical_size):
self.stdout.seek(0)
self.stdout.truncate(0)
backend_tencent.generate("test", model="seedream-image-v5.0-lite", image_size=logical_size)
self.assertEqual(self.post.call_args.kwargs["json"]["size"], "2048x2048")
self.assertEqual(self.stdout.getvalue().count(f"upgrading {logical_size} to 2K"), 1)
self.assertIn("Resolution: 2048x2048", self.stdout.getvalue())
def test_seedream_area_scales_across_cli_ratios(self) -> None:
for model, logical_size, side in (
("seedream-image-v5.0-pro", "1K", 1024),
("seedream-image-v5.0-pro", "2K", 2048),
("seedream-image-v5.0-lite", "2K", 2048),
("seedream-image-v5.0-lite", "4K", 4096),
):
for ratio in image_gen.ALL_ASPECT_RATIOS:
with self.subTest(model=model, size=logical_size, ratio=ratio):
size = backend_tencent._resolve_size(ratio, logical_size, model)
width, height = map(int, size.split("x"))
ratio_width, ratio_height = map(int, ratio.split(":"))
self.assertAlmostEqual(width * height / side ** 2, 1.0, delta=0.004)
self.assertAlmostEqual(width / height, ratio_width / ratio_height, delta=0.025)
def test_seedream_pro_rejects_512px_and_4k(self) -> None:
for logical_size in ("512px", "4K"):
with self.subTest(size=logical_size):
with self.assertRaisesRegex(ValueError, "Use 1K or 2K"):
backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
self.post.assert_not_called()
def test_invalid_ratio_and_size_fail_before_request(self) -> None:
for ratio in ("invalid", "0:1", "1:0", "-1:2", "1:2:3"):
with self.subTest(ratio=ratio):
with self.assertRaisesRegex(ValueError, "Unsupported aspect ratio"):
backend_tencent.generate("test", aspect_ratio=ratio)
with self.assertRaisesRegex(ValueError, "Unsupported image size"):
backend_tencent.generate("test", image_size="3K")
self.post.assert_not_called()
def test_unknown_model_lists_supported_models(self) -> None:
with self.assertRaises(ValueError) as caught:
backend_tencent.generate("test", model="other-model")
for model in backend_tencent.SUPPORTED_MODELS:
self.assertIn(model, str(caught.exception))
self.post.assert_not_called()
def test_vidu_reports_unsupported_async_api(self) -> None:
with self.assertRaisesRegex(ValueError, "Vidu.*asynchronous submit/query API.*not supported"):
backend_tencent.generate("test", model="vidu-image-q2")
self.post.assert_not_called()
def test_missing_key_names_both_options(self) -> None:
os.environ.pop("TENCENT_API_KEY")
with self.assertRaisesRegex(ValueError, "No API key found.*TENCENT_API_KEY or TOKENHUB_API_KEY"):
backend_tencent.generate("test")
self.post.assert_not_called()
self.sleep.assert_not_called()
def test_key_fallback_and_primary_precedence(self) -> None:
os.environ["TOKENHUB_API_KEY"] = "fallback-key"
backend_tencent.generate("test")
self.assertEqual(self.post.call_args.kwargs["headers"]["Authorization"], "Bearer test-key")
os.environ["TENCENT_API_KEY"] = ""
backend_tencent.generate("test")
self.assertEqual(self.post.call_args.kwargs["headers"]["Authorization"], "Bearer fallback-key")
def test_missing_image_includes_response_body(self) -> None:
for model in backend_tencent.SUPPORTED_MODELS:
for items in (None, [], [{}], [{"url": ""}]):
with self.subTest(model=model, items=items):
self.response.json.return_value = {"data": items, "request_id": "missing-image"}
with self.assertRaisesRegex(RuntimeError, "missing image URL.*missing-image"):
backend_tencent.generate("test", model=model, max_retries=0)
self.download.assert_not_called()
self.save.assert_not_called()
def test_full_endpoint_is_used_without_appending_path(self) -> None:
for model, endpoint in backend_tencent.MODEL_ENDPOINTS.items():
with self.subTest(model=model):
url = "https://tokenhub-intl.tencentmaas.com" + endpoint
os.environ["TENCENT_BASE_URL"] = url + "/"
backend_tencent.generate("test", model=model)
self.assertEqual(self.post.call_args.args[0], url)
def test_international_base_selects_model_endpoint(self) -> None:
os.environ["TENCENT_BASE_URL"] = "https://tokenhub-intl.tencentmaas.com/"
backend_tencent.generate("test")
self.assertEqual(
self.post.call_args.args[0],
"https://tokenhub-intl.tencentmaas.com/v1/wand/hunyuan-image/v3-generation",
)
def test_provider_options_apply_only_to_their_model(self) -> None:
os.environ.update({
"TENCENT_REVISE": "false", "TENCENT_WATERMARK": "true", "TENCENT_OUTPUT_FORMAT": "jpeg",
})
self.assertEqual(backend_tencent.generate("test"), "test.png")
self.assertIs(self.post.call_args.kwargs["json"]["revise"], False)
self.assertNotIn("watermark", self.post.call_args.kwargs["json"])
self.assertNotIn("output_format", self.post.call_args.kwargs["json"])
self.assertEqual(backend_tencent.generate("test", model="seedream-image-v5.0-pro"), "test.jpeg")
self.assertIs(self.post.call_args.kwargs["json"]["watermark"], True)
self.assertEqual(self.post.call_args.kwargs["json"]["output_format"], "jpeg")
self.assertNotIn("revise", self.post.call_args.kwargs["json"])
def test_seedream_base64_response_uses_shared_save(self) -> None:
image_bytes = b"\x89PNG\r\n\x1a\nminimal-mocked-image"
self.response.json.return_value = {"data": [{"b64_json": base64.b64encode(image_bytes).decode("ascii")}]}
result = backend_tencent.generate("test", model="seedream-image-v5.0-pro")
self.save.assert_called_once_with(image_bytes, "test.png", content_type="image/png")
self.download.assert_not_called()
self.assertEqual(result, "test.png")
def test_permanent_http_errors_do_not_retry(self) -> None:
for status, code in ((400, "FieldInvalid"), (401, "Unauthorized"), (422, "CreationPolicyViolation")):
with self.subTest(status=status):
self.post.reset_mock()
self.response.status_code = status
self.response.text = '{"error": {"code": "' + code + '"}}'
with self.assertRaisesRegex(RuntimeError, rf"\({status}\).*{code}"):
backend_tencent.generate("test")
self.post.assert_called_once()
self.sleep.assert_not_called()
def test_rate_limit_uses_shared_exponential_backoff(self) -> None:
limited = Mock(status_code=429, text='{"error": {"code": "ConcurrencyLimit"}}')
self.post.side_effect = [limited, limited, self.response]
backend_tencent.generate("test")
self.assertEqual(self.post.call_count, 3)
self.assertEqual([call.args[0] for call in self.sleep.call_args_list], [10, 20])
def test_server_error_is_retried(self) -> None:
failure = Mock(status_code=500, text='{"error": {"code": "InternalError"}}')
self.post.side_effect = [failure, self.response]
backend_tencent.generate("test")
self.sleep.assert_called_once_with(5)
self.assertEqual(self.post.call_count, 2)
def test_prompt_limits_fail_before_request(self) -> None:
for model, limit in (("hy-image-v3", 8192), ("seedream-image-v5.0-pro", 600)):
with self.subTest(model=model):
with self.assertRaisesRegex(ValueError, f"1-{limit} characters"):
backend_tencent.generate("x" * (limit + 1), model=model)
self.post.assert_not_called()
def test_invalid_provider_options_do_not_retry(self) -> None:
for key, model in (
("TENCENT_REVISE", "hy-image-v3"),
("TENCENT_WATERMARK", "seedream-image-v5.0-pro"),
("TENCENT_OUTPUT_FORMAT", "seedream-image-v5.0-pro"),
):
with self.subTest(key=key), patch.dict(os.environ, {key: "invalid"}):
with self.assertRaisesRegex(ValueError, key):
backend_tencent.generate("test", model=model)
self.post.assert_not_called()
self.sleep.assert_not_called()
def test_registry_aliases_and_env_file_reach_backend(self) -> None:
env_text = (
"IMAGE_BACKEND=tokenhub\nTOKENHUB_API_KEY=dotenv-key\n"
"TENCENT_MODEL=seedream-image-v5.0-pro\n"
"TENCENT_BASE_URL=https://tokenhub-intl.tencentmaas.com\n"
"TENCENT_WATERMARK=true\nTENCENT_OUTPUT_FORMAT=jpeg\nTENCENT_REVISE=false\n"
)
os.environ.pop("TENCENT_API_KEY")
with tempfile.TemporaryDirectory() as temp_dir:
env_path = Path(temp_dir) / ".env"
env_path.write_text(env_text, encoding="utf-8")
with patch("config.resolve_env_path", return_value=env_path):
image_gen._load_image_env_file()
self.assertEqual(os.environ["TENCENT_REVISE"], "false")
for alias in ("tencent", "tokenhub", "hunyuan", "tencentmaas"):
self.assertEqual(image_gen.BACKEND_ALIASES[alias], "tencent")
backend, name = image_gen._resolve_backend()
self.assertEqual(name, "tencent")
self.assertIs(backend, backend_tencent)
self.assertEqual(backend.generate("test"), "test.jpeg")
self.assertEqual(
self.post.call_args.args[0], "https://tokenhub-intl.tencentmaas.com/v1/wand/si-image/generation",
)
self.assertEqual(self.post.call_args.kwargs["headers"]["Authorization"], "Bearer dotenv-key")
self.assertIs(self.post.call_args.kwargs["json"]["watermark"], True)
if __name__ == "__main__":
unittest.main()
@@ -182,3 +182,19 @@ class CandidatePoolContinuationTests(unittest.TestCase):
self.assertEqual(getattr(image, "n_frames", 1), 1) self.assertEqual(getattr(image, "n_frames", 1), 1)
self.assertGreater(image.getpixel((3, 3))[0], 150) self.assertGreater(image.getpixel((3, 3))[0], 150)
self.assertFalse(image_search._normalize_multi_frame_jpeg(path)) self.assertFalse(image_search._normalize_multi_frame_jpeg(path))
def test_multi_frame_camera_jpeg_bakes_exif_orientation(self) -> None:
from PIL import Image
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "camera.jpg"
first = Image.new("RGB", (64, 48), "red")
exif = first.getexif()
exif[274] = 6
first.save(path, format="MPO", save_all=True, exif=exif,
append_images=[Image.new("RGB", (64, 48), "blue")])
self.assertTrue(image_search._normalize_multi_frame_jpeg(path))
with Image.open(path) as image:
self.assertEqual(image.format, "JPEG")
self.assertEqual(image.size, (48, 64))
self.assertNotIn(274, image.getexif())
@@ -0,0 +1,470 @@
"""Native chart export follows the fallback drawing: order, markers, fills, spacing."""
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.native_objects.chart_data import _chart_data # noqa: E402
from svg_to_pptx.native_objects.chart_style import ( # noqa: E402
_inferred_bar_gap_width,
_inferred_bar_overlap,
_inferred_cross_between,
_native_chart_chrome_warnings,
)
from svg_to_pptx.native_objects.chart_xml import _chart_xml # noqa: E402
from pptx_to_svg.chart_to_svg import ( # noqa: E402
_UnsupportedChart,
_cache_point_values,
_category_payload,
_numeric_cache_values,
)
from pptx_to_svg.emu_units import NS, Xfrm # noqa: E402
from pptx_to_svg.ooxml_loader import PartRef # noqa: E402
from pptx_to_svg.normalized_chart_svg import render_normalized_chart_svg # noqa: E402
from pptx_to_svg.shape_walker import GRAPHIC, ShapeNode # noqa: E402
from pptx_to_svg.slide_to_svg import AssemblyContext, _convert_graphic_fallback # noqa: E402
from svg_authoring_view import _render_projection # noqa: E402
SVG_NS = "http://www.w3.org/2000/svg"
def _marker(fallback: str) -> ET.Element:
svg = ET.fromstring(
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">'
'<g id="chart" data-pptx-replace-with="chart">'
'<metadata type="application/json">{}</metadata>'
f"{fallback}</g></svg>"
)
return svg.find(f"{{{SVG_NS}}}g")
def _render(payload: dict, fallback: str = "") -> str:
elem = _marker(fallback)
return _chart_xml(
elem,
payload,
chart_rels_id="rId1",
chart_data=_chart_data(payload),
chart_bounds=(0, 0, 12192000, 6858000),
).decode("utf-8")
def _bar_payload(**extra: object) -> dict:
payload = {
"type": "bar",
"categories": ["A", "B", "C"],
"series": [{"name": "S", "values": [1, 2, 3]}],
}
payload.update(extra)
return payload
class ChartImportTests(unittest.TestCase):
def test_sparse_cache_preserves_missing_indices(self) -> None:
cache = ET.fromstring(
'<c:numCache xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">'
'<c:ptCount val="5"/><c:pt idx="3"><c:v>2.5</c:v></c:pt>'
'<c:pt idx="1"><c:v>1</c:v></c:pt></c:numCache>'
)
self.assertEqual(_cache_point_values(cache), [None, "1", None, "2.5", None])
self.assertEqual(_numeric_cache_values(cache), [None, 1, None, 2.5, None])
def test_exported_line_gaps_survive_category_import(self) -> None:
payload = {
"type": "line", "categories": ["A", "B", "C", "D"],
"series": [{"name": "S", "values": [1, None, 3, None]}],
}
chart = ET.fromstring(_render(payload)).find(
".//{http://schemas.openxmlformats.org/drawingml/2006/chart}lineChart"
)
imported = _category_payload(chart, "line", Xfrm(0, 0, 400, 200))
self.assertEqual(imported["categories"], payload["categories"])
self.assertEqual(imported["series"], payload["series"])
def test_invalid_cache_indices_and_counts_remain_rejected(self) -> None:
for contents in (
'<c:ptCount val="1"/><c:pt idx="1"><c:v>2</c:v></c:pt>',
'<c:ptCount val="2"/><c:pt idx="0"/><c:pt idx="0"/>',
'<c:ptCount val="2"/><c:pt idx="-1"/>',
'<c:ptCount val="-1"/>',
'<c:ptCount val="bad"/>',
):
with self.subTest(contents=contents):
cache = ET.fromstring(
'<c:numCache xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">'
f'{contents}</c:numCache>'
)
with self.assertRaises(_UnsupportedChart):
_cache_point_values(cache)
def test_normalized_chart_preview_keeps_gaps(self) -> None:
payload = {
"type": "line", "x": 0, "y": 0, "width": 400, "height": 200,
"categories": ["A", "B", "C", "D", "E", "F"],
"series": [{"name": "S", "values": [None, 1, 2, None, 4, None]}],
"line_style": "lineMarker",
}
rendered = render_normalized_chart_svg(payload, [])
self.assertIsNotNone(rendered)
root = ET.fromstring(f'<svg xmlns="{SVG_NS}">{rendered}</svg>')
lines = root.findall(f'.//{{{SVG_NS}}}polyline')
self.assertEqual([len(line.get("points").split()) for line in lines], [2, 1])
self.assertEqual(len(root.findall(f'.//{{{SVG_NS}}}circle')), 3)
for chart_type in ("area", "bar", "column"):
with self.subTest(type=chart_type):
self.assertIsNotNone(render_normalized_chart_svg({**payload, "type": chart_type}, []))
combo = {**payload, "type": "combo", "plots": [{"type": "line", "series": payload["series"]}]}
self.assertIsNotNone(render_normalized_chart_svg(combo, []))
def test_unsupported_graphic_placeholder_can_publish_authoring_projection(self) -> None:
xml = ET.fromstring(
f'<p:graphicFrame xmlns:p="{NS["p"]}" xmlns:a="{NS["a"]}">'
'<a:graphic><a:graphicData '
'uri="http://schemas.openxmlformats.org/drawingml/2006/chart"/>'
'</a:graphic></p:graphicFrame>'
)
node = ShapeNode(GRAPHIC, xml, Xfrm(20, 20, 200, 100), name="unsupported", spid="2")
ctx = AssemblyContext(
palette=None, pkg=None,
slide_part=PartRef("ppt/slides/slide1.xml", ET.Element("slide")),
render_graphic_previews=False,
)
fallback = _convert_graphic_fallback(node, ctx, top_level=True)
self.assertIn("unsupported-chart-reference", fallback)
self.assertIn("[chart]", fallback)
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "slide.svg"
source.write_text(
f'<svg xmlns="{SVG_NS}" viewBox="0 0 400 200">{fallback}</svg>',
encoding="utf-8",
)
_, rendered, _ = _render_projection(source, Path(tmp) / "authoring.svg")
root = ET.fromstring(rendered)
self.assertTrue(root.get("font-family"))
self.assertIn("[chart]", "".join(root.itertext()))
class CategoryOrderTests(unittest.TestCase):
def test_bar_categories_read_top_down_with_value_axis_at_bottom(self) -> None:
xml = _render(_bar_payload())
cat_ax = xml[xml.index("<c:catAx>"):xml.index("</c:catAx>")]
val_ax = xml[xml.index("<c:valAx>"):xml.index("</c:valAx>")]
self.assertIn('<c:orientation val="maxMin"/>', cat_ax)
self.assertIn('<c:crosses val="autoZero"/>', cat_ax)
self.assertIn('<c:axPos val="b"/>', val_ax)
self.assertIn('<c:crosses val="max"/>', val_ax)
def test_bar_reverse_false_restores_bottom_up_order(self) -> None:
xml = _render(_bar_payload(axes={"category": {"reverse": False}}))
cat_ax = xml[xml.index("<c:catAx>"):xml.index("</c:catAx>")]
val_ax = xml[xml.index("<c:valAx>"):xml.index("</c:valAx>")]
self.assertIn('<c:orientation val="minMax"/>', cat_ax)
self.assertIn('<c:crosses val="autoZero"/>', val_ax)
def test_column_value_axis_on_the_right_crosses_at_the_last_category(self) -> None:
payload = _bar_payload(type="column", axes={"value": {"position": "right"}})
xml = _render(payload)
val_ax = xml[xml.index("<c:valAx>"):xml.index("</c:valAx>")]
self.assertIn('<c:axPos val="r"/>', val_ax)
self.assertIn('<c:crosses val="max"/>', val_ax)
def test_combo_secondary_axes_keep_their_crossing(self) -> None:
payload = {
"type": "combo",
"categories": ["A", "B"],
"plots": [
{"type": "column", "series": [{"name": "C", "values": [1, 2]}]},
{"type": "line", "axis": "secondary", "series": [{"name": "L", "values": [3, 4]}]},
],
}
xml = _render(payload)
crossings = [
xml[idx:idx + 60]
for idx in range(len(xml))
if xml.startswith("<c:crosses", idx)
]
self.assertEqual(len(crossings), 4)
self.assertIn("autoZero", crossings[2])
self.assertIn("max", crossings[3])
def test_fallback_order_warning_names_the_expected_reverse(self) -> None:
payload = _bar_payload(axes={"category": {"reverse": False}})
fallback = (
'<text x="10" y="20">A</text><text x="10" y="80">C</text>'
)
warnings = _native_chart_chrome_warnings(_marker(fallback), payload)
self.assertTrue(any("axes.category.reverse: true" in item for item in warnings))
self.assertEqual(_native_chart_chrome_warnings(_marker(fallback), _bar_payload()), [])
class LineMarkerTests(unittest.TestCase):
def test_line_marker_style_writes_sized_coloured_markers(self) -> None:
payload = {
"type": "line",
"line_style": "lineMarker",
"marker_size": 10,
"categories": ["A", "B"],
"series": [{"name": "S", "values": [1, 2]}],
"style": {"colors": ["#111111"]},
}
xml = _render(payload)
self.assertIn('<c:marker><c:symbol val="circle"/><c:size val="8"/>', xml)
self.assertIn('<a:srgbClr val="111111"/></a:solidFill><a:ln>', xml)
def test_point_colors_mark_single_points_and_leave_null_bare(self) -> None:
payload = {
"type": "line",
"categories": ["A", "B", "C"],
"series": [{
"name": "S",
"values": [1, 2, 3],
"point_colors": [None, None, "#C8102E"],
}],
}
xml = _render(payload)
self.assertEqual(xml.count("<c:dPt>"), 3)
self.assertIn(
'<c:dPt><c:idx val="2"/><c:marker><c:symbol val="circle"/>'
'<c:spPr><a:solidFill><a:srgbClr val="C8102E"/>',
xml,
)
self.assertIn('<c:dPt><c:idx val="0"/><c:marker><c:symbol val="none"/>', xml)
def test_bar_point_colors_still_require_colours(self) -> None:
payload = _bar_payload(series=[{"name": "S", "values": [1, 2, 3], "point_colors": [None, "#111111", "#222222"]}])
with self.assertRaises(RuntimeError):
_chart_data(payload)
def test_marker_warning_fires_only_without_markers(self) -> None:
fallback = '<circle cx="10" cy="10" r="5" fill="#C8102E"/>'
payload = {
"type": "line",
"categories": ["A", "B"],
"series": [{"name": "S", "values": [1, 2]}],
"style": {"colors": ["#111111"]},
}
warnings = _native_chart_chrome_warnings(_marker(fallback), payload)
self.assertEqual(len(warnings), 2)
self.assertIn("point marker(s)", warnings[0])
self.assertIn("#C8102E", warnings[1])
payload["series"][0]["point_colors"] = [None, "#C8102E"]
self.assertEqual(_native_chart_chrome_warnings(_marker(fallback), payload), [])
class GapAndFillTests(unittest.TestCase):
def test_null_values_leave_gaps_in_the_cache(self) -> None:
payload = {
"type": "line",
"categories": ["A", "B", "C"],
"series": [{"name": "S", "values": [1, None, 3]}],
}
xml = _render(payload)
num_cache = xml[xml.index("<c:numCache>"):xml.index("</c:numCache>")]
self.assertIn('<c:ptCount val="3"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="2">', num_cache)
self.assertNotIn('<c:pt idx="1">', num_cache)
self.assertIn('<c:dispBlanksAs val="gap"/>', xml)
def test_all_null_series_is_rejected(self) -> None:
payload = {
"type": "line",
"categories": ["A", "B"],
"series": [{"name": "S", "values": [None, None]}],
}
with self.assertRaises(RuntimeError):
_chart_data(payload)
def test_area_has_no_outline_unless_line_width_is_set(self) -> None:
payload = {
"type": "area",
"categories": ["A", "B"],
"series": [{"name": "S", "values": [1, 2], "fill_opacity": 0.14}],
"style": {"colors": ["#111111"]},
}
xml = _render(payload)
self.assertIn('<a:alpha val="14000"/>', xml)
self.assertIn("<a:ln><a:noFill/></a:ln>", xml[xml.index("<c:ser>"):xml.index("</c:ser>")])
payload["series"][0]["line_width"] = 3
xml = _render(payload)
self.assertIn('<a:ln w="28575">', xml[xml.index("<c:ser>"):xml.index("</c:ser>")])
def test_area_warnings_report_translucency_and_overlaid_lines(self) -> None:
fallback = (
'<path d="M0 0 L10 10 L20 0 Z" fill="#111111" fill-opacity="0.14"/>'
'<polyline points="0,0 10,10 20,0" fill="none" stroke="#111111"/>'
)
payload = {
"type": "area",
"categories": ["A", "B", "C"],
"series": [{"name": "S", "values": [1, 2, 1]}],
}
warnings = _native_chart_chrome_warnings(_marker(fallback), payload)
self.assertEqual(len(warnings), 2)
self.assertIn("fill-opacity 0.14", warnings[0])
self.assertIn('type "combo"', warnings[1])
class SpacingTests(unittest.TestCase):
def test_explicit_gap_width_and_overlap_are_written(self) -> None:
xml = _render(_bar_payload(type="column", gap_width=60, overlap=-20))
self.assertIn('<c:gapWidth val="60"/><c:overlap val="-20"/>', xml)
def test_gap_width_is_read_from_fallback_columns(self) -> None:
payload = _bar_payload(
type="column",
plot_area={"x": 0, "y": 0, "width": 300, "height": 100},
)
fallback = (
'<rect x="20" y="50" width="60" height="50" fill="#111111"/>'
'<rect x="120" y="30" width="60" height="70" fill="#111111"/>'
'<rect x="220" y="10" width="60" height="90" fill="#111111"/>'
)
elem = _marker(fallback)
chart_data = _chart_data(payload)
self.assertEqual(_inferred_bar_gap_width(elem, chart_data), 67)
self.assertIn('<c:gapWidth val="67"/>', _render(payload, fallback))
def test_overlap_is_read_from_clustered_bars(self) -> None:
payload = {
"type": "bar",
"categories": ["A", "B"],
"series": [{"name": "S1", "values": [1, 2]}, {"name": "S2", "values": [2, 3]}],
"plot_area": {"x": 0, "y": 0, "width": 200, "height": 100},
}
fallback = (
'<rect x="0" y="10" width="100" height="20" fill="#111111"/>'
'<rect x="0" y="34" width="120" height="20" fill="#222222"/>'
'<rect x="0" y="60" width="80" height="20" fill="#111111"/>'
'<rect x="0" y="84" width="140" height="20" fill="#222222"/>'
)
chart_data = _chart_data(payload)
self.assertEqual(_inferred_bar_overlap(_marker(fallback), chart_data), -20)
def test_cross_between_follows_the_fallback_start(self) -> None:
payload = {
"type": "line",
"categories": ["A", "B", "C", "D"],
"series": [{"name": "S", "values": [1, 2, 3, 4]}],
"plot_area": {"x": 100, "y": 0, "width": 400, "height": 100},
}
chart_data = _chart_data(payload)
centred = '<polyline points="150,10 250,20 350,30 450,40" fill="none" stroke="#111111"/>'
edged = '<polyline points="100,10 233,20 366,30 500,40" fill="none" stroke="#111111"/>'
self.assertEqual(_inferred_cross_between(_marker(centred), chart_data), "between")
self.assertEqual(_inferred_cross_between(_marker(edged), chart_data), "midCat")
self.assertIn('<c:crossBetween val="midCat"/>', _render(payload, edged))
self.assertIn('<c:crossBetween val="between"/>', _render(payload))
def test_axis_text_colour_font_and_size_are_per_axis(self) -> None:
payload = _bar_payload(axes={
"category": {"color": "#1A1A1A"},
"value": {"color": "#6E6E6E", "font_family": "Arial", "font_size": 16},
})
xml = _render(payload)
cat_ax = xml[xml.index("<c:catAx>"):xml.index("</c:catAx>")]
val_ax = xml[xml.index("<c:valAx>"):xml.index("</c:valAx>")]
self.assertIn('<a:srgbClr val="1A1A1A"/>', cat_ax)
self.assertIn('sz="1200"', val_ax)
self.assertIn('<a:srgbClr val="6E6E6E"/>', val_ax)
self.assertIn('<a:latin typeface="Arial"/>', val_ax)
with self.assertRaises(RuntimeError):
_chart_data(_bar_payload(axes={"value": {"color": "grey-ish"}}))
def test_tick_marks_default_to_none_and_accept_out(self) -> None:
self.assertIn('<c:majorTickMark val="none"/>', _render(_bar_payload()))
xml = _render(_bar_payload(axes={"value": {"tick_marks": "out"}}))
val_ax = xml[xml.index("<c:valAx>"):xml.index("</c:valAx>")]
self.assertIn('<c:majorTickMark val="out"/>', val_ax)
class CompanionPlacementTests(unittest.TestCase):
def test_svg_first_companion_sits_on_the_fallback_baseline(self) -> None:
import json
import re
import tempfile
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
payload = {
"x": 100, "y": 100, "width": 600, "height": 300,
"name": "chart-note",
"type": "column",
"categories": ["A", "B"],
"series": [{"name": "S", "values": [1, 2]}],
"plot_area": {"x": 140, "y": 130, "width": 540, "height": 250},
"style": {"colors": ["#111111"], "text_color": "#111111"},
# y copied from the SVG baseline: without fallback anchoring the
# box would start at the baseline and cover the plot top.
"notes": [{"text": "Unit note", "x": 140, "y": 120, "width": 200, "height": 24, "font_size": 18}],
}
svg = (
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">'
'<g id="chart-note" data-pptx-replace-with="chart">'
f'<metadata type="application/json">{json.dumps(payload)}</metadata>'
'<rect x="140" y="130" width="540" height="250" fill="#FFFFFF"/>'
'<text x="140" y="120" font-size="18" fill="#111111">Unit note</text>'
'<text x="410" y="400" font-size="18" fill="#111111">A</text>'
'<text x="410" y="400" font-size="18" fill="#111111">B</text>'
"</g></svg>"
)
from svg_to_pptx.native_objects.fallback_hash import stamp_native_fallback_baseline
root = ET.fromstring(svg)
stamp_native_fallback_baseline(root.find(f"{{{SVG_NS}}}g"), document_root=root)
svg = ET.tostring(root, encoding="unicode")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "01.svg"
path.write_text(svg, encoding="utf-8")
slide_xml, *_ = convert_svg_to_slide_shapes(
path,
resource_root=Path(tmp),
native_objects=True,
)
from svg_to_pptx.drawingml.utils import px_to_emu
note = slide_xml[slide_xml.index("Chart Note"):]
off_y = int(re.search(r'<a:off x="\d+" y="(\d+)"/>', note).group(1))
ext_cy = int(re.search(r'<a:ext cx="\d+" cy="(\d+)"/>', note).group(1))
# bottom edge = baseline 120px + 0.25em; box 1.6em tall, bottom anchored
self.assertEqual(off_y + ext_cy, px_to_emu(120 + 18 * 0.25))
self.assertEqual(ext_cy, px_to_emu(18 * 1.6))
self.assertIn('anchor="b"', note[:note.index("</a:bodyPr>") + 1] if "</a:bodyPr>" in note else note[:900])
class FrameGrowthTests(unittest.TestCase):
def test_frame_grows_below_the_plot_to_hold_value_axis_labels(self) -> None:
from svg_to_pptx.native_objects import _grow_chart_frame_for_axis_labels
payload = {"type": "bar", "categories": ["A"], "series": [{"name": "S", "values": [1]}],
"plot_area": {"x": 50, "y": 120, "width": 400, "height": 250}}
bounds = (0, 100 * 9525, 500 * 9525, 300 * 9525) # frame bottom 400, plot bottom 370
grown = _grow_chart_frame_for_axis_labels(_chart_data(payload), bounds, axis_font_px=20)
self.assertEqual(grown, (0, 100 * 9525, 500 * 9525, 310 * 9525)) # bottom 370 + 40
roomy = (0, 100 * 9525, 500 * 9525, 320 * 9525)
self.assertEqual(_grow_chart_frame_for_axis_labels(_chart_data(payload), roomy, axis_font_px=20), roomy)
def test_frame_grows_above_for_a_top_axis_and_not_for_hidden_labels(self) -> None:
from svg_to_pptx.native_objects import _grow_chart_frame_for_axis_labels
base = {"type": "column", "categories": ["A"], "series": [{"name": "S", "values": [1]}],
"plot_area": {"x": 50, "y": 120, "width": 400, "height": 250}}
bounds = (0, 100 * 9525, 500 * 9525, 300 * 9525) # plot top 120, frame top 100
top = _chart_data({**base, "axes": {"category": {"position": "top"}}})
self.assertEqual(_grow_chart_frame_for_axis_labels(top, bounds, axis_font_px=20),
(0, 80 * 9525, 500 * 9525, 320 * 9525))
hidden = _chart_data({**base, "axes": {"category": {"label_position": "none"}}})
self.assertEqual(_grow_chart_frame_for_axis_labels(hidden, bounds, axis_font_px=20), bounds)
pie = _chart_data({"type": "pie", "categories": ["A"], "series": [{"name": "S", "values": [1]}],
"plot_area": {"x": 50, "y": 120, "width": 400, "height": 250}})
self.assertEqual(_grow_chart_frame_for_axis_labels(pie, bounds, axis_font_px=20), bounds)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Regression tests for native chart companion groups and animation targets."""
from __future__ import annotations
import base64
import hashlib
import json
import random
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_animations import create_sequence_timing_xml # noqa: E402
from svg_to_pptx.drawingml.context import ConvertContext # noqa: E402
from svg_to_pptx.drawingml.converter import convert_element # noqa: E402
from svg_to_pptx.native_objects import stamp_native_fallback_baseline # noqa: E402
from svg_to_pptx.pptx_package.builder import _build_sequence_targets # noqa: E402
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"cx": "http://schemas.microsoft.com/office/drawing/2014/chartex",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
SVG_NS = "http://www.w3.org/2000/svg"
EMU_PER_PX = 9525
def _payload(**extra: object) -> dict:
return {
"type": "column",
"x": 100, "y": 80, "width": 400, "height": 240,
"categories": ["A", "B"],
"series": [{"name": "S", "values": [1, 2]}],
**extra,
}
def _marker(payload: dict, kind: str = "chart", **attrs: str) -> ET.Element:
elem = ET.Element(f"{{{SVG_NS}}}g", {
"id": "sales-chart",
"data-pptx-replace-with": kind,
"data-pptx-native-authority": "json",
**attrs,
})
ET.SubElement(elem, f"{{{SVG_NS}}}metadata", {
"type": "application/json",
}).text = json.dumps(payload)
ET.SubElement(elem, f"{{{SVG_NS}}}rect", {
"x": "100", "y": "80", "width": "400", "height": "240", "fill": "#123456",
})
ET.SubElement(elem, f"{{{SVG_NS}}}text", {
"x": "100", "y": "350", "font-size": "16",
}).text = "Fallback label"
return elem
def _parse(xml: str) -> ET.Element:
namespaces = " ".join(f'xmlns:{key}="{value}"' for key, value in NS.items())
return ET.fromstring(f"<root {namespaces}>{xml}</root>")
def _shape_id(shape: ET.Element) -> int:
return int(shape.find(".//p:cNvPr", NS).get("id"))
def _box(shape: ET.Element, path: str) -> tuple[int, int, int, int]:
xfrm = shape.find(path, NS)
off = xfrm.find("a:off", NS)
ext = xfrm.find("a:ext", NS)
x, y = int(off.get("x")), int(off.get("y"))
return x, y, x + int(ext.get("cx")), y + int(ext.get("cy"))
class NativeChartGroupingTests(unittest.TestCase):
def _convert(self, elem: ET.Element, **options: object) -> tuple:
ctx = ConvertContext(native_objects_enabled=True, trace_events=[], **options)
result = convert_element(elem, ctx)
self.assertIsNotNone(result)
return result, _parse(result.xml), ctx
def _assert_group(self, result, root: ET.Element, ctx: ConvertContext) -> ET.Element:
self.assertEqual(len(root), 1, "replacement must be one slide object")
group = root[0]
self.assertEqual(group.tag, f"{{{NS['p']}}}grpSp")
frame = group.find("p:graphicFrame", NS)
self.assertIsNotNone(frame)
labels = group.findall("p:sp", NS)
self.assertTrue(labels)
child_bounds = [_box(frame, "p:xfrm")]
child_bounds.extend(_box(label, "p:spPr/a:xfrm") for label in labels)
expected = (
min(box[0] for box in child_bounds),
min(box[1] for box in child_bounds),
max(box[2] for box in child_bounds),
max(box[3] for box in child_bounds),
)
self.assertEqual(result.bounds_emu, expected)
self.assertEqual(_box(group, "p:grpSpPr/a:xfrm"), expected)
xfrm = group.find("p:grpSpPr/a:xfrm", NS)
self.assertEqual(xfrm.find("a:off", NS).attrib, xfrm.find("a:chOff", NS).attrib)
self.assertEqual(xfrm.find("a:ext", NS).attrib, xfrm.find("a:chExt", NS).attrib)
ids = [int(node.get("id")) for node in group.findall(".//p:cNvPr", NS)]
self.assertEqual(len(ids), len(set(ids)))
self.assertEqual(ctx.anim_targets, [(_shape_id(group), "sales-chart")])
self.assertNotEqual(_shape_id(group), _shape_id(frame))
self.assertEqual(ctx.trace_events[-1]["output_geometry"], "native-object")
self.assertEqual(ctx.trace_events[-1]["fidelity"], "native-normalized")
self.assertEqual(ctx.trace_events[-1]["shape_id"], _shape_id(group))
return group
def test_classic_companions_keep_text_style_and_union_bounds(self) -> None:
payload = _payload(
name='Sales & "margin"',
title={"text": "Bounded title", "x": 50, "y": 30, "width": 200, "height": 30},
caption="Caption",
source="Source",
notes=[{
"text": "Far right note", "x": 520, "y": 100, "width": 100, "height": 24,
"font_size": 18, "color": "#C8102E", "bold": True,
}],
footnote="Footnote",
)
result, root, ctx = self._convert(_marker(payload))
group = self._assert_group(result, root, ctx)
self.assertEqual(
[node.text for node in group.findall("p:sp/p:txBody//a:t", NS)],
["Bounded title", "Caption", "Source", "Far right note", "Footnote"],
)
self.assertEqual(result.bounds_emu, tuple(v * EMU_PER_PX for v in (50, 30, 620, 376)))
self.assertEqual(group.find("p:nvGrpSpPr/p:cNvPr", NS).get("name"), 'Sales & "margin"')
note = group.findall("p:sp", NS)[3]
run = note.find("p:txBody/a:p/a:r/a:rPr", NS)
self.assertEqual(run.get("sz"), "1350")
self.assertEqual(run.get("b"), "1")
self.assertEqual(run.find("a:solidFill/a:srgbClr", NS).get("val"), "C8102E")
self.assertTrue(all(label.find("p:nvSpPr/p:cNvSpPr", NS).get("txBox") == "1"
for label in group.findall("p:sp", NS)))
def test_sidecar_timing_targets_whole_group_once(self) -> None:
result, root, ctx = self._convert(_marker(_payload(notes=["First", "Second"])))
group = self._assert_group(result, root, ctx)
targets, _ = _build_sequence_targets(
ctx.anim_targets, "slide", {"groups": {"sales-chart": {
"effect": "entrance_wipe", "effect_options": {"direction": "right"},
"duration": 0.9, "order": 3,
}}}, None, {}, 0.5, 0.2, 0, random.Random(0),
)
self.assertEqual(len(targets), 1)
timing = _parse(create_sequence_timing_xml(targets))
self.assertEqual(
{int(node.get("spid")) for node in timing.findall(".//p:spTgt", NS)},
{_shape_id(group)},
)
self.assertIn('filter="wipe(right)"', ET.tostring(timing, encoding="unicode"))
self.assertFalse(timing.findall(".//p:bldP", NS))
def test_no_emitted_companions_keep_single_frame(self) -> None:
for extra in ({}, {"notes": [], "caption": " ", "source": ""},
{"notes": [{"text": " "}], "title": "Internal chart title"}):
with self.subTest(extra=extra):
result, root, ctx = self._convert(_marker(_payload(**extra)))
self.assertEqual(len(root), 1)
self.assertEqual(root[0].tag, f"{{{NS['p']}}}graphicFrame")
self.assertEqual(ctx.anim_targets, [(_shape_id(root[0]), "sales-chart")])
self.assertEqual(result.bounds_emu, tuple(v * EMU_PER_PX for v in (100, 80, 500, 320)))
def test_chartex_companions_keep_chart_relationship_and_package(self) -> None:
for companions in ({}, {"title": "Tree", "subtitle": "Subtitle", "notes": ["Note"]}):
with self.subTest(companions=companions):
result, root, ctx = self._convert(_marker(_payload(
type="treemap", levels=[["A", "B"]], values=[1, 2], **companions,
)))
if companions:
group = self._assert_group(result, root, ctx)
self.assertEqual([n.text for n in group.findall(".//a:t", NS)],
["Tree", "Subtitle", "Note"])
else:
self.assertEqual(root[0].tag, f"{{{NS['p']}}}graphicFrame")
chart = root.find(".//cx:chart", NS)
self.assertIsNotNone(chart)
self.assertEqual(chart.get(f"{{{NS['r']}}}id"), ctx.rel_entries[0]["id"])
self.assertTrue(ctx.rel_entries[0]["type"].endswith("/chartEx"))
self.assertIn("ppt/charts/chartEx101.xml", ctx.package_files)
self.assertIn("ppt/charts/style101.xml", ctx.package_files)
self.assertIn("ppt/charts/colors101.xml", ctx.package_files)
self.assertIn("ppt/embeddings/Microsoft_Excel_Sheet101.xlsx", ctx.package_files)
def test_source_package_keeps_claimed_frame_id_and_exact_parts(self) -> None:
_, original, source_ctx = self._convert(_marker(_payload()))
frame = original[0]
frame.find("p:nvGraphicFramePr/p:cNvPr", NS).set("id", "77")
frame_bytes = ET.tostring(frame, encoding="utf-8")
def blob(data: bytes) -> dict:
return {"encoding": "base64", "payload": base64.b64encode(data).decode("ascii"),
"sha256": hashlib.sha256(data).hexdigest()}
source = {
"chart_part": "ppt/charts/chart101.xml",
"frame": blob(frame_bytes),
"parts": [{
"name": name, **blob(data),
"content_type": source_ctx.content_type_overrides.get(name),
} for name, data in source_ctx.package_files.items()],
}
elem = _marker(_payload(source_package=source, notes=["Must not be synthesized"]),
**{"data-pptx-shape-id": "77"})
_, root, ctx = self._convert(
elem, reserved_shape_ids=frozenset({77}),
source_shape_id_map={("slide", "77"): 77}, rel_id_counter=8,
)
self.assertEqual(len(root), 1)
self.assertEqual(root[0].tag, f"{{{NS['p']}}}graphicFrame")
self.assertEqual(_shape_id(root[0]), 77)
self.assertEqual(ctx.anim_targets, [(77, "sales-chart")])
self.assertEqual(ctx.package_files, source_ctx.package_files)
self.assertEqual(ctx.content_type_overrides, source_ctx.content_type_overrides)
frame.find(".//c:chart", NS).set(f"{{{NS['r']}}}id", "rId8")
self.assertEqual(ET.tostring(root[0]), ET.tostring(frame))
def test_table_stays_one_native_frame(self) -> None:
_, root, ctx = self._convert(_marker({
"schema": "ppt-master.semantic-table.v2",
"x": 100, "y": 80, "width": 400, "height": 240,
"rows": [["A", "B"], ["1", "2"]],
}, kind="table"))
self.assertEqual(len(root), 1)
self.assertEqual(root[0].tag, f"{{{NS['p']}}}graphicFrame")
self.assertIsNotNone(root.find(".//a:tbl", NS))
self.assertEqual(ctx.anim_targets, [(_shape_id(root[0]), "sales-chart")])
def test_svg_fallback_still_groups_and_reports_visual_geometry(self) -> None:
elem = _marker(_payload(notes=["Fallback label"]))
elem.attrib.pop("data-pptx-native-authority")
stamp_native_fallback_baseline(elem)
ctx = ConvertContext(trace_events=[])
result = convert_element(elem, ctx)
root = _parse(result.xml)
self.assertEqual(len(root), 1)
self.assertEqual(root[0].tag, f"{{{NS['p']}}}grpSp")
self.assertIsNone(root.find(".//p:graphicFrame", NS))
self.assertEqual(ctx.anim_targets, [(_shape_id(root[0]), "sales-chart")])
self.assertEqual(ctx.trace_events[-1]["output_geometry"], "group")
self.assertEqual(ctx.trace_events[-1]["fidelity"], "visual-only")
def test_nested_transformed_group_includes_outlying_companion(self) -> None:
for explicit in (True, False):
with self.subTest(explicit=explicit):
outer = ET.Element(f"{{{SVG_NS}}}g", {
"id": "section", "transform": "translate(10 20) scale(2)",
})
payload = _payload(notes=[{
"text": "Outside", "x": 1100, "y": 700, "width": 100, "height": 30,
}])
if not explicit:
for key in ("x", "y", "width", "height"):
payload.pop(key)
elem = _marker(payload)
if not explicit:
elem.attrib.pop("data-pptx-native-authority")
elem.remove(elem.find(f"{{{SVG_NS}}}text"))
stamp_native_fallback_baseline(elem)
outer.append(elem)
result, root, ctx = self._convert(outer)
group = root[0]
self.assertEqual(ctx.anim_targets, [(_shape_id(group), "section")])
self.assertEqual(len(root.findall(".//p:grpSp", NS)), 2)
frame = group.find(".//p:graphicFrame", NS)
# Explicit payload boxes are already in slide coordinates;
# inferred fallback bounds consume the ancestor transform.
frame_box = (100, 80, 500, 320) if explicit else (210, 180, 1010, 660)
self.assertEqual(_box(frame, "p:xfrm"), tuple(v * EMU_PER_PX for v in frame_box))
expected = tuple(v * EMU_PER_PX for v in (*frame_box[:2], 1200, 730))
self.assertEqual(result.bounds_emu, expected)
self.assertEqual(_box(group, "p:grpSpPr/a:xfrm"), expected)
if __name__ == "__main__":
unittest.main()
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -261,6 +262,40 @@ class NativeProjectionCheckerTests(unittest.TestCase):
for text in expected: for text in expected:
self.assertIn(text, joined) self.assertIn(text, joined)
def test_formatted_chart_labels_warning_names_data_label_number_format(self) -> None:
for value, number_format, label in (
(1000, "#,##0", "1,000"),
(0.5, "0%", "50%"),
):
with self.subTest(label=label):
marker = ET.fromstring(f"""
<g data-pptx-replace-with="chart">
<metadata type="application/json">
{{
"x": 0, "y": 0, "width": 400, "height": 240,
"type": "column", "categories": ["A"],
"series": [{{"name": "Series", "values": [{value}]}}],
"number_format": "{number_format}",
"data_labels": {{"show_value": true}}
}}
</metadata>
<text x="100" y="100">{label}</text>
</g>
""")
warnings = native_object_projection_warnings(marker)
joined = "\n".join(warnings)
self.assertIn(f"visible text not projected: {label!r}", joined)
self.assertIn("data_labels.number_format", joined)
metadata = marker.find("metadata")
payload = json.loads(metadata.text)
payload["data_labels"]["number_format"] = payload.pop("number_format")
metadata.text = json.dumps(payload)
self.assertNotIn(
"visible text not projected",
"\n".join(native_object_projection_warnings(marker)),
)
def test_explicit_chart_text_axis_and_grid_colors_use_role_inference(self) -> None: def test_explicit_chart_text_axis_and_grid_colors_use_role_inference(self) -> None:
marker = ET.fromstring(""" marker = ET.fromstring("""
<g data-pptx-replace-with="chart" data-pptx-bounds="0 0 400 240"> <g data-pptx-replace-with="chart" data-pptx-bounds="0 0 400 240">
@@ -448,6 +483,25 @@ class NativeTableFillAndTextDefaultsTests(unittest.TestCase):
# defaults.cell wins over defaults.run for a shared field # defaults.cell wins over defaults.run for a shared field
self.assertEqual(body_a["font_size"], 20) self.assertEqual(body_a["font_size"], 20)
def test_paragraph_cells_inherit_run_defaults_like_plain_text(self) -> None:
expanded = expand_semantic_table_payload({
"schema": "ppt-master.semantic-table.v2",
"x": 0, "y": 0, "width": 200, "height": 80,
"column_widths": [100, 100],
"row_heights": [40, 40],
"defaults": {"run": {"color": "#1F2933", "bold": False}},
"columns": ["A", "B"],
"rows": [[
{"paragraphs": ["line one", "line two"]},
{"paragraphs": [{"runs": [{"text": "run"}]}], "color": "#B4342C"},
]],
})
strings, runs = expanded["rows"][0]
self.assertEqual(strings["color"], "#1F2933")
self.assertFalse(strings["bold"])
self.assertEqual(runs["color"], "#B4342C")
self.assertEqual(runs["paragraphs"][0]["runs"][0]["color"], "#1F2933")
def test_body_text_color_parity_warns_when_no_layer_carries_it(self) -> None: def test_body_text_color_parity_warns_when_no_layer_carries_it(self) -> None:
def marker(rows_json: str, style_json: str = "") -> ET.Element: def marker(rows_json: str, style_json: str = "") -> ET.Element:
return ET.fromstring(f""" return ET.fromstring(f"""
@@ -496,6 +550,123 @@ class NativeTableFillAndTextDefaultsTests(unittest.TestCase):
)) ))
self.assertNotIn("body text color", via_run_defaults) self.assertNotIn("body text color", via_run_defaults)
paragraph_strings = "\n".join(native_object_projection_warnings(
marker(
'[{"paragraphs": ["Altar"]}, {"paragraphs": ["Cosmos"]}]',
'"defaults": {"run": {"color": "#DAD5CA"}},',
)
))
self.assertNotIn("body text color", paragraph_strings)
def test_first_column_in_body_text_colour_is_carried_by_style(self) -> None:
def marker(style_json: str) -> ET.Element:
return ET.fromstring(f"""
<g data-pptx-replace-with="table" data-pptx-bounds="0 0 300 80">
<metadata type="application/json">
{{
"schema": "ppt-master.semantic-table.v2",
"x": 0, "y": 0, "width": 300, "height": 80,
"header_rows": 1,
"column_widths": [100, 100, 100],
"row_heights": [40, 40],
{style_json}
"columns": [
{{"text": "Task", "color": "#F5F7F2", "align": "l"}},
{{"text": "Old", "color": "#F5F7F2", "align": "l"}},
{{"text": "uv", "color": "#F5F7F2", "align": "l"}}
],
"rows": [[
"Create env",
{{"text": "venv", "color": "#9EACB8"}},
{{"text": "uv venv", "color": "#D7FF64"}}
]]
}}
</metadata>
<line x1="0" y1="0" x2="300" y2="0" stroke="#3A4651"/>
<line x1="0" y1="40" x2="300" y2="40" stroke="#3A4651"/>
<line x1="0" y1="80" x2="300" y2="80" stroke="#3A4651"/>
<g fill="#F5F7F2">
<text x="10" y="25">Task</text>
<text x="110" y="25">Old</text>
<text x="210" y="25">uv</text>
</g>
<text x="10" y="65" fill="#E7ECEF">Create env</text>
<text x="110" y="65" fill="#9EACB8">venv</text>
<text x="210" y="65" fill="#D7FF64">uv venv</text>
</g>
""")
carried = "\n".join(native_object_projection_warnings(
marker('"style": {"body_text": "#E7ECEF"},')
))
self.assertNotIn("first-column", carried)
uncarried = "\n".join(native_object_projection_warnings(marker("")))
self.assertIn("first-column text style not projected", uncarried)
self.assertIn("#E7ECEF", uncarried)
def test_header_align_parity_reads_the_centred_export_default(self) -> None:
def marker(header_anchor: str, columns_json: str) -> ET.Element:
x = {"start": 10, "middle": 50, "end": 90}[header_anchor]
return ET.fromstring(f"""
<g data-pptx-replace-with="table" data-pptx-bounds="0 0 200 80">
<metadata type="application/json">
{{
"schema": "ppt-master.semantic-table.v2",
"x": 0, "y": 0, "width": 200, "height": 80,
"header_rows": 1,
"column_widths": [100, 100],
"row_heights": [40, 40],
"defaults": {{"run": {{"color": "#DAD5CA"}}}},
"columns": [{columns_json}],
"rows": [["Altar", "Cosmos"]]
}}
</metadata>
<line x1="0" y1="0" x2="200" y2="0" stroke="#999999"/>
<line x1="0" y1="40" x2="200" y2="40" stroke="#999999"/>
<line x1="0" y1="80" x2="200" y2="80" stroke="#999999"/>
<g fill="#DAD5CA" text-anchor="{header_anchor}">
<text x="{x}" y="25">Level</text>
<text x="{x + 100}" y="25">Meaning</text>
</g>
<g fill="#DAD5CA">
<text x="10" y="65">Altar</text>
<text x="110" y="65">Cosmos</text>
</g>
</g>
""")
left_without_align = "\n".join(native_object_projection_warnings(
marker("start", '"Level", "Meaning"')
))
self.assertIn('columns[].align "l"', left_without_align)
self.assertIn("export centred unless align is set", left_without_align)
left_with_align = "\n".join(native_object_projection_warnings(
marker("start", '{"text": "Level", "align": "l"}, {"text": "Meaning", "align": "l"}')
))
self.assertNotIn("columns[].align", left_with_align)
centred_without_align = "\n".join(native_object_projection_warnings(
marker("middle", '"Level", "Meaning"')
))
self.assertNotIn("columns[].align", centred_without_align)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TableRowHeightTests(unittest.TestCase):
def test_vertical_padding_shrinks_to_the_authored_row_height(self) -> None:
from svg_to_pptx.native_objects.table import _table_padding_attrs
cell = {"padding": 16}
paragraphs = '<a:p><a:r><a:rPr lang="en-US" sz="1500"/><a:t>x</a:t></a:r></a:p>'
loose = _table_padding_attrs(cell, {}, row_height=914400, paragraphs_xml=paragraphs)
self.assertIn('marT="152400" marB="152400"', loose)
tight = _table_padding_attrs(cell, {}, row_height=457200, paragraphs_xml=paragraphs)
top = int(tight.split('marT="')[1].split('"')[0])
self.assertLess(top, 152400)
self.assertGreater(top, 0)
self.assertIn('marL="152400" marR="152400"', tight)
@@ -0,0 +1,655 @@
#!/usr/bin/env python3
"""Regression tests for native export guards and SVG fidelity."""
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
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_shapes.formula import OOXML_COORDINATE_MAX # noqa: E402
from animation_config import main as animation_config_main # noqa: E402
from pptx_gradients import native_gradient_metadata, preserved_native_gradient_xml # noqa: E402
from svg_finalize.flatten_tspan import flatten_text_with_tspans # noqa: E402
from svg_quality.checker import SVGQualityChecker # noqa: E402
from svg_to_pptx.animation_config import ( # noqa: E402
build_group_listing,
build_scaffold,
scan_svg_targets,
validate_animation_config,
)
from svg_to_pptx.drawingml.converter import ( # noqa: E402
SvgNativeConversionError,
convert_svg_to_slide_shapes,
)
from svg_to_pptx.drawingml.styles import build_gradient_fill # noqa: E402
from svg_to_pptx.pptx_package.discovery import find_notes_files # noqa: E402
NS = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
}
PNG_DATA = (
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'
)
class NativeExportGuardTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.svg_path = self.root / '01_fixture.svg'
def _svg(self, body: str, attributes: str = '') -> None:
self.svg_path.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
f'data-pptx-page-role="content" {attributes}>{body}</svg>',
encoding='utf-8',
)
def _export(self) -> ET.Element:
xml, *_rest = convert_svg_to_slide_shapes(self.svg_path, resource_root=self.root)
return ET.fromstring(xml)
def _check(self) -> dict:
return SVGQualityChecker(quick_generate=True).check_file(str(self.svg_path))
@staticmethod
def _shape_colors(slide: ET.Element) -> list[str]:
return [
color.get('val')
for color in slide.findall('.//p:sp/p:spPr/a:solidFill/a:srgbClr', NS)
]
def test_huge_coordinate_export_fails_with_page_and_element(self) -> None:
for transform in ('', 'transform="matrix(1 0 0 1 0 0)"'):
with self.subTest(transform=transform):
self._svg(
f'<rect id="huge" x="100000000000000000000000000000000000" y="100" width="200" height="100" '
f'fill="#FF0000" {transform}/>'
)
with self.assertRaises(SvgNativeConversionError) as caught:
self._export()
self.assertIn('01_fixture.svg', str(caught.exception))
self.assertIn('huge', str(caught.exception))
self.assertIn('OOXML coordinate range', str(caught.exception))
def test_checker_rejects_oversized_offsets_and_extents(self) -> None:
for attribute in ('x', 'y', 'width', 'height'):
with self.subTest(attribute=attribute):
values = dict(x='100', y='100', width='200', height='100')
values[attribute] = '100000000000000000000000000000000000'
geometry = ' '.join(f'{key}="{value}"' for key, value in values.items())
self._svg(f'<rect id="huge" {geometry} fill="#FF0000"/>')
errors = self._check()['errors']
self.assertTrue(any('huge' in error and 'OOXML' in error for error in errors), errors)
with self.assertRaises(SvgNativeConversionError):
self._export()
def test_coordinates_inside_ooxml_range_still_export(self) -> None:
for x in (-10, OOXML_COORDINATE_MAX // 9525):
with self.subTest(x=x):
self._svg(f'<rect id="valid" x="{x}" y="100" width="20" height="10" fill="#123456"/>')
self.assertFalse(self._check()['errors'])
self.assertEqual(self._shape_colors(self._export()), ['123456'])
def test_hidden_rect_is_omitted_for_attributes_and_style(self) -> None:
for hiding in ('visibility="hidden"', 'style="visibility:hidden"',
'display="none"', 'style="display:none"'):
with self.subTest(hiding=hiding):
self._svg(
f'<rect id="hidden" x="100" y="100" width="200" height="100" fill="#FF0000" {hiding}/>'
'<rect id="shown" x="400" y="100" width="200" height="100" fill="#00FF00"/>'
)
self.assertEqual(self._shape_colors(self._export()), ['00FF00'])
report = self._check()
self.assertFalse(report['errors'])
self.assertTrue(any('hidden' in warning and 'not be exported' in warning
for warning in report['warnings']), report['warnings'])
def _clipped_image(self, shapes: str, clip_attributes: str = '') -> None:
self._svg(
f'<defs><clipPath id="clip" {clip_attributes}>{shapes}</clipPath></defs>'
'<image id="picture" x="100" y="100" width="200" height="100" '
f'preserveAspectRatio="xMidYMid slice" href="{PNG_DATA}" clip-path="url(#clip)"/>'
)
def test_hidden_clip_shapes_omit_picture_and_report_empty_clip(self) -> None:
for hiding in ('visibility="hidden"', 'style="visibility:hidden"',
'display="none"', 'style="display:none"'):
for inherited in (False, True):
with self.subTest(hiding=hiding, inherited=inherited):
self._clipped_image(
'<rect x="100" y="100" width="200" height="100" '
f'{"" if inherited else hiding}/>',
hiding if inherited else '',
)
self.assertFalse(self._export().findall('.//p:pic', NS))
report = self._check()
self.assertFalse(report['errors'], report['errors'])
self.assertTrue(any(
'Hidden element <image id="picture"> will not be exported' in warning
and 'empty clip: url(#clip)' in warning and 'advisory only' in warning
for warning in report['warnings']
), report['warnings'])
def test_visible_clip_keeps_picture_crop_and_ignores_hidden_siblings(self) -> None:
visible = '<rect x="100" y="100" width="200" height="100" rx="10"/>'
self._clipped_image(visible)
picture = self._export().find('.//p:pic', NS)
crop = picture.find('.//a:srcRect', NS).attrib
self.assertEqual(crop, {'l': '0', 't': '25000', 'r': '0', 'b': '25000'})
for shapes, clip_attributes in (
('<circle cx="200" cy="150" r="50" visibility="hidden"/>' + visible, ''),
(visible + '<rect display="none" width="1" height="1"/>', ''),
(visible.replace('rx="10"', 'rx="10" visibility="visible"'), 'visibility="hidden"'),
):
with self.subTest(shapes=shapes, clip_attributes=clip_attributes):
self._clipped_image(shapes, clip_attributes)
self.assertFalse(self._check()['errors'])
actual = self._export().find('.//p:pic', NS)
self.assertEqual(actual.find('.//a:srcRect', NS).attrib, crop)
self.assertEqual(actual.find('.//a:prstGeom', NS).get('prst'), 'roundRect')
def test_empty_clip_is_omitted_from_checker_coordinate_measurement(self) -> None:
self._clipped_image('<rect visibility="hidden"/>')
tree = ET.parse(self.svg_path)
tree.getroot().find('{http://www.w3.org/2000/svg}image').set('x', '100000000000000000000')
tree.write(self.svg_path, encoding='utf-8')
self.assertFalse(self._check()['errors'])
self.assertFalse(self._export().findall('.//p:pic', NS))
def test_clip_still_rejects_multiple_visible_shapes(self) -> None:
self._clipped_image('<rect x="100" y="100" width="200" height="100"/>' * 2)
self.assertTrue(any('exactly one' in error for error in self._check()['errors']))
with self.assertRaisesRegex(SvgNativeConversionError, 'exactly one'):
self._export()
self._clipped_image('<rect visibility="hidden"/><circle display="none"/>')
self.assertFalse(self._export().findall('.//p:pic', NS))
def test_nested_crop_uses_visible_clip_shape_and_omits_empty_clip(self) -> None:
for hidden in (False, True):
with self.subTest(hidden=hidden):
self._svg(
'<defs><clipPath id="clip"><circle display="none"/>'
'<rect x="0.25" y="0.25" width="0.5" height="0.5" rx="0.1" '
f'visibility="{"hidden" if hidden else "visible"}"/></clipPath></defs>'
'<svg x="100" y="100" width="200" height="200" viewBox="0.25 0.25 0.5 0.5" '
'data-pptx-crop="1" overflow="hidden" preserveAspectRatio="none">'
'<image x="0" y="0" width="1" height="1" preserveAspectRatio="none" '
f'href="{PNG_DATA}" clip-path="url(#clip)"/></svg>'
)
self.assertFalse(self._check()['errors'])
pictures = self._export().findall('.//p:pic', NS)
self.assertEqual(len(pictures), 0 if hidden else 1)
if not hidden:
self.assertEqual(pictures[0].find('.//a:prstGeom', NS).get('prst'), 'roundRect')
self.assertEqual(pictures[0].find('.//a:srcRect', NS).attrib,
{'l': '25000', 't': '25000', 'r': '25000', 'b': '25000'})
def test_zero_width_strokes_export_no_fill_including_style_and_inheritance(self) -> None:
for width in ('0', '0.5'):
for placement in ('attribute', 'style', 'inherited_attribute', 'inherited_style'):
with self.subTest(width=width, placement=placement):
attribute = (f'style="stroke-width:{width}"' if placement.endswith('style')
else f'stroke-width="{width}"')
direct = '' if placement.startswith('inherited') else attribute
parent = attribute if placement.startswith('inherited') else ''
self._svg(
f'<g stroke="#123456" data-pptx-bounds="80 80 340 100" {parent}>'
f'<line x1="100" y1="100" x2="200" y2="150" {direct}/>'
f'<rect x="300" y="100" width="100" height="50" {direct}/></g>'
)
self.assertFalse(self._check()['errors'])
strokes = self._export().findall('.//p:sp/p:spPr/a:ln', NS)
self.assertEqual(len(strokes), 2)
for stroke in strokes:
if width == '0':
self.assertEqual(stroke.attrib, {})
self.assertIsNotNone(stroke.find('a:noFill', NS))
self.assertIsNone(stroke.find('a:solidFill', NS))
else:
self.assertEqual(stroke.get('w'), '4762')
self.assertIsNotNone(stroke.find('a:solidFill', NS))
def _motion_project(self, hiding: str, child_attributes: str = '') -> None:
self._svg(
f'<g id="shape" {hiding} data-pptx-bounds="80 70 300 150">'
f'<rect x="100" y="100" width="100" height="50" {child_attributes}/></g>'
'<g id="shown"><rect x="400" y="100" width="100" height="50"/></g>'
)
output = self.root / 'svg_output'
output.mkdir(exist_ok=True)
for name in ('01', '02'):
(output / f'{name}.svg').write_text(self.svg_path.read_text(encoding='utf-8'), encoding='utf-8')
def test_hidden_animation_groups_are_excluded_from_scan_listing_and_scaffold(self) -> None:
for hiding in ('display="none"', 'style="display:none"',
'visibility="hidden"', 'style="visibility:hidden"'):
with self.subTest(hiding=hiding):
self._motion_project(hiding)
targets, anonymous = scan_svg_targets(self.root / 'svg_output/01.svg')
self.assertEqual([target.group_id for target in targets], ['shown'])
self.assertFalse(anonymous)
self.assertEqual(build_group_listing(self.root)[0], [
f'{name}: shown [hidden, not exported: shape]' for name in ('01', '02')
])
for slide in build_scaffold(self.root)['slides'].values():
self.assertEqual(slide['groups'], {'shown': {}})
def test_hidden_animation_references_fail_validation_before_morph_export(self) -> None:
self._motion_project('display="none"')
config = {
'version': 1,
'defaults': {'animation': {'effect': 'none'}},
'slides': {
'01': {'groups': {
'shape': {'effect': 'fade', 'order': 1},
'shown': {'effect': 'fade', 'trigger': 'on-click', 'trigger_shape': 'shape'},
}},
'02': {'transition': {'effect': 'morph', 'duration': 1},
'morph': {'from': '01', 'pairs': {'key': {'from': 'shape', 'to': 'shape'}}}},
},
}
messages = validate_animation_config(self.root, config)
self.assertEqual(len(messages), 4, messages)
self.assertTrue(all('hidden, not exported' in message for message in messages), messages)
self.assertTrue(any('trigger_shape' in message for message in messages))
self.assertTrue(any('groups["shape"]' in message for message in messages))
for name in ('01', '02'):
self.assertTrue(any(f'Morph endpoint {name}/shape' in message for message in messages))
(self.root / 'animations.json').write_text(json.dumps(config), encoding='utf-8')
with redirect_stderr(io.StringIO()) as stderr, redirect_stdout(io.StringIO()):
self.assertEqual(animation_config_main(['validate', str(self.root)]), 1)
self.assertIn('hidden, not exported', stderr.getvalue())
def test_visibility_override_remains_an_exported_animation_target(self) -> None:
self._motion_project('visibility="hidden"', 'visibility="visible"')
targets, _anonymous = scan_svg_targets(self.root / 'svg_output/01.svg')
self.assertEqual([target.group_id for target in targets], ['shape', 'shown'])
self.assertIn('shape', build_scaffold(self.root)['slides']['01']['groups'])
self.assertEqual(len(self._export().findall('.//p:sp', NS)), 2)
config = {'slides': {'01': {'groups': {'shape': {'effect': 'fade'}}}}}
self.assertEqual(validate_animation_config(self.root, config), [])
def test_display_none_group_cannot_be_overridden_by_descendants(self) -> None:
for hiding in ('display="none"', 'style="display:none"'):
with self.subTest(hiding=hiding):
self._svg(
f'<g id="hidden-group" {hiding}>'
'<g display="inline" visibility="visible">'
'<rect id="child" x="100" y="100" width="200" height="100" fill="#FF0000"/>'
'</g></g>'
)
self.assertEqual(self._shape_colors(self._export()), [])
warnings = self._check()['warnings']
self.assertTrue(any('hidden-group' in warning and 'display:none' in warning
for warning in warnings), warnings)
def test_hidden_group_allows_explicitly_visible_descendant(self) -> None:
for hiding, showing in (
('visibility="hidden"', 'visibility="visible"'),
('style="visibility:hidden"', 'style="visibility:visible"'),
):
with self.subTest(hiding=hiding):
self._svg(
f'<g id="hidden-group" {hiding}>'
'<rect id="hidden-child" x="100" y="100" width="200" height="100" fill="#FF0000"/>'
f'<g><rect id="shown" {showing} x="400" y="100" width="200" height="100" fill="#00FF00"/>'
'</g></g>'
)
self.assertEqual(self._shape_colors(self._export()), ['00FF00'])
warnings = self._check()['warnings']
self.assertTrue(any('hidden-child' in warning and 'visibility:hidden' in warning
for warning in warnings), warnings)
self.assertFalse(any('shown' in warning and 'not be exported' in warning for warning in warnings))
def test_style_visibility_overrides_presentation_attribute(self) -> None:
self._svg(
'<rect id="shown" x="100" y="100" width="200" height="100" fill="#123456" '
'visibility="hidden" style="visibility:visible"/>'
)
self.assertEqual(self._shape_colors(self._export()), ['123456'])
def test_hidden_background_is_not_promoted(self) -> None:
for body, attributes in (
('<rect width="1280" height="720" fill="#FF0000" visibility="hidden"/>', ''),
('<g display="none"><rect width="1280" height="720" fill="#FF0000"/></g>', ''),
('<rect width="1280" height="720" fill="#FF0000"/>', 'display="none"'),
):
with self.subTest(body=body, attributes=attributes):
self._svg(body, attributes)
slide = self._export()
self.assertIsNone(slide.find('p:cSld/p:bg', NS))
self.assertEqual(self._shape_colors(slide), [])
def test_native_geometry_carrier_survives_but_ancestor_can_hide_it(self) -> None:
carrier = (
'<path id="carrier" d="M 100 100 L 300 100 L 300 200 L 100 200 Z" '
'fill="#123456" data-pptx-prst="rect" data-pptx-frame="100 100 200 100" '
'data-pptx-object="shape" data-pptx-shape-id="2" data-pptx-part="geometry" '
'visibility="hidden" pointer-events="none"/>'
)
self._svg(carrier)
slide = self._export()
self.assertEqual(self._shape_colors(slide), ['123456'])
self.assertEqual(slide.find('.//p:sp/p:nvSpPr/p:cNvPr', NS).get('id'), '2')
self.assertFalse(any('carrier' in warning and 'not be exported' in warning
for warning in self._check()['warnings']))
self._svg(f'<g visibility="hidden">{carrier}</g>')
self.assertEqual(self._shape_colors(self._export()), [])
def _pattern(self, paints: str, attributes: str = 'data-pptx-pattern="smGrid"') -> None:
self._svg(
f'<defs><pattern id="pat" {attributes} patternUnits="userSpaceOnUse" '
f'width="10" height="10">{paints}</pattern></defs>'
'<rect id="pattern-shape" x="100" y="100" width="200" height="100" fill="url(#pat)"/>'
)
def test_pattern_missing_foreground_fails_checker_and_export(self) -> None:
self._pattern('<rect width="10" height="10" fill="#FF0000"/>')
errors = self._check()['errors']
self.assertTrue(any('pat' in error and 'foreground' in error and 'data-pptx-fg' in error
for error in errors), errors)
with self.assertRaisesRegex(SvgNativeConversionError, 'foreground'):
self._export()
def test_complete_pattern_exports_native_fill(self) -> None:
self._pattern(
'<rect width="10" height="10" fill="#FFFFFF"/>'
'<path d="M 0 0 L 10 10" stroke="#123456"/>'
)
self.assertFalse(self._check()['errors'])
pattern = self._export().find('.//a:pattFill', NS)
self.assertIsNotNone(pattern)
self.assertEqual(pattern.get('prst'), 'smGrid')
self.assertEqual(pattern.find('a:fgClr/a:srgbClr', NS).get('val'), '123456')
self.assertEqual(pattern.find('a:bgClr/a:srgbClr', NS).get('val'), 'FFFFFF')
def test_pattern_metadata_and_child_alpha_remain_supported(self) -> None:
self._pattern(
'<rect width="10" height="10" style="fill:#FFFFFF;fill-opacity:0.5"/>'
'<path d="M 0 0 L 10 10" style="stroke:#123456;stroke-opacity:0.25"/>',
'data-pptx-pattern="smGrid" data-pptx-fg="#123456" data-pptx-bg="#FFFFFF"',
)
self.assertFalse(self._check()['errors'])
pattern = self._export().find('.//a:pattFill', NS)
self.assertEqual(pattern.find('a:fgClr/a:srgbClr/a:alpha', NS).get('val'), '25000')
self.assertEqual(pattern.find('a:bgClr/a:srgbClr/a:alpha', NS).get('val'), '50000')
def test_unmarked_pattern_keeps_missing_foreground_fallback(self) -> None:
self._pattern('<rect width="10" height="10" fill="#FF0000"/>', '')
self.assertFalse(self._check()['errors'])
slide = self._export()
self.assertIsNone(slide.find('.//a:pattFill', NS))
self.assertIsNotNone(slide.find('.//p:sp/p:spPr/a:noFill', NS))
def test_single_child_rotation_matches_direct_transform(self) -> None:
for child in (
'<text x="100" y="120" {transform}>ABC</text>',
'<rect x="100" y="120" width="80" height="30" {transform}/>',
):
with self.subTest(child=child):
transform = 'transform="rotate(90 100 120)"'
self._svg(child.format(transform=transform), 'font-family="Arial" font-size="20"')
direct = self._export().find('.//p:sp/p:spPr/a:xfrm', NS)
self._svg(
f'<g {transform}>{child.format(transform="")}</g>',
'font-family="Arial" font-size="20"',
)
grouped = self._export()
rotated = grouped.find('.//a:xfrm[@rot]', NS)
self.assertIsNotNone(rotated)
self.assertEqual(rotated.get('rot'), direct.get('rot'))
# Group and direct transforms round fractional glyph widths separately.
for axis in ('x', 'y'):
self.assertAlmostEqual(
int(rotated.find('a:off', NS).get(axis)),
int(direct.find('a:off', NS).get(axis)),
delta=1,
)
self.assertEqual(rotated.find('a:ext', NS).attrib, direct.find('a:ext', NS).attrib)
def test_multiple_child_rotation_keeps_group_frame(self) -> None:
self._svg(
'<g transform="rotate(90 100 120)"><text x="100" y="120">ABC</text>'
'<rect x="160" y="110" width="40" height="20"/></g>',
'font-family="Arial" font-size="20"',
)
group = self._export().find('.//p:grpSp', NS)
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'})
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:
for transform in ('matrix(0 1 -1 0 220 20)', 'matrix(2 0 0 3 10 20)'):
with self.subTest(transform=transform):
rect = '<rect x="100" y="120" width="80" height="30" {transform}/>'
self._svg(rect.format(transform=f'transform="{transform}"'))
direct = self._export().find('.//p:sp/p:spPr', NS)
self._svg(f'<g transform="{transform}">{rect.format(transform="")}</g>')
grouped = self._export().find('.//p:sp/p:spPr', NS)
self.assertEqual(ET.tostring(grouped), ET.tostring(direct))
for transform in ('matrix(1 0 0.36 1 0 0)', 'skewX(20)'):
for body in (
'<rect x="100" y="120" width="80" height="30" transform="{transform}"/>',
'<g transform="{transform}"><rect x="100" y="120" width="80" height="30"/></g>',
):
with self.subTest(transform=transform, body=body):
self._svg(body.format(transform=transform))
with self.assertRaisesRegex(SvgNativeConversionError, 'skew|skewX'):
self._export()
def test_root_opacity_multiplies_group_and_fill_opacity(self) -> None:
for attributes in ('opacity="0.5"', 'style="opacity:0.5"', 'opacity="0.8" style="opacity:0.5"'):
for group_opacity, expected in (('1', '50000'), ('0.5', '25000')):
with self.subTest(attributes=attributes, group_opacity=group_opacity):
self._svg(
f'<g opacity="{group_opacity}" data-pptx-bounds="50 50 500 300">'
'<rect x="100" y="100" width="200" height="100" fill="#FF0000"/></g>',
attributes,
)
self.assertFalse(self._check()['errors'])
alpha = self._export().find('.//p:sp/p:spPr/a:solidFill/a:srgbClr/a:alpha', NS)
self.assertIsNotNone(alpha)
self.assertEqual(alpha.get('val'), expected)
self._svg('<rect width="1280" height="720" fill="#FF0000"/>', 'opacity="0"')
self.assertEqual(self._export().find('.//a:alpha', NS).get('val'), '0')
self._svg(
'<rect x="100" y="100" width="200" height="100" fill="#FF0000"/>',
'opacity="0.5" fill-opacity="0.5"',
)
self.assertEqual(self._export().find('.//a:alpha', NS).get('val'), '25000')
def _native_gradient(self, alpha: str = '') -> ET.Element:
gradient = ET.fromstring(
'<linearGradient xmlns="http://www.w3.org/2000/svg" id="g" x1="0" y1="0" x2="1" y2="0">'
'<stop offset="0" stop-color="#FF0000"/><stop offset="1" stop-color="#0000FF"/>'
'</linearGradient>'
)
native = ET.fromstring(
f'<a:gradFill xmlns:a="{NS["a"]}"><a:gsLst>'
f'<a:gs pos="0"><a:srgbClr val="FF0000">{alpha}</a:srgbClr></a:gs>'
'<a:gs pos="100000"><a:schemeClr val="accent1"/></a:gs>'
'</a:gsLst><a:lin ang="0" scaled="1"/></a:gradFill>'
)
gradient.attrib.update(native_gradient_metadata(native, gradient))
return gradient
def test_native_gradient_opacity_uses_copy_and_preserves_identity(self) -> None:
for source_alpha, expected in (('', '50000'), ('<a:alpha val="33333"/>', '16667')):
with self.subTest(source_alpha=source_alpha):
gradient = self._native_gradient(source_alpha)
original = preserved_native_gradient_xml(gradient)
attributes = dict(gradient.attrib)
tinted = ET.fromstring(build_gradient_fill(gradient, opacity=0.5))
self.assertEqual([n.get('val') for n in tinted.findall('.//a:alpha', NS)], [expected, '50000'])
self.assertEqual(gradient.attrib, attributes)
self.assertEqual(build_gradient_fill(gradient, opacity=None), original)
self.assertEqual(build_gradient_fill(gradient, opacity=1.0), original)
self.assertEqual(build_gradient_fill(gradient, opacity=0.5), ET.tostring(tinted, encoding='unicode'))
transparent = ET.fromstring(build_gradient_fill(gradient, opacity=0.0))
self.assertEqual([n.get('val') for n in transparent.findall('.//a:alpha', NS)], ['0', '0'])
def test_native_gradient_shape_opacity_exports_each_stop(self) -> None:
gradient = self._native_gradient()
self._svg(
f'<defs>{ET.tostring(gradient, encoding="unicode")}</defs>'
'<rect x="100" y="100" width="200" height="100" fill="url(#g)" opacity="0.5"/>'
'<rect x="400" y="100" width="200" height="100" fill="url(#g)"/>',
)
self.assertFalse(self._check()['errors'])
gradients = self._export().findall('.//a:gradFill', NS)
self.assertEqual([n.get('val') for n in gradients[0].findall('.//a:alpha', NS)], ['50000', '50000'])
self.assertFalse(gradients[1].findall('.//a:alpha', NS))
def test_inline_dx_adds_only_spacing_runs_to_text_body(self) -> None:
for dx in ('10', '-10', '-200', '0'):
with self.subTest(dx=dx):
body = (
'<text x="100" y="120"><tspan {dx}>Alpha</tspan>'
'<tspan fill="#123456" dx="20">Beta</tspan></text>'
)
attributes = 'font-family="Arial" font-size="20"'
self._svg(body.format(dx=f'dx="{dx}"'), attributes)
self.assertFalse(self._check()['errors'])
shifted = self._export().find('.//p:txBody', NS)
self._svg(body.format(dx='').replace(' dx="20"', ''), attributes)
unshifted = self._export().find('.//p:txBody', NS)
paragraph = shifted.find('a:p', NS)
spacers = [r for r in paragraph.findall('a:r', NS) if r.find('a:t', NS).text == '\u00a0']
self.assertEqual(len(spacers), 1 if dx == '0' else 2)
if dx != '0':
spacing = int(spacers[0].find('a:rPr', NS).get('spc'))
self.assertGreater(spacing, 0) if dx == '10' else self.assertLess(spacing, 0)
for spacer in spacers:
paragraph.remove(spacer)
self.assertEqual(ET.tostring(shifted), ET.tostring(unshifted))
def test_inline_dx_keeps_nested_and_tail_runs(self) -> None:
self._svg(
'<text x="100" y="120"><tspan dx="10">A<tspan dx="-4" fill="#123456">B</tspan>C</tspan>'
'D<tspan dx="5">E</tspan></text>',
'font-family="Arial" font-size="20"',
)
self.assertFalse(self._check()['errors'])
texts = [n.text for n in self._export().findall('.//a:t', NS)]
self.assertEqual(texts, ['\u00a0', 'A', '\u00a0', 'B', 'CD', '\u00a0', 'E'])
def test_inline_dx_does_not_coalesce_into_plain_ab(self) -> None:
self._svg(
'<text x="100" y="120"><tspan dx="10">A</tspan><tspan dx="20">B</tspan></text>',
'font-family="Arial" font-size="20"',
)
shifted = self._export()
runs = shifted.findall('.//a:r', NS)
self.assertEqual([r.find('a:t', NS).text for r in runs], ['\u00a0', 'A', '\u00a0', 'B'])
# Arial's 0.2778 em NBSP leaves 4.444 / 14.444 px of tracking at 20 px.
self.assertEqual([r.find('a:rPr', NS).get('spc') for r in runs], ['333', None, '1083', None])
self._svg('<text x="100" y="120"><tspan>A</tspan><tspan>B</tspan></text>',
'font-family="Arial" font-size="20"')
self.assertEqual([t.text for t in self._export().findall('.//a:t', NS)], ['AB'])
def test_inline_dx_survives_empty_span_and_first_line_compaction(self) -> None:
for first in ('<tspan dx="10">A</tspan>', '<tspan dx="10"/><tspan>A</tspan>'):
with self.subTest(first=first):
self._svg(
f'<text x="100" y="120">{first}<tspan x="100" dy="30">B</tspan></text>',
'font-family="Arial" font-size="20"',
)
texts = [t.text for t in self._export().findall('.//a:t', NS)]
self.assertEqual(texts, ['\u00a0', 'A', 'B'])
def test_inline_dx_survives_split_and_preserved_lines(self) -> None:
for text_flow in ('split', 'preserve', 'reflow'):
with self.subTest(text_flow=text_flow):
self._svg(
'<text x="100" y="120"><tspan x="100">Alpha</tspan><tspan dx="10">Beta</tspan>'
'<tspan x="100" dy="30">Gamma</tspan><tspan dx="-5">Delta</tspan></text>',
'font-family="Arial" font-size="20"',
)
xml, *_ = convert_svg_to_slide_shapes(self.svg_path, resource_root=self.root, text_flow=text_flow)
slide = ET.fromstring(xml)
self.assertEqual(sum(n.text == '\u00a0' for n in slide.findall('.//a:t', NS)), 2)
self.assertEqual(len(slide.findall('.//p:sp', NS)), 2 if text_flow == 'split' else 1)
if text_flow == 'preserve':
self.assertEqual(len(slide.findall('.//a:br', NS)), 1)
def test_line_starter_dx_is_consumed_once_and_small_dy_still_splits(self) -> None:
self._svg(
'<text x="100" y="120"><tspan x="100" dx="10">A</tspan>'
'<tspan x="100" dx="-5" dy="30">B</tspan></text>',
'font-family="Arial" font-size="20"',
)
positioned = ET.tostring(self._export())
self._svg(
'<text x="110" y="120">A</text><text x="95" y="150">B</text>',
'font-family="Arial" font-size="20"',
)
self.assertEqual(positioned, ET.tostring(self._export()))
tree = ET.ElementTree(ET.fromstring(
'<svg xmlns="http://www.w3.org/2000/svg"><text x="100" y="120">A<tspan dy="-4">B</tspan>'
'</text></svg>'
))
self.assertTrue(flatten_text_with_tspans(tree))
self.assertEqual([n.get('y') for n in tree.getroot()], ['120', '116'])
class NotesDiscoveryTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.notes_dir = self.root / 'notes'
self.notes_dir.mkdir()
def test_filename_notes_win_in_both_glob_orders(self) -> None:
exact = self.notes_dir / '01_intro.md'
legacy = self.notes_dir / 'slide01.md'
exact.write_text('EXACT FILENAME NOTE\n', encoding='utf-8')
legacy.write_text('LEGACY INDEX NOTE\n', encoding='utf-8')
for order in ([exact, legacy], [legacy, exact]):
with self.subTest(order=[path.name for path in order]):
stderr = io.StringIO()
with patch.object(Path, 'glob', return_value=iter(order)), redirect_stderr(stderr):
notes = find_notes_files(self.root, [self.root / '01_intro.svg'])
self.assertEqual(notes, {'01_intro': 'EXACT FILENAME NOTE'})
self.assertEqual(len(stderr.getvalue().splitlines()), 1)
self.assertIn('01_intro.svg', stderr.getvalue())
self.assertIn(str(exact), stderr.getvalue())
def test_legacy_notes_still_match_by_slide_index(self) -> None:
(self.notes_dir / 'slide01.md').write_text('LEGACY INDEX NOTE\n', encoding='utf-8')
self.assertEqual(
find_notes_files(self.root, [self.root / '01_intro.svg']),
{'01_intro': 'LEGACY INDEX NOTE'},
)
def test_one_file_matching_both_modes_does_not_warn(self) -> None:
(self.notes_dir / 'slide01.md').write_text('SLIDE NOTE\n', encoding='utf-8')
stderr = io.StringIO()
with redirect_stderr(stderr):
notes = find_notes_files(self.root, [self.root / 'slide01.svg'])
self.assertEqual(notes, {'slide01': 'SLIDE NOTE'})
self.assertEqual(stderr.getvalue(), '')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Regression tests for image rotation task outcomes and EXIF handling."""
from __future__ import annotations
import io
import json
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from PIL import Image
from rotate_images import ImageRotator, main
class ImageRotationTests(unittest.TestCase):
def test_fix_exit_status_tracks_failed_and_invalid_tasks(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "image.png"
fixes = root / "fixes.json"
cases = (
([{"path": str(root / "missing.png"), "rotation": 90}], 1),
([{"path": str(source), "rotation": "bad"}], 1),
([{"path": str(source)}], 1),
([{"rotation": 90}], 1),
([None, {"path": 42, "rotation": 90}], 1),
([{"path": str(source), "rotation": 90}], 0),
([{"path": str(source), "rotation": 0}], 0),
([], 0),
({"path": str(source), "rotation": 90}, 1),
)
for tasks, expected in cases:
with self.subTest(tasks=tasks):
Image.new("RGB", (80, 40), "red").save(source)
fixes.write_text(json.dumps(tasks), encoding="utf-8")
with redirect_stdout(io.StringIO()):
self.assertEqual(main(["fix", str(fixes)]), expected)
def test_fix_continues_after_failures_and_reports_success_count(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "image.png"
Image.new("RGB", (80, 40), "red").save(source)
tasks = [None, {}, {"path": str(source), "rotation": "bad"},
{"path": str(source), "rotation": 90}]
with redirect_stdout(io.StringIO()):
stats = ImageRotator().apply_fixes(tasks)
self.assertEqual(stats, {"total": 4, "success": 1, "failed": 3})
with Image.open(source) as result:
self.assertEqual(result.size, (40, 80))
def test_manual_rotation_follows_exif_display_orientation(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "image.jpg"
image = Image.new("RGB", (80, 40), "red")
image.paste("blue", (40, 0, 80, 40))
exif = image.getexif()
exif[274] = 6
image.save(source, exif=exif)
with redirect_stdout(io.StringIO()):
stats = ImageRotator().apply_fixes([{"path": str(source), "rotation": 90}])
self.assertEqual(stats["success"], 1)
with Image.open(source) as result:
self.assertEqual(result.size, (80, 40))
self.assertEqual(result.getexif().get(274, 1), 1)
self.assertGreater(result.getpixel((10, 10))[2], 200)
def test_auto_and_gen_fail_on_unreadable_images_and_missing_directories(self) -> None:
for command in ("auto", "gen"):
with self.subTest(command=command), tempfile.TemporaryDirectory() as tmp:
images = Path(tmp) / "images"
with redirect_stdout(io.StringIO()):
self.assertEqual(main([command, str(images)]), 1)
images.mkdir()
(images / "bad.jpg").write_bytes(b"not an image")
with redirect_stdout(io.StringIO()):
self.assertEqual(main([command, str(images)]), 1)
self.assertFalse((Path(tmp) / "image_orientation_tool.html").exists())
def test_auto_and_gen_accept_valid_images(self) -> None:
for command in ("auto", "gen"):
with self.subTest(command=command), tempfile.TemporaryDirectory() as tmp:
images = Path(tmp) / "images"
images.mkdir()
source = images / "image.jpg"
image = Image.new("RGB", (80, 40), "red")
exif = image.getexif()
exif[274] = 6
image.save(source, exif=exif)
with redirect_stdout(io.StringIO()):
self.assertEqual(main([command, str(images)]), 0)
self.assertEqual(main([command, str(images)]), 0)
with Image.open(source) as result:
self.assertEqual(result.size, (40, 80))
self.assertEqual(result.getexif().get(274, 1), 1)
def test_auto_continues_after_failed_images(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "bad.jpg").write_bytes(b"not an image")
image = Image.new("RGB", (80, 40), "red")
exif = image.getexif()
exif[274] = 6
image.save(root / "good.jpg", exif=exif)
with redirect_stdout(io.StringIO()) as stdout:
self.assertEqual(main(["auto", str(root)]), 1)
self.assertIn("Auto-fixed EXIF orientation for 1 image(s)", stdout.getvalue())
with Image.open(root / "good.jpg") as result:
self.assertEqual(result.size, (40, 80))
if __name__ == "__main__":
unittest.main()
@@ -3,20 +3,70 @@
from __future__ import annotations from __future__ import annotations
import base64
import io
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from xml.etree import ElementTree as ET
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from slice_images import slice_sheet
from svg_finalize.crop_images import process_svg_images
from svg_finalize.embed_images import _optimize_image_bytes
from svg_finalize.fix_image_aspect import (
get_image_dimensions_from_base64,
get_image_dimensions_pil,
)
from pptx_to_svg.pic_to_svg import _apply_blip_image_effects, _image_size_at_96_dpi
SCRIPTS_DIR = Path(__file__).resolve().parents[1] SCRIPTS_DIR = Path(__file__).resolve().parents[1]
SCRIPT = SCRIPTS_DIR / "slice_images.py" SCRIPT = SCRIPTS_DIR / "slice_images.py"
class SliceImagesDiagnosticsTests(unittest.TestCase): class SliceImagesDiagnosticsTests(unittest.TestCase):
def test_sheet_orientation_is_applied_before_slicing(self) -> None:
for orientation, expected_size in ((6, (40, 80)), (None, (80, 40))):
with self.subTest(orientation=orientation), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "sheet.jpg"
image = Image.new("RGB", (80, 40), "red")
image.paste("blue", (40, 0, 80, 40))
exif = image.getexif()
if orientation is not None:
exif[274] = orientation
image.save(source, exif=exif)
paths = slice_sheet(source, 1, 1, root / "output")
with Image.open(paths[0]) as result:
self.assertEqual(result.size, expected_size)
self.assertNotIn(274, result.getexif())
self.assertGreater(result.getpixel((10, 10))[0], 200)
blue_point = (10, 60) if orientation == 6 else (60, 10)
self.assertGreater(result.getpixel(blue_point)[2], 200)
def test_grid_uses_oriented_dimensions(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "sheet.jpg"
image = Image.new("RGB", (80, 40), "red")
image.paste("blue", (40, 0, 80, 40))
exif = image.getexif()
exif[274] = 6
image.save(source, exif=exif)
paths = slice_sheet(source, 2, 1, root / "output")
for path, channel in zip(paths, (0, 2)):
with Image.open(path) as result:
self.assertEqual(result.size, (40, 40))
self.assertGreater(result.getpixel((20, 20))[channel], 200)
def test_strict_alpha_reports_measured_sheet_border_and_exact_rerun(self) -> None: def test_strict_alpha_reports_measured_sheet_border_and_exact_rerun(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
@@ -56,5 +106,85 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
self.assertFalse((output_dir / "element.png").exists()) self.assertFalse((output_dir / "element.png").exists())
class ImageOrientationProcessingTests(unittest.TestCase):
def test_compression_applies_orientation_and_preserves_image_format(self) -> None:
for fmt, mime in (("JPEG", "image/jpeg"), ("PNG", "image/png"), ("WEBP", "image/webp")):
for orientation in (None, 6):
with self.subTest(format=fmt, orientation=orientation):
image = Image.new("RGB", (80, 40), "red")
image.paste("blue", (40, 0, 80, 40))
exif = image.getexif()
if orientation is not None:
exif[274] = orientation
source = io.BytesIO()
image.save(source, format=fmt, exif=exif)
result = _optimize_image_bytes(source.getvalue(), mime, compress=True, max_dimension=20)
self.assertLess(len(result), len(source.getvalue()))
with Image.open(io.BytesIO(result)) as optimized:
self.assertEqual(optimized.format, fmt)
self.assertEqual(optimized.size, (10, 20) if orientation == 6 else (20, 10))
self.assertNotIn(274, optimized.getexif())
def test_animated_images_are_not_reencoded(self) -> None:
source = io.BytesIO()
Image.new("RGB", (80, 40), "red").save(
source, format="GIF", save_all=True,
append_images=[Image.new("RGB", (80, 40), "blue")],
)
original = source.getvalue()
self.assertEqual(_optimize_image_bytes(original, "image/gif", compress=True), original)
def test_svg_dimensions_and_crop_use_display_orientation_while_import_keeps_stored_pixels(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "photo.jpg"
image = Image.new("RGB", (80, 40), "red")
image.paste("blue", (40, 0, 80, 40))
exif = image.getexif()
exif[274] = 6
image.save(source, exif=exif, dpi=(96, 96))
data = source.read_bytes()
uri = "data:image/jpeg;base64," + base64.b64encode(data).decode("ascii")
self.assertEqual(get_image_dimensions_pil(str(source)), (40, 80))
self.assertEqual(get_image_dimensions_from_base64(uri), (40, 80))
# PPTX import keeps the stored pixel orientation: PowerPoint renders
# an embedded picture without applying its EXIF orientation tag.
self.assertEqual(_image_size_at_96_dpi(data, ET.Element("blipFill")), (80, 40))
svg = root / "slide.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 80">'
'<image href="photo.jpg" width="40" height="80" '
'preserveAspectRatio="xMidYMid slice"/></svg>',
encoding="utf-8",
)
self.assertEqual(process_svg_images(str(svg), root / "cropped", verbose=False), (1, 0))
with Image.open(root / "cropped" / "photo.jpg") as cropped:
self.assertEqual(cropped.size, (40, 80))
self.assertGreater(cropped.getpixel((10, 60))[2], 200)
blip = ET.fromstring(
'<a:blip xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<a:lum bright="10000"/></a:blip>'
)
_, adjusted, diagnostics = _apply_blip_image_effects("photo.jpg", data, blip)
self.assertEqual(diagnostics, ())
with Image.open(io.BytesIO(adjusted)) as result:
self.assertEqual(result.size, (80, 40))
def test_watermark_processing_applies_orientation(self) -> None:
from gemini_watermark_remover import process_image
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "image.jpg"
image = Image.new("RGB", (256, 160), "red")
exif = image.getexif()
exif[274] = 6
image.save(source, exif=exif)
output = process_image(source, Path(tmp) / "processed.png", verbose=False)
with Image.open(output) as result:
self.assertEqual(result.size, (160, 256))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""Unit tests for source_to_md.py output naming and image-flag routing."""
from __future__ import annotations
import argparse
import codecs
import io
import json
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import Mock, patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import source_to_md # noqa: E402
WEB_BACKEND_DIR = SCRIPTS_DIR / "source_to_md"
if str(WEB_BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(WEB_BACKEND_DIR))
from web_to_md import is_plain_text_document # noqa: E402
import web_to_md # noqa: E402
from excel_to_md import _format_cell_value # noqa: E402
def _args(**overrides: object) -> argparse.Namespace:
values = dict(
images=None,
no_images=False,
filter_images=False,
render_vector_figures=False,
)
values.update(overrides)
return argparse.Namespace(**values)
class OutputNamingTests(unittest.TestCase):
def test_single_input_extensionless_output_gets_md_suffix(self) -> None:
self.assertEqual(
source_to_md._dispatch_output_arg(
"https://example.com/post", "web", "sources_cf", False, set(),
),
"sources_cf.md",
)
self.assertEqual(
source_to_md._dispatch_output_arg(
"report.docx", "doc", "notes.markdown", False, set(),
),
"notes.markdown",
)
def test_existing_directory_still_keeps_default_name_inside_it(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = source_to_md._dispatch_output_arg(
"report.docx", "doc", tmp, False, set(),
)
self.assertEqual(Path(result), Path(tmp) / "report.md")
class ImageFlagRoutingTests(unittest.TestCase):
def test_downloaded_webp_is_oriented_before_png_conversion(self) -> None:
from PIL import Image
image = Image.new("RGB", (80, 40), "red")
exif = image.getexif()
exif[274] = 6
encoded = io.BytesIO()
image.save(encoded, format="WEBP", exif=exif)
response = Mock(content=encoded.getvalue(), headers={"Content-Type": "image/webp"})
content = web_to_md.BeautifulSoup('<p><img src="photo.webp"/></p>', "html.parser")
with tempfile.TemporaryDirectory() as tmp:
with patch.object(web_to_md, "_http_get", return_value=response), redirect_stdout(io.StringIO()):
count = web_to_md.download_and_rewrite_images(content, "https://example.com/", tmp, "images")
self.assertEqual(count, 1)
with Image.open(next(Path(tmp).glob("*.png"))) as converted:
self.assertEqual(converted.size, (40, 80))
self.assertNotIn(274, converted.getexif())
def test_no_images_is_accepted_for_web_and_pdf(self) -> None:
self.assertTrue(
source_to_md._validate_pdf_image_flags(
_args(no_images=True), ["web", "pdf"],
)
)
self.assertTrue(
source_to_md._validate_pdf_image_flags(
_args(images="none"), ["web"],
)
)
def test_other_image_flags_stay_pdf_only(self) -> None:
self.assertFalse(
source_to_md._validate_pdf_image_flags(
_args(filter_images=True), ["web"],
)
)
self.assertFalse(
source_to_md._validate_pdf_image_flags(
_args(no_images=True), ["doc"],
)
)
def test_no_images_is_a_no_op_for_markdown_and_text(self) -> None:
self.assertTrue(
source_to_md._validate_pdf_image_flags(
_args(no_images=True), ["markdown", "text", "web"],
)
)
class RawTextUrlTests(unittest.TestCase):
def test_markdown_url_with_markdown_body_is_plain_text(self) -> None:
self.assertTrue(is_plain_text_document(
"https://raw.githubusercontent.com/astral-sh/uv/main/CHANGELOG.md",
"# Changelog\n\n## 0.12.10\n\nReleased on 2026-09-04.\n",
))
def test_html_bodies_and_html_urls_still_go_through_the_extractor(self) -> None:
self.assertFalse(is_plain_text_document(
"https://example.com/notes.md",
"<!DOCTYPE html><html><body><p>rendered</p></body></html>",
))
self.assertFalse(is_plain_text_document(
"https://docs.astral.sh/uv/", "# looks like markdown but is a page",
))
def test_skips_images_reads_both_spellings(self) -> None:
self.assertTrue(source_to_md._skips_images(_args(no_images=True)))
self.assertTrue(source_to_md._skips_images(_args(images="none")))
self.assertFalse(source_to_md._skips_images(_args(images="all")))
class ExcelCellValueTests(unittest.TestCase):
def test_float_values_keep_excel_display_precision(self) -> None:
# 15 significant digits, the precision Excel itself displays.
cases = [
(1234567.89, "1234567.89"),
(0.1 + 0.2, "0.3"),
(100.0, "100"),
(1e-7, "1e-07"),
(-0.5, "-0.5"),
(1.2345678901234567, "1.23456789012346"),
(-0.0, "-0"),
(1e20, "1e+20"),
]
for value, expected in cases:
with self.subTest(value=value):
result = _format_cell_value(value)
self.assertEqual(result, expected)
self.assertAlmostEqual(float(result), value, delta=abs(value) * 1e-14)
class WebTraversalTests(unittest.TestCase):
def test_inline_whitespace_is_preserved_once(self) -> None:
cases = [
("<strong>Hello</strong> <em>World</em>", "**Hello** *World*"),
('See <a href="x">here</a> now', "See [here](x) now"),
("<code>a</code> <code>b</code>", "`a` `b`"),
("<strong>Hello</strong> \n\t <em>World</em>", "**Hello** *World*"),
("<strong>Hello</strong> <!-- comment --> <em>World</em>", "**Hello** *World*"),
("<strong>Hello</strong><em>World</em>", "**Hello***World*"),
]
for html, expected in cases:
with self.subTest(html=html):
soup = web_to_md.BeautifulSoup(f"<p>{html}</p>", "html.parser")
self.assertEqual(web_to_md.simple_html_to_markdown_traversal(soup), expected)
def test_block_whitespace_does_not_leak_into_paragraphs(self) -> None:
cases = [
("<p>one</p> \n <p>two</p>", "one\n\ntwo"),
("<div>one</div> \n <div>two</div>", "one\n\ntwo"),
("<section>one</section> \n <section>two</section>", "one\n\ntwo"),
("<ul> <li>one</li> <li>two</li> </ul>", "- one\n- two"),
("<p> lead</p>", "lead"),
("<p> lead</p><p>trail </p>", "lead\n\ntrail"),
("<p> <strong>Hello</strong> <em>World</em> </p>", "**Hello** *World*"),
]
for html, expected in cases:
with self.subTest(html=html):
soup = web_to_md.BeautifulSoup(html, "html.parser")
self.assertEqual(web_to_md.simple_html_to_markdown_traversal(soup), expected)
def test_preformatted_text_and_explicit_line_breaks_keep_spacing(self) -> None:
cases = [
("<pre> a\n b </pre>", "```\n a\n b \n```"),
("<p>one<br>two</p>", "one \ntwo"),
]
for html, expected in cases:
with self.subTest(html=html):
soup = web_to_md.BeautifulSoup(html, "html.parser")
self.assertEqual(web_to_md.simple_html_to_markdown_traversal(soup), expected)
def test_links_resolve_relative_targets_and_keep_special_targets(self) -> None:
cases = [
("../target", "[label](https://example.com/target)"),
("/abs", "[label](https://example.com/abs)"),
("//cdn.x/y", "[label](https://cdn.x/y)"),
("#frag", "[label](#frag)"),
("mailto:reader@example.com", "[label](mailto:reader@example.com)"),
("tel:+123456789", "[label](tel:+123456789)"),
("javascript:void(0)", "label"),
("JavaScript:void(0)", "label"),
]
for href, expected in cases:
with self.subTest(href=href):
soup = web_to_md.BeautifulSoup(f'<p><a href="{href}">label</a></p>', "html.parser")
self.assertEqual(
web_to_md.simple_html_to_markdown_traversal(soup, "https://example.com/a/page"),
expected,
)
def test_process_url_resolves_against_redirect_and_first_base_href(self) -> None:
cases = [
("", "https://redirect.example/docs/"),
('<base href="https://base.example/root/">', "https://base.example/root/"),
('<base href="../assets/">', "https://redirect.example/assets/"),
('<base target="_blank"><base href="/first/"><base href="/ignored/">',
"https://redirect.example/first/"),
]
for base, expected_base in cases:
with self.subTest(base=base), tempfile.TemporaryDirectory() as tmp:
html = (
f"<html><head><title>Links</title>{base}</head><body>"
'<p><a href="target">target</a> <img src="pic.png" alt="pic"></p>'
"</body></html>"
)
response = Mock(
content=html.encode("utf-8"),
headers={"Content-Type": "text/html; charset=utf-8"},
encoding="utf-8",
apparent_encoding="utf-8",
url="https://redirect.example/docs/page",
)
response.raise_for_status.return_value = None
output = Path(tmp) / "page.md"
with patch.object(web_to_md, "_http_get", return_value=response), redirect_stdout(io.StringIO()):
result = web_to_md.process_url(
"https://original.example/start", str(output), download_images=False,
)
self.assertTrue(result[0], result[2])
markdown = output.read_text(encoding="utf-8")
self.assertIn(f"[target]({expected_base}target)", markdown)
self.assertIn(f"![pic]({expected_base}pic.png)", markdown)
class SourceCollisionTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
def _run_cli(self, *arguments: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPTS_DIR / "source_to_md.py"), *arguments, "--json"],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
def _outputs(self, result: subprocess.CompletedProcess) -> list[Path]:
return [
Path(json.loads(line)["markdown"])
for line in result.stdout.splitlines() if line.startswith("{")
]
def test_same_stem_batch_preserves_markdown_in_both_orders(self) -> None:
for reverse in (False, True):
for output_directory in (False, True):
with self.subTest(reverse=reverse, output_directory=output_directory):
root = self.root / f"{reverse}_{output_directory}"
root.mkdir()
text_source = root / "same.txt"
markdown_source = root / "same.md"
text_source.write_text("TEXT-SOURCE\n", encoding="utf-8")
markdown_source.write_text("ORIGINAL-MARKDOWN\n", encoding="utf-8")
inputs = [text_source, markdown_source]
if reverse:
inputs.reverse()
arguments = [str(path) for path in inputs]
if output_directory:
arguments.extend(["-o", str(root)])
result = self._run_cli(*arguments)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(markdown_source.read_text(encoding="utf-8"), "ORIGINAL-MARKDOWN\n")
self.assertEqual(text_source.read_text(encoding="utf-8"), "TEXT-SOURCE\n")
outputs = self._outputs(result)
self.assertEqual(outputs, [root / "same_2.md", root / "same_3.md"])
for source, output in zip(inputs, outputs):
self.assertEqual(output.read_text(encoding="utf-8"), source.read_text(encoding="utf-8"))
self.assertIn("Renamed output", result.stderr)
self.assertIn("Success: 2/2", result.stderr)
def test_batch_outputs_with_the_same_stem_are_distinct(self) -> None:
text_source = self.root / "same.txt"
markdown_source = self.root / "same.markdown"
text_source.write_text("TEXT-SOURCE\n", encoding="utf-8")
markdown_source.write_text("ORIGINAL-MARKDOWN\n", encoding="utf-8")
result = self._run_cli(str(text_source), str(markdown_source))
self.assertEqual(result.returncode, 0, result.stderr)
outputs = self._outputs(result)
self.assertEqual(outputs, [self.root / "same.md", self.root / "same_2.md"])
self.assertEqual([path.read_text(encoding="utf-8") for path in outputs],
["TEXT-SOURCE\n", "ORIGINAL-MARKDOWN\n"])
def test_batch_suffixes_also_avoid_input_paths(self) -> None:
inputs = [self.root / name for name in ("same.txt", "same.md", "same_2.md")]
for index, source in enumerate(inputs):
source.write_text(f"SOURCE-{index}\n", encoding="utf-8")
result = self._run_cli(*(str(path) for path in inputs))
self.assertEqual(result.returncode, 0, result.stderr)
outputs = self._outputs(result)
self.assertEqual(len(set(outputs)), 3)
self.assertFalse(set(inputs) & set(outputs))
for index, (source, output) in enumerate(zip(inputs, outputs)):
self.assertEqual(source.read_text(encoding="utf-8"), f"SOURCE-{index}\n")
self.assertEqual(output.read_text(encoding="utf-8"), f"SOURCE-{index}\n")
def test_explicit_output_refuses_another_existing_file(self) -> None:
source = self.root / "same.txt"
destination = self.root / "same.md"
source.write_text("TEXT-SOURCE\n", encoding="utf-8")
destination.write_text("ORIGINAL-MARKDOWN\n", encoding="utf-8")
result = self._run_cli(str(source), "-o", str(destination))
self.assertNotEqual(result.returncode, 0)
self.assertIn("[ERROR]", result.stderr)
self.assertIn(str(destination), result.stderr)
self.assertEqual(destination.read_text(encoding="utf-8"), "ORIGINAL-MARKDOWN\n")
self.assertEqual(source.read_text(encoding="utf-8"), "TEXT-SOURCE\n")
self.assertEqual(self._outputs(result), [])
self.assertFalse(destination.with_suffix(".conversion_profile.json").exists())
def test_single_markdown_passthrough_keeps_its_own_source(self) -> None:
source = self.root / "same.md"
source.write_text("ORIGINAL-MARKDOWN\n", encoding="utf-8")
for options in ([], ["-o", str(source)]):
with self.subTest(options=options):
result = self._run_cli(str(source), *options)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._outputs(result), [source])
self.assertEqual(source.read_text(encoding="utf-8"), "ORIGINAL-MARKDOWN\n")
def test_passthrough_decodes_supported_encodings_without_changing_text(self) -> None:
text = "# 中文金额 123.45\r\n\r\nKeep spaces\tand tabs.\r\n"
utf8 = text.encode("utf-8")
cases = [
("utf8", utf8, "utf-8"),
("utf8_bom", codecs.BOM_UTF8 + utf8, "utf-8-sig"),
("utf16_le", codecs.BOM_UTF16_LE + text.encode("utf-16-le"), "utf-16"),
("utf16_be", codecs.BOM_UTF16_BE + text.encode("utf-16-be"), "utf-16"),
("gb18030", text.encode("gb18030"), "gb18030"),
]
for name, raw, encoding in cases:
for suffix in (".txt", ".md"):
with self.subTest(encoding=name, suffix=suffix):
source = self.root / f"{name}{suffix}"
output = self.root / f"{name}_{suffix[1:]}_output.md"
source.write_bytes(raw)
result = self._run_cli(str(source), "-o", str(output))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._outputs(result), [output])
self.assertEqual(output.read_bytes(), utf8)
self.assertEqual(source.read_bytes(), raw)
profile = json.loads(output.with_suffix(".conversion_profile.json").read_text(encoding="utf-8"))
if encoding == "utf-8":
self.assertEqual(profile["warnings"], [])
else:
self.assertIn(encoding, result.stdout)
self.assertIn(encoding, " ".join(profile["warnings"]))
def test_invalid_passthrough_fails_before_writing_outputs(self) -> None:
cases = [
bytes(range(256)),
b"\x00\x01binary",
codecs.BOM_UTF8 + b"\xff",
codecs.BOM_UTF16_LE + b"A\x00B",
codecs.BOM_UTF16_BE + b"\x00AB",
]
for index, raw in enumerate(cases):
with self.subTest(raw=raw):
source = self.root / f"invalid_{index}.txt"
output = self.root / f"output_{index}" / "result.md"
source.write_bytes(raw)
result = self._run_cli(str(source), "-o", str(output))
self.assertNotEqual(result.returncode, 0)
self.assertIn("[ERROR]", result.stderr)
self.assertEqual(self._outputs(result), [])
self.assertFalse(output.parent.exists())
self.assertEqual(source.read_bytes(), raw)
def test_non_utf8_passthrough_requires_a_distinct_output_path(self) -> None:
for encoding in ("utf-8-sig", "utf-16", "gb18030"):
with self.subTest(encoding=encoding):
source = self.root / f"{encoding}.md"
raw = "中文原稿\n".encode(encoding)
source.write_bytes(raw)
result = self._run_cli(str(source))
self.assertNotEqual(result.returncode, 0)
self.assertIn("-o", result.stderr)
self.assertEqual(self._outputs(result), [])
self.assertEqual(source.read_bytes(), raw)
self.assertFalse(source.with_suffix(".conversion_profile.json").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Regression tests for SVG editor slide-list caching without a running server."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from svg_editor import server # noqa: E402
class SlideListCacheTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
svg_dir = self.root / 'svg_output'
svg_dir.mkdir()
self.svg_path = svg_dir / '01.svg'
cache_patch = patch.dict(server._LIST_CACHE, clear=True)
cache_patch.start()
self.addCleanup(cache_patch.stop)
with patch.object(server.threading.Thread, 'start'):
app = server.create_app(str(self.root), idle_timeout=0)
app.config['TESTING'] = True
self.client = app.test_client()
def _slide(self) -> dict:
response = self.client.get('/api/slides')
self.assertEqual(response.status_code, 200)
slides = response.get_json()['slides']
self.assertEqual(len(slides), 1)
return slides[0]
def test_invalid_svg_keeps_error_on_repeated_requests(self) -> None:
self.svg_path.write_text('<svg><g>', encoding='utf-8')
first = self._slide()
second = self._slide()
for slide in (first, second):
self.assertIs(slide['ok'], False)
self.assertIn('XML parse error:', slide['error'])
self.assertEqual(slide['annotation_count'], 0)
self.assertEqual(second, first)
def test_changed_mtime_refreshes_parse_status(self) -> None:
self.svg_path.write_text('<svg><g>', encoding='utf-8')
first = self._slide()
self.assertIs(first['ok'], False)
self.svg_path.write_text('<svg xmlns="http://www.w3.org/2000/svg"/>', encoding='utf-8')
mtime = first['mtime'] + 1
os.utime(self.svg_path, (mtime, mtime))
for _ in range(2):
slide = self._slide()
self.assertIs(slide['ok'], True)
self.assertIsNone(slide['error'])
self.assertEqual(slide['mtime'], mtime)
if __name__ == '__main__':
unittest.main()
@@ -37,6 +37,126 @@ def _empty_result() -> dict:
class SVGQualityCheckerBoundsTests(unittest.TestCase): class SVGQualityCheckerBoundsTests(unittest.TestCase):
@staticmethod
def _text_bounds(root: ET.Element, *, include_headroom: bool = False) -> tuple:
text = root.find(f'.//{{{SVG_NS}}}text')
parents = {id(child): parent for parent in root.iter() for child in parent}
sizes = checker_module._resolve_project_font_sizes(root)
spacings = checker_module._resolve_project_letter_spacings(root, sizes)
return SVGQualityChecker._estimated_text_bounds(
text, parents, sizes, spacings, include_headroom=include_headroom,
)
def test_inline_dx_triggers_module_and_canvas_overflow_for_each_anchor(self) -> None:
for anchor, x in (('start', 1200), ('middle', 1200), ('end', 100)):
with self.subTest(anchor=anchor):
root = _parse_svg(
'<g id="title" data-pptx-bounds="80 70 1190 80">'
f'<text x="{x}" y="110" text-anchor="{anchor}" font-size="24">'
'A<tspan dx="400">B</tspan></text></g>',
'font-family="Arial"', view_box='0 0 1280 720',
)
result = _empty_result()
SVGQualityChecker()._check_text_bounds(root, result)
self.assertTrue(any('root viewBox on the horizontal axis' in error
for error in result['errors']), result)
self.assertFalse(any('Cannot verify' in warning for warning in result['warnings']), result)
root.find(f'.//{{{SVG_NS}}}text').set('x', '600')
root.find(f'{{{SVG_NS}}}g').set('data-pptx-bounds', '580 70 90 80')
module_result = _empty_result()
SVGQualityChecker()._check_text_bounds(root, module_result)
self.assertTrue(any('data-pptx-bounds on the horizontal axis' in error
for error in module_result['errors']), module_result)
def test_inline_dx_uses_signed_advances_and_preserves_anchor(self) -> None:
for anchor in ('start', 'middle', 'end'):
for headroom in (False, True):
with self.subTest(anchor=anchor, headroom=headroom):
template = (
f'<text x="600" y="110" text-anchor="{anchor}" font-size="24">'
'A<tspan dx="{dx}">B</tspan></text>'
)
bounds = {}
for dx in (0, 400, -400):
root = _parse_svg(template.format(dx=dx), 'font-family="Arial"')
bounds[dx] = self._text_bounds(root, include_headroom=headroom)
self.assertIsNotNone(bounds[dx])
left0, _top, right0, _bottom = bounds[0]
left, _top, right, _bottom = bounds[400]
self.assertAlmostEqual((right - left) - (right0 - left0), 400)
if anchor == 'start':
self.assertAlmostEqual(left, 600)
elif anchor == 'middle':
self.assertAlmostEqual((left + right) / 2, 600)
else:
self.assertAlmostEqual(right, 600)
single = _parse_svg(
f'<text x="600" y="110" text-anchor="{anchor}" font-size="24">A</text>',
'font-family="Arial"',
)
self.assertEqual(bounds[-400], self._text_bounds(single, include_headroom=headroom))
def test_end_anchor_negative_dx_has_no_false_overflow(self) -> None:
root = _parse_svg(
'<g id="title" data-pptx-bounds="1100 70 180 80">'
'<text x="1270" y="110" text-anchor="end" font-size="24">'
'ABCD<tspan dx="-20">EFGH</tspan></text></g>',
'font-family="Arial"', view_box='0 0 1280 720',
)
result = _empty_result()
SVGQualityChecker()._check_text_bounds(root, result)
self.assertEqual(result, _empty_result())
def test_inline_dx_measures_cjk_links_empty_spans_and_line_start(self) -> None:
for content in (
'中<tspan dx="400">文</tspan>',
'A<a href="https://example.com"><tspan dx="400">B</tspan></a>',
'<tspan dx="400">B</tspan>',
'<tspan dx="400"/><tspan>B</tspan>',
'<tspan dx="250">A<tspan dx="150">B</tspan>C</tspan>D',
):
with self.subTest(content=content):
root = _parse_svg(
f'<text x="1200" y="110" font-size="24">{content}</text>',
'font-family="Arial"', view_box='0 0 1280 720',
)
bounds = self._text_bounds(root)
self.assertIsNotNone(bounds)
self.assertGreater(bounds[2], 1600)
result = _empty_result()
SVGQualityChecker()._check_text_bounds(root, result)
self.assertTrue(any('root viewBox' in error for error in result['errors']), result)
def test_non_scalar_and_percentage_dx_remain_unmeasurable(self) -> None:
for dx in ('20%', '10 20', '10,20', 'calc(20 + 30)'):
with self.subTest(dx=dx):
root = _parse_svg(
f'<text x="1200" y="110" font-size="24">A<tspan dx="{dx}">B</tspan></text>',
'font-family="Arial"', view_box='0 0 1280 720',
)
self.assertIsNone(self._text_bounds(root))
result = _empty_result()
SVGQualityChecker()._check_text_bounds(root, result)
self.assertTrue(any('Cannot verify root viewBox bounds' in warning
for warning in result['warnings']), result)
def test_positioned_lines_count_starter_and_inline_dx_once(self) -> None:
root = _parse_svg(
'<text x="100" y="110" font-size="24">'
'<tspan x="100" dx="10">A</tspan><tspan dx="400">B</tspan>'
'<tspan x="100" dx="-5" dy="30">C</tspan><tspan dx="-20">D</tspan></text>',
'font-family="Arial"',
)
parents = {id(child): parent for parent in root.iter() for child in parent}
sizes = checker_module._resolve_project_font_sizes(root)
spacings = checker_module._resolve_project_letter_spacings(root, sizes)
lines = SVGQualityChecker._resolved_text_lines(root[0], parents, sizes, spacings)
self.assertIsNotNone(lines)
self.assertEqual([(line[1], line[2]) for line in lines], [(110, 110), (95, 140)])
self.assertEqual([
[run['_inline_dx'] for run in line[3] if '_inline_dx' in run] for line in lines
], [[400], [-20]])
def test_leading_direct_text_and_positioned_tspans_are_estimable(self) -> None: def test_leading_direct_text_and_positioned_tspans_are_estimable(self) -> None:
root = _parse_svg( root = _parse_svg(
'<g id="module" data-pptx-bounds="0 0 1000 1000">' '<g id="module" data-pptx-bounds="0 0 1000 1000">'
@@ -18,6 +18,7 @@ if str(SCRIPTS_DIR) not in sys.path:
from svg_to_pptx.drawingml.elements import ( # noqa: E402 from svg_to_pptx.drawingml.elements import ( # noqa: E402
estimate_single_line_text_frame_width, estimate_single_line_text_frame_width,
) )
from svg_to_pptx.drawingml.utils import estimate_text_cluster_widths # noqa: E402
from text_measure import ( # noqa: E402 from text_measure import ( # noqa: E402
_CLOSING_PUNCTUATION, _CLOSING_PUNCTUATION,
_OPENING_PUNCTUATION, _OPENING_PUNCTUATION,
@@ -62,7 +63,157 @@ class TextMeasureTests(unittest.TestCase):
self.assertAlmostEqual(measure_text(SAMPLE, size=22), expected) self.assertAlmostEqual(measure_text(SAMPLE, size=22), expected)
result = _run_cli('measure', SAMPLE, '--size', '22') result = _run_cli('measure', SAMPLE, '--size', '22')
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, f'880.4\t{SAMPLE}\n') self.assertEqual(result.stdout, f'736.5\t{SAMPLE}\n')
def test_arial_raw_width_matches_reference_lines(self) -> None:
cases = (
(
'The dissemination layer covers the poster you stand next to at a conference session',
16, 'normal', 596,
),
(
'The fill loop is discrete: five verdict bands, one section edited per round,',
28, 'bold', 966,
),
)
for text, size, weight, expected in cases:
with self.subTest(weight=weight):
actual = measure_text(
text, size=size, family='Arial', weight=weight,
include_headroom=False,
)
self.assertAlmostEqual(actual, expected, delta=expected * 0.02)
def test_unknown_family_keeps_crude_width(self) -> None:
text = 'The dissemination layer covers the poster you stand next to at a conference session'
crude = sum(estimate_text_cluster_widths(text, 16))
self.assertAlmostEqual(crude, 661.6)
for family in ('Segoe UI', 'Unlisted Sans', 'Segoe UI, Arial'):
with self.subTest(family=family):
self.assertEqual(
measure_text(text, size=16, family=family, include_headroom=False),
crude,
)
def test_bundled_families_use_each_run_style(self) -> None:
# Sum of the supplied A, i, W, and e-acute advances in each face.
advances = {
'Arial': (2.3892, 2.5, 2.3892, 2.5),
'Times New Roman': (2.3876, 2.4438, 2.1654, 2.2778),
'Georgia': (2.4229, 2.8101, 2.4156, 2.8076),
'Verdana': (2.5425, 2.9107, 2.5429, 2.9107),
'Calibri': (2.1954, 2.2612, 2.1757, 2.2495),
}
styles = (
('400', 'normal'), ('bold', 'normal'),
('400', 'italic'), ('bold', 'italic'),
)
for family, widths in advances.items():
for (weight, style), width in zip(styles, widths):
with self.subTest(family=family, weight=weight, style=style):
run = dict(
text='AiWé', font_size=20, font_family=family,
font_weight=weight, font_style=style,
)
self.assertAlmostEqual(
estimate_single_line_text_frame_width([run], include_headroom=False),
width * 20,
)
def test_primary_family_and_style_aliases(self) -> None:
for family in ('Arial', ' "ARIAL", sans-serif', "'arial', Consolas"):
for weight in ('bold', '600', '700', '800', '900'):
with self.subTest(family=family, weight=weight):
self.assertAlmostEqual(
measure_text('AiWé', size=20, family=family, weight=weight,
include_headroom=False),
50.0,
)
for weight, advance in (('500', 2.1654), ('600', 2.2778)):
run = dict(
text='AiWé', font_size=20, font_family='Times New Roman',
font_weight=weight, font_style='oblique',
)
self.assertAlmostEqual(
estimate_single_line_text_frame_width([run], include_headroom=False),
advance * 20,
)
def test_mixed_cjk_and_latin_uses_separate_advances(self) -> None:
for weight, latin in (('normal', 0.667 + 0.2222), ('bold', 0.7222 + 0.2778)):
with self.subTest(weight=weight):
self.assertAlmostEqual(
measure_text('中A文i', size=20, family='Arial', weight=weight,
include_headroom=False),
(2 + latin) * 20,
)
def test_missing_glyph_falls_back_for_its_cluster_only(self) -> None:
for weight, expected in (
('400', [0.667, 0.55, 0.2222]),
('bold', [0.7222, 0.55 * 1.05, 0.2778]),
):
with self.subTest(weight=weight):
self.assertEqual(
estimate_text_cluster_widths('AΩi', 16, weight, font_family='Arial'),
[advance * 16 for advance in expected],
)
self.assertAlmostEqual(
measure_text('AΩi', size=16, family='Arial', weight=weight,
include_headroom=False),
sum(expected) * 16,
)
def test_extended_clusters_keep_existing_widths_and_tracking(self) -> None:
text = 'e\u0301👩🏽‍💻🇨🇳1️⃣Aア'
for weight in ('400', 'bold'):
with self.subTest(weight=weight):
crude = estimate_text_cluster_widths(text, 20, weight)
self.assertEqual(
estimate_text_cluster_widths(text, 20, weight, font_family='Arial'),
crude,
)
self.assertAlmostEqual(
measure_text(text, size=20, family='Arial', weight=weight,
letter_spacing=2, include_headroom=False),
sum(crude) + 2 * (len(crude) - 1),
)
def test_arial_black_keeps_wide_family_factor(self) -> None:
crude = sum(estimate_text_cluster_widths('CAPS', 20, 'bold'))
self.assertAlmostEqual(
measure_text('CAPS', size=20, family='Arial Black', weight='bold',
include_headroom=False),
crude * 1.25,
)
def test_monospace_families_measure_fixed_pitch(self) -> None:
text = "WHERE table = 'x'"
consolas = measure_text(
text, size=20, family='Consolas', include_headroom=False,
)
courier = measure_text(
text, size=20, family='Courier New', include_headroom=False,
)
unlisted = measure_text(
text, size=20, family='Victor Mono', include_headroom=False,
)
arial = measure_text(
text, size=20, family='Arial', include_headroom=False,
)
self.assertAlmostEqual(consolas, len(text) * 0.55 * 20)
self.assertAlmostEqual(courier, len(text) * 0.60 * 20, delta=1.0)
self.assertAlmostEqual(unlisted, courier, delta=0.01)
self.assertGreater(consolas, arial)
# Weight never changes a fixed-pitch advance.
self.assertAlmostEqual(
measure_text(
text, size=20, family='Consolas', weight='bold',
include_headroom=False,
),
consolas,
delta=0.01,
)
def test_wrap_lines_never_exceed_max_width(self) -> None: def test_wrap_lines_never_exceed_max_width(self) -> None:
max_width = 180.0 max_width = 180.0
@@ -94,7 +94,7 @@ def measure_text(
"""Measure one line with the checker-owned DrawingML estimator.""" """Measure one line with the checker-owned DrawingML estimator."""
run = dict( run = dict(
text=text, font_size=size, font_family=family, text=text, font_size=size, font_family=family,
font_weight=weight, letter_spacing=letter_spacing, font_weight=weight, font_style='normal', letter_spacing=letter_spacing,
) )
return estimate_single_line_text_frame_width( return estimate_single_line_text_frame_width(
[run], [run],
@@ -17,6 +17,7 @@ Selection and installation follow [`routing.md`](../../workflows/routing.md) §7
| Reusable method and evidence discipline | Style, where compatible with the current contract | | Reusable method and evidence discipline | Style, where compatible with the current contract |
| Reusable structure | Layout, otherwise Deck; Style never supplies structure | | Reusable structure | Layout, otherwise Deck; Style never supplies structure |
| Recurring application context | Deck, subordinate to the Stage-1 contract | | Recurring application context | Deck, subordinate to the Stage-1 contract |
| Page titling: a Style's page-message discipline against a mode's title tendency | The authored §IX titles and any explicit user or Style titling rule; the locked mode shapes voice and register only ([`executor-base.md`](../../references/executor-base.md) §2.2 Step 2) |
Style fallbacks seed the Stage-2 solution when a decision is open; they are not identity truth and never bypass confirmation. Surface a material Style/Deck conflict rather than weakening either. Style fallbacks seed the Stage-2 solution when a decision is open; they are not identity truth and never bypass confirmation. Surface a material Style/Deck conflict rather than weakening either.
@@ -0,0 +1,103 @@
---
style_id: mbb-consulting
kind: style
summary: Strategy-document method in the MBB consulting convention — answer-first argument at document density with numbered exhibits, a key-message column, footnotes, and action titles on every page.
keywords: [consulting, strategy-document, MBB-style, document-density, exhibits]
---
# MBB Consulting — Style Specification
> Method and design defaults only. No project communication contract, brand identity, page structure, or SVG prototypes.
## I. Style Overview
| Property | Value |
|---|---|
| Style Name | MBB Consulting |
| Best Fit | Strategy documents read at desk distance in the top-tier strategy-consulting convention: market and portfolio diagnoses, priority recommendations, board and management decision papers |
| Reusable Intent | Make each page deliver a decision-relevant answer with traceable proof at document density — several numbered exhibits, a key-message column, and footnoted sources per page — while leaving identity and the current communication contract open to the project |
| Sources | Method inherited from `consulting-decision` ([GitHub Issue #241](https://github.com/hugohe3/ppt-master/issues/241)); document-density page conventions confirmed 2026-09-05 from the EV market-priority deck (example `ppt169_ev_market_priorities`) |
## II. Communication Method
- **Preferred Mode**: pyramid
- **Argument Flow**: Define the governing decision or question, the overall answer, its key supporting arguments, the evidence required for each argument, and any unresolved assumptions or evidence gaps. Maintain the trace `overall answer → key support → page message → evidence`; adapt the sequence to the current project instead of imposing a fixed roster.
- **Page Message Discipline**: For every planned page, identify one governing question, answer it through an assertion title or equally dominant message, and place the supporting proof visibly beneath or beside that answer. Use supporting subquestions only when their relationship matters; avoid topic-only titles.
- **Claim Discipline**: Keep facts, assumptions, implications, and recommendations semantically distinct. Cite facts, name uncertainty in assumptions, derive implications from visible evidence, and pair recommendations with their rationale and action. Keep recommendation and implication wording consistent across the deck; never promote an unsupported claim to a conclusion.
## III. Page Role Vocabulary
| Role | Communication Job | Evidence Obligation | Composition Tendency |
|---|---|---|---|
| Executive synthesis | State the governing decision and overall answer | Show the few supports that make the answer credible and identify any material gap | Numbered findings with page references stacked in the main column, an inference panel and a basis-numbers strip beneath them, and a dark decision-ask column at the right |
| Recommendation | Specify the action and why it is preferable | Connect the action to diagnosis, expected effect, dependencies, and trade-offs | A priority table with a tier column and evidence-page column, a totals strip, and a tier-meaning block beneath it |
| Situation / complication / resolution | Establish context, surface the tension, and resolve the governing question | Distinguish observed conditions from the interpretation that creates the tension | Let the contrast between current reality and the answer carry the page |
| Driver decomposition | Explain what determines an outcome or decision | Use distinct supported branches; preserve real overlap or acknowledged gaps | Make the governing relationship primary and branches easy to compare |
| Current-state diagnosis | Identify the condition that matters and its causes | Separate observation from interpretation and tie each diagnosis to evidence | Two numbered exhibits side by side, a third panel beneath them (compact comparison table, KPI strip, or note), and a key-message column ending in an implication panel |
| Comparison / benchmark | Clarify a decision through alternatives, peers, periods, or standards | State basis, units, period, and comparability; never invent a benchmark | Align comparable evidence and emphasize only decision-relevant differences |
| Process / operating model | Explain how work, ownership, or decisions flow | Show actors, handoffs, dependencies, controls, and failure points that the source supports | Prioritize causal or operational flow over decorative process art |
| Roadmap | Translate the recommendation into sequenced action | Connect phases to outcomes, dependencies, milestones, and decision gates | Stage chevrons across the top, an outputs row, tier lanes as a matrix, and a decision-gate box with dated thresholds at the bottom |
| Risk / mitigation | Expose uncertainty and the response it requires | Pair each risk with likelihood or trigger evidence, impact, mitigation, and owner when known | A risk register table (risk, trigger baseline → threshold, impact, response, monitoring data), a monitoring-cadence column, an implication panel, and a reference-values strip |
| Decision request | Ask for the decisions and name what happens without them | Show each ask with its rationale pages, the action within a fixed window, the owner, and the consequence of not deciding | Numbered asks in aligned columns (ask, action, owner, consequence) over a milestone strip and a review-date line |
| Appendix / evidence | Preserve detail needed to audit or deepen the argument | Retain source, period, method, definitions, and limitations | Full data tables as numbered exhibits, a calculation-method block, and a source-and-licence block with links |
## IV. Evidence & Data Expression
- **Argument Trace**: Every page message must trace back to one key support for the overall answer and forward to visible proof. Keep missing evidence or unresolved assumptions explicit instead of concealing the gap with confident wording.
- **Charts**: Choose the chart from the decision question and comparison logic. Number every exhibit (A, B, C) with a solid badge, a bold title stating what it shows, and a unit line; label every value directly, put deltas and multiples as companion text at bar ends or line ends, and omit gridlines and value axes. Annotate the decision-relevant change in the accent colour, retain units, and footnote each calculation. Separate observation from interpretation and never invent a baseline, peer, target, or trend.
- **Tables**: Derive columns and row groups from the comparison or decision logic. Align units and periods, preserve hierarchy, distinguish facts from assumptions, and emphasize only the differences that affect the answer. Compact comparison tables sit beneath exhibits as a third panel; full tables carry a tier or judgment column and an evidence-page column. Use color only when it carries a declared meaning.
- **Sources**: Number footnotes at the bottom-left of every page with the source line beneath them, and reference footnote numbers from titles, unit lines, and labels. Retain source, period, scope, and measurement basis; label estimates, proxies, scenario values, and computed values as such. Do not present unattributed or unsupported statements as facts.
- **Native Editability**: Prefer editable native charts, tables, and business shapes when the supported interface fits the intended object and editability is useful. Otherwise retain a legible editable shape-based representation rather than sacrificing fidelity or meaning.
## V. Visual System Defaults
- **Preferred Visual Style**: custom
- **Visual Style References**: swiss-minimal
- **Visual Style Behavior**: A strategy-document page, not a keynote slide. Every content page carries one fixed chrome: a two-line action title at the top-left with a hairline beneath it, a unit / exhibit-index line under the title, a section tracker and draft marker at the top-right, numbered footnotes with a source line at the bottom-left, and the page number at the bottom-right. Exhibits carry an A / B / C badge, a bold title, and a unit line; a right-hand column of numbered key messages closes with a tinted implication panel. The cover is the only dark full-bleed field; every other page is a white field ruled by hairlines, with no cards and no shadows.
- **Composition**: Fill each content page with five to seven distinct modules on one grid. The default diagnosis page is two exhibits side by side over a third full-width panel (compact comparison table, KPI strip with captions, or note block), plus the key-message column and its implication panel. Table pages pair the table with a totals strip and a meaning block; roadmap pages stack stage chevrons, an outputs row, tier lanes, and a decision gate; decision pages align ask, action, owner, and consequence columns over a milestone strip and a review line; the cover adds a document-structure strip and headline figures. Whitespace separates modules; it never stands in for a missing module.
- **Density**: Document density read at desk distance. On a 1280-px canvas: action titles near 24 px, body and key messages near 14 px, exhibit titles near 13 px, annotations and chart labels 1112 px, footnotes 10 px. Each exhibit takes roughly a third of the page height; small tables use 1822 px rows with hairline separators, a bold first column, right-aligned numerals, and one tinted band for the emphasized tier. Synthesis, roadmap, decision, and appendix pages keep the same chrome and density; only the cover drops below four modules.
- **Decoration**: Hairline rules and solid numbered badges are the only ornament. Implication and inference panels use a light-grey surface with a thin accent bar on the left edge; nothing else is boxed, shaded, or filled. Avoid cards, gradients, glows, decorative icons, and filled callouts.
- **Color Behavior**: One deep dominant carries titles, badges, primary series, and axes; one saturated accent marks the point of each exhibit (the emphasized bar, the priority tier, the highlighted figure, the implication bar); a light tint of the accent carries the secondary series; a grey ramp carries context, category labels, unit lines, and footnotes; one negative colour is reserved for declines and risk triggers. Tints of one family order series; never rainbow-code. Any confirmed Brand or Deck identity replaces these tendencies; never imply a consultancy-specific or trademarked palette.
- **Typography Character**: One compact sans-serif family for all editable text, with a lining-figure companion for numerals in charts, tables, KPI strips, and badges. Hierarchy comes from weight, size steps, letter-spaced small labels for trackers and column headers, and alignment — never from containers. Exact families and locale coverage remain current-project or resolved identity decisions.
### Fallback Color Scheme
| Role | HEX | Purpose |
|---|---|---|
| Field | #FFFFFF | Page field on every page except the cover |
| Dominant | #051C2C | Titles, badges, primary series, axes, and the cover field |
| Accent | #2251FF | The point of each exhibit: emphasized bar, priority tier, highlighted figure, implication bar |
| Accent tint | #8FB8FF | Secondary series and secondary text on the cover |
| Body | #333333 | Body copy, key messages, and table text |
| Muted | #7F7F7F | Category labels, unit lines, trackers, and footnotes |
| Hairline | #B7B7B7 | Axes and dividers |
| Grid | #E3E3E3 | Table row separators and dashed connectors |
| Surface | #F7F7F7 | Implication and inference panels, alternating lanes |
| Negative | #B3261E | Declines, negative deltas, and risk triggers |
### Fallback Typography
| Role | Primary | Fallback Tail | Character |
|---|---|---|---|
| Titles and body | Microsoft YaHei | Arial | Compact neutral sans; bold for action titles, exhibit titles, and badges |
| Numerals and data | Arial | Helvetica | Lining figures for chart labels, tables, KPI strips, page numbers |
## VI. Image & Icon Direction
- **Preferred Image Rendering**: minimalist-swiss
- **Image Usage**: Use images only when they provide evidence, necessary context, or a causal explanation that shapes the decision. Default to sparse imagery; keep data and business structures editable instead of replacing them with decorative illustrations.
- **Image Treatment**: Crop for the evidentiary subject, use restrained framing or a functional scrim, and retain a nearby caption or source when the image supports a claim. Avoid gratuitous full bleed, synthetic text inside images, and atmospheric imagery that weakens the argument.
- **Icon Treatment**: Use one coherent icon family with consistent stroke or fill treatment, only when an icon clarifies a role, state, or relationship. Avoid logos, consultancy-specific marks, decorative icon grids, and mixed visual languages; actual icon selection remains a project decision.
## VII. Review Focus
<!-- visual-review-trigger: explicit-user-only -->
> Apply this section only after the user explicitly activates visual review. It never triggers that stage.
- The intended answer is identifiable quickly at the rendered slide size.
- The governing question is actually answered rather than repeated as a topic.
- Visible evidence supports the message and is spatially connected to it.
- Facts, assumptions, implications, and recommendations remain distinguishable and semantically consistent.
- Direct labels, sources, hierarchy, and annotations remain legible at the rendered slide size.
- Every content page shows five to seven modules: numbered exhibits with unit lines, a key-message column with an implication panel, numbered footnotes, and a source line.
- Dense pages retain one clear scan path and do not hide overflow or structural ambiguity.
- No unsupported claim is presented as established evidence; unresolved gaps remain visible.
@@ -49,6 +49,16 @@
"investor" "investor"
] ]
}, },
"mbb-consulting": {
"summary": "Strategy-document method in the MBB consulting convention — answer-first argument at document density with numbered exhibits, a key-message column, footnotes, and action titles on every page.",
"keywords": [
"consulting",
"strategy-document",
"MBB-style",
"document-density",
"exhibits"
]
},
"narrative-keynote": { "narrative-keynote": {
"summary": "Story-driven keynote method that earns one idea through tension, turn, and concrete human detail.", "summary": "Story-driven keynote method that earns one idea through tension, turn, and concrete human detail.",
"keywords": [ "keywords": [
@@ -68,6 +68,12 @@ Validate each normalized root once. The effective structural owner is Layout whe
| `layout` | Reusable structure, precedence over Deck; Default plans against its prototypes, Quick reads the roster and authors its Master/Layout/slot contract directly | | `layout` | Reusable structure, precedence over Deck; Default plans against its prototypes, Quick reads the roster and authors its Master/Layout/slot contract directly |
| `deck` | Descriptive application context and identity; structure and prototype roster only when no Layout is selected | | `deck` | Descriptive application context and identity; structure and prototype roster only when no Layout is selected |
**Command**: the mapping, provenance line, asset copy, collision and duplicate-kind refusal, and completion receipt below are one run of the install tool (add `--dry-run` to review the mapping first; `--skip-validation` only when the §2 checker already ran on that root in this turn); [`template-tools.md`](../../scripts/docs/template-tools.md#apply_templatepy) owns its behavior:
```bash
python3 skills/ppt-master/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...]
```
**Atomic install preflight**: resolve every source and destination path; enumerate the union mapping across all roots and across `templates/`, `images/`, `icons/`, mapping each source file at most once; resolve Layout-over-Deck precedence before building the map so the shadowed roster never enters it; reject every destination collision and duplicate kind before writing; write the accepted mapping once — never recursive copy as an implicit conflict policy. An input equal to the target project is consumed in place; if a selected Layout supersedes its in-place Deck roster, stage the mapping and replace the roster atomically. **Atomic install preflight**: resolve every source and destination path; enumerate the union mapping across all roots and across `templates/`, `images/`, `icons/`, mapping each source file at most once; resolve Layout-over-Deck precedence before building the map so the shadowed roster never enters it; reject every destination collision and duplicate kind before writing; write the accepted mapping once — never recursive copy as an implicit conflict policy. An input equal to the target project is consumed in place; if a selected Layout supersedes its in-place Deck roster, stage the mapping and replace the roster atomically.
**Hard rule — project-local consumer boundary**: after installation, Default final Stage 2, Quick's agent before authoring, and every later role read only `<project_path>/templates/` and the project-local `images/` / `icons/` pools; the library or external root is installation input only. **Hard rule — project-local consumer boundary**: after installation, Default final Stage 2, Quick's agent before authoring, and every later role read only `<project_path>/templates/` and the project-local `images/` / `icons/` pools; the library or external root is installation input only.
@@ -63,13 +63,13 @@ Unless an all-motion disable bypasses it, validate an existing sidecar first: `p
| The same object continues across adjacent Morph pages | Isolate each endpoint as one direct-root `<g>` of compatible kinds; a root primitive carrying a static role (`decoration`, `background`) cannot be paired — wrap it as a group first | | The same object continues across adjacent Morph pages | Isolate each endpoint as one direct-root `<g>` of compatible kinds; a root primitive carrying a static role (`decoration`, `background`) cannot be paired — wrap it as a group first |
| Two narrated stages interlock geometrically (a fitted preset joint, a deliberate overlap) | Keep them one group — sibling groups cannot hold disjoint bounds — and let one directional effect travel the reading path | | Two narrated stages interlock geometrically (a fitted preset joint, a deliberate overlap) | Keep them one group — sibling groups cannot hold disjoint bounds — and let one directional effect travel the reading path |
| Several atoms express one inseparable idea | Keep them together | | Several atoms express one inseparable idea | Keep them together |
| Page chrome, structural layers, static framing | Preserve and exclude from ordinary targets | | Page chrome, structural layers, static framing | Preserve; use [`animations.md`](../../references/animations.md) §5 for target eligibility |
**Hard rule — visual equivalence**: regrouping changes object boundaries only — preserve every visible pixel, paint order, coordinate, transform, inherited paint, opacity, clip, filter, reference, and native metadata; keep rendering-bearing wrappers nested when flattening could change appearance. **Hard rule — structural boundary**: never split or merge across `data-pptx-layer`, `data-pptx-placeholder`, native chart/table carrier, native preset, or imported logical-object boundaries; structural/static objects stay non-animatable; ordinary direct-root groups follow [`shared-standards-core.md`](../../references/shared-standards-core.md) §4.3 (descriptive unique `id`, positive root-coordinate `data-pptx-bounds`, no bounds on nested groups). **Hard rule — visual equivalence**: regrouping changes object boundaries only — preserve every visible pixel, paint order, coordinate, transform, inherited paint, opacity, clip, filter, reference, and native metadata; keep rendering-bearing wrappers nested when flattening could change appearance. **Hard rule — structural boundary**: never split or merge across `data-pptx-layer`, `data-pptx-placeholder`, native chart/table carrier, native preset, or imported logical-object boundaries; ordinary direct-root groups follow [`shared-standards-core.md`](../../references/shared-standards-core.md) §4.3 (descriptive unique `id`, positive root-coordinate `data-pptx-bounds`, no bounds on nested groups).
**Forbidden — group-list-first choreography**: choosing effects or order from `list-groups` before the audit; keeping a coarse wrapper because it has an `id`; splitting one idea into shapes or lines to raise the count; merging unrelated ideas to lower it; adding animation `data-*` attributes to SVG. There is no target group count. **Forbidden — group-list-first choreography**: choosing effects or order from `list-groups` before the audit; keeping a coarse wrapper because it has an `id`; splitting one idea into shapes or lines to raise the count; merging unrelated ideas to lower it; adding animation `data-*` attributes to SVG. There is no target group count.
After any regrouping, rerun the final gate (`svg_quality_checker.py <project_path> --canonical-authoring --stage final --json`; Quick inserts `--quick-generate` before `--stage`), then list the post-regroup anchors with `animation_config.py list-groups <project_path>` (one line per slide, chrome groups excluded and named at the end of the line — an id equal to, or holding a `-`/`_` token from, `bg` / `background` / `header` / `footer` / `decor` / `decoration(s)` / `chrome` / `nav` / `watermark` / `logo` / `pagenumber` / `pagenum` / `slidenumber` / `slidenum` / `rule`, so `takeaway-rule` is chrome by name; a `*-header` or `*-footer` group that holds the page's largest text is the title block, not chrome, and stays listed). That list is the only source of slide and group keys for §3–§4. An explicit sidecar entry overrides only the marker-free legacy id-name heuristic; a `data-pptx-layer` group never animates; a static role/placeholder group animates only when a sidecar entry names it. If a starting file is useful, `animation_config.py scaffold <project_path>` after regrouping creates a neutral scaffold (default object effect `none`, groups as empty `{}` placeholders) — creating it selects nothing, and it need not be read in full. After any regrouping, rerun the final gate (`svg_quality_checker.py <project_path> --canonical-authoring --stage final --json`; Quick inserts `--quick-generate` before `--stage`), then list the post-regroup anchors with `animation_config.py list-groups <project_path>` (one line per slide, chrome groups excluded and named at the end of the line — an id equal to, or holding a `-`/`_` token from, `bg` / `background` / `header` / `footer` / `decor` / `decoration(s)` / `chrome` / `nav` / `watermark` / `logo` / `pagenumber` / `pagenum` / `slidenumber` / `slidenum` / `rule`, so `takeaway-rule` is chrome by name; a `*-header` or `*-footer` group that holds the page's largest text is the title block, not chrome, and stays listed). That list is the only source of slide and group keys for §3–§4. For chrome defaults, explicit sidecar overrides, and structural exclusions, see [`animations.md`](../../references/animations.md) §5. If a starting file is useful, `animation_config.py scaffold <project_path>` after regrouping creates a neutral scaffold (default object effect `none`, groups as empty `{}` placeholders) — creating it selects nothing, and it need not be read in full.
--- ---
@@ -77,7 +77,7 @@ After any regrouping, rerun the final gate (`svg_quality_checker.py <project_pat
**Mandatory**: plan the requested layers for each affected slide before editing — page transition (`defaults.transition` / `slides.<slide>.transition`), deterministic Morph pair (`slides.<destination>.morph`), page animation defaults (`defaults.animation` / `slides.<slide>.animation`), object lifecycle (`slides.<slide>.groups.<group_id>` as one legacy row or an ordered `effects[]`). A local object request needs no deck-wide transition review. **Mandatory**: plan the requested layers for each affected slide before editing — page transition (`defaults.transition` / `slides.<slide>.transition`), deterministic Morph pair (`slides.<destination>.morph`), page animation defaults (`defaults.animation` / `slides.<slide>.animation`), object lifecycle (`slides.<slide>.groups.<group_id>` as one legacy row or an ordered `effects[]`). A local object request needs no deck-wide transition review.
**Per-affected-page motion brief**: classify the communication job (including none) and each unit's lifecycle — the page's lead carrier (the showcase image, the hero figure, the chart) enters with intent, framing elements (lettering, slug, numeral) behave the same way on every page of the deck, and supporting pieces (small doodles, arrows, ornaments) stay still; choose only the required transition, effect, order, timing, and one dominant Start rhythm; order follows the reading path — the slug, numeral, or headline that frames a page enters before the units it frames, and an unlisted group takes the position of the nearest listed group above it ([`pptx-animations.md`](../../scripts/docs/pptx-animations.md) §8), so number only what departs from that; mix modes or add emphasis/exit only for a distinct job with a restrained effect. **Mandatory — select from meaning, not catalog coverage**: run the page-relationship and lifecycle playbooks in `animations.md` §3–§4 before any specific effect; candidates are recall aids. **Title motion**: classify the lifecycle, then choose immediate, delayed, synchronized, post-hero, or narration-cued timing; use the sidecar override for a marker-free chrome-like id and repair an incorrect structural marker before animating it. **Default — inherit unaffected layers (may override when the page's job requires it)**: leave the transition and untouched pages on defaults; add a slide-specific `transition` only when the page needs one. **Timing**: shorter for dense/repeated scan content, longer for pivots, hero diagrams, section boundaries, and takeaways; uniform timing is valid. **Reference — motion judgment**: decide job, lifecycle, tone, audience order, and whether direction carries meaning before geometry; a unit that gains no clarity or feeling is `static` (`none`, `entrance_appear`, or `entrance_fade` only when that matches the lifecycle); layout direction alone requires no motion; variation follows content or tone, never quota. **Per-affected-page motion brief**: classify the communication job (including none) and each unit's lifecycle — the page's lead carrier (the showcase image, the hero figure, the chart) enters with intent, framing elements (lettering, slug, numeral) behave the same way on every page of the deck, and supporting pieces (small doodles, arrows, ornaments) stay still; choose only the required transition, effect, order, timing, and one dominant Start rhythm; order follows the reading path — the slug, numeral, or headline that frames a page enters before the units it frames, and an unlisted group takes the position of the nearest listed group above it ([`pptx-animations.md`](../../scripts/docs/pptx-animations.md) §8), so number only what departs from that; mix modes or add emphasis/exit only for a distinct job with a restrained effect. **Mandatory — select from meaning, not catalog coverage**: run the page-relationship and lifecycle playbooks in `animations.md` §3–§4 before any specific effect; candidates are recall aids. **Title motion**: classify the lifecycle, then choose immediate, delayed, synchronized, post-hero, or narration-cued timing; use the sidecar override under [`animations.md`](../../references/animations.md) §5 and repair an incorrect structural marker before animating it. **Default — inherit unaffected layers (may override when the page's job requires it)**: leave the transition and untouched pages on defaults; add a slide-specific `transition` only when the page needs one. **Timing**: shorter for dense/repeated scan content, longer for pivots, hero diagrams, section boundaries, and takeaways; uniform timing is valid. **Reference — motion judgment**: decide job, lifecycle, tone, audience order, and whether direction carries meaning before geometry; a unit that gains no clarity or feeling is `static` (`none`, `entrance_appear`, or `entrance_fade` only when that matches the lifecycle); layout direction alone requires no motion; variation follows content or tone, never quota.
### 3.1 Supported Page Transitions ### 3.1 Supported Page Transitions
@@ -95,7 +95,7 @@ Only after visual transition, lifecycle, effect, order, and timing are complete,
## 4. Edit `animations.json` ## 4. Edit `animations.json`
**Hard rule — sparse overrides reference real targets**: write only affected slides and only fields that differ from exporter or sidecar defaults; an unlisted SVG inherits deck-wide settings; a listed slide carries only the `transition`, `animation`, `groups`, or `morph` fields it overrides; `defaults` is optional and deck-wide only; chrome groups stay out (the exporter pins them to `none`), and a legacy chrome-like id is named only on explicit reviewed intent with no structural marker. **Forbidden**: a slide absent from `svg_output/`; a missing, ambiguous, or structural group; enumerating every group to restate the slide default; listing a group with `data-pptx-layer` or a static role/placeholder marker; animation `data-*` attributes in SVG. **Hard rule — sparse overrides reference real targets**: write only affected slides and only fields that differ from exporter or sidecar defaults; an unlisted SVG inherits deck-wide settings; a listed slide carries only the `transition`, `animation`, `groups`, or `morph` fields it overrides; `defaults` is optional and deck-wide only; target eligibility follows [`animations.md`](../../references/animations.md) §5. **Forbidden**: a slide absent from `svg_output/`; a missing, ambiguous, or structural group; enumerating every group to restate the slide default; animation `data-*` attributes in SVG.
**Hard rule — one group representation**: a populated `groups.<id>` uses either the legacy single-effect fields or `effects[]`, never both; an untouched scaffold `{}` is neutral; omitted row values inherit the resolved slide values. Every field, its range, and its inheritance are [`pptx-animations.md`](../../scripts/docs/pptx-animations.md) §8; run `pptx_animations.py --describe <canonical_effect>` before writing a parameterized effect, and give Change Font one target-installed face, never a CSS stack. Use the multi-category `effects[]` example in `animations.md` §2 and the two-slide Morph example in §2.1 (never copy the source group into the destination `groups` to establish identity); keep the legacy object for one-row overrides and never convert old sidecars mechanically. **Hard rule — one group representation**: a populated `groups.<id>` uses either the legacy single-effect fields or `effects[]`, never both; an untouched scaffold `{}` is neutral; omitted row values inherit the resolved slide values. Every field, its range, and its inheritance are [`pptx-animations.md`](../../scripts/docs/pptx-animations.md) §8; run `pptx_animations.py --describe <canonical_effect>` before writing a parameterized effect, and give Change Font one target-installed face, never a CSS stack. Use the multi-category `effects[]` example in `animations.md` §2 and the two-slide Morph example in §2.1 (never copy the source group into the destination `groups` to establish identity); keep the legacy object for one-row overrides and never convert old sidecars mechanically.