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-07-13 00:01:37 +08:00
parent 1682603705
commit e3dd129116
223 changed files with 38912 additions and 3017 deletions
+12 -12
View File
@@ -280,18 +280,6 @@
},
"category": "设计"
},
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "ui-ux-pro-max",
"source": {
@@ -316,6 +304,18 @@
},
"category": "开发工具"
},
{
"name": "oh-my-codex",
"source": {
"source": "local",
"path": "./plugins/oh-my-codex"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "ppt-master",
"source": {
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-07-11T16:01:50Z"
"syncedAt": "2026-07-12T16:01:37Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "1bd2fd585aac793ca2589e6f18f17a412fd11005",
"commit": "31541356c21bc389150b958379acb26aa7e97b22",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-07-11T15:59:59Z"
"syncedAt": "2026-07-12T16:00:00Z"
}
@@ -1,6 +1,6 @@
{
"name": "oh-my-codex",
"version": "0.20.0",
"version": "0.20.1",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": {
"name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"commit": "5d43a5bf6f008de17f9425bee4495c457c60b96a",
"commit": "fce27bfd6c17c7665a6f1505b6b8384cc2c8edd5",
"adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-07-10T16:00:00Z"
"syncedAt": "2026-07-12T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "dbf1e363abb4dba36f7c883ee7ba1495073394d0",
"commit": "8f76b6fcef5f24c8716894adeb6fe24c5a418bc3",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-11T15:59:59Z"
"syncedAt": "2026-07-12T16:00:00Z"
}
@@ -7,7 +7,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
> AI-driven multi-format SVG content generation system. Converts source documents into high-quality SVG pages through multi-role collaboration and exports to PPTX.
**Core Pipeline**: `Source Document → Create Project → [Template] → Strategist → [Image_Generator] → Executor Live Preview → Quality Check → Post-processing → Export`
**Core Pipeline**: `Source Document → Create Project → [Template] → Strategist Structured Plan → [Image_Generator] → Executor Live Preview → Quality Check → Post-processing → Export`
### SVG Page-Design Boundary
@@ -34,8 +34,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
> 5. **NO SPECULATIVE EXECUTION** — "Pre-preparing" content for subsequent Steps is FORBIDDEN (e.g., writing SVG code during the Strategist phase)
> 6. **NO SUB-AGENT SVG GENERATION** — Executor Step 6 SVG generation is context-dependent and MUST be completed by the current main agent end-to-end. Delegating page SVG generation to sub-agents is FORBIDDEN
> 7. **SEQUENTIAL PAGE GENERATION ONLY** — In Executor Step 6, after the global design context is confirmed, SVG pages MUST be generated sequentially page by page in one continuous pass. Grouped page batches (for example, 5 pages at a time) are FORBIDDEN
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the confirmed `pptx_structure.template_adherence` when present, the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit), `page_charts` (which chart template to adapt, if any), and — when `pptx_structure.mode` is `template` or `preserve` — `pptx_layouts` (which native PowerPoint layout the page references). Deck/layout template routes require both mappings for every page: `strict` reuses the selected layout contract; `adaptive` may create a new explicit layout contract from the selected reference. Empty / absent `page_layouts` rows are reserved for free design and brand-only routes — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the complete `pptx_masters` / `pptx_layouts` structure, `pptx_structure.template_adherence` when present, the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (the input template SVG), and `page_charts`. Master/Layout structure exists before drawing; export never infers it later. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce. **Narrow exception**: `preset_shape_svg.py` may print one deterministic stock-shape fragment to stdout after the main agent has selected its semantic role, frame, and paint. It cannot write `svg_output/`, choose layout, batch shapes, or generate a page; the main agent reads the fragment and inserts it through the normal hand-authored page edit
> 10. **FOLLOW DETERMINISTIC ROUTING RULES** — Do not add blocking routing questions when this skill defines a route. If the user request violates a route precondition, state the required prerequisite and stop that route instead of asking the user to choose around the rule. Ordinary finite options, stylistic preferences, and recoverable details are surfaced with a recommended value plus alternatives at the next existing confirmation gate.
> [!IMPORTANT]
@@ -86,7 +86,9 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
| `${SKILL_DIR}/scripts/latex_render.py` | LaTeX formula rendering (manifest-driven PNG assets) |
| `${SKILL_DIR}/scripts/image_gen.py` | AI image generation (multi-provider) |
| `${SKILL_DIR}/scripts/slice_images.py` | Slice one AI illustration sheet into individual spot-illustration elements |
| `${SKILL_DIR}/scripts/svg_authoring_view.py` | Create a lightweight non-destructive inspection projection of PPTX-imported SVGs; never a release source |
| `${SKILL_DIR}/scripts/svg_quality_checker.py` | SVG quality check |
| `${SKILL_DIR}/scripts/preset_shape_svg.py` | Print one registry-backed native PowerPoint preset fragment to stdout for hand-authored SVG insertion |
| `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting |
| `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) |
| `${SKILL_DIR}/scripts/svg_to_pptx.py` | Export to PPTX |
@@ -120,13 +122,31 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
| Raw PPTX template plus new material/topic, generate a PPTX | [`template-fill-pptx`](workflows/template-fill-pptx.md) |
| Existing PPTX, preserve page count/order and slide wording 1:1, improve layout | [`beautify-pptx`](workflows/beautify-pptx.md) |
| Existing PPTX as source material, rethink outline or change page count/order | Main pipeline via `source_to_md.py` plus PPTX intake |
| Build a reusable template package from a PPTX/design reference | [`create-template`](workflows/create-template.md), then return with the generated template directory path |
| Build a reusable template package from a PPTX/design reference | [`create-template`](workflows/create-template.md), then return with the generated template workspace path |
| Finished PPTX, keep content/layout stable and add notes/audio/timing/transitions | [`native-enhance-pptx`](workflows/native-enhance-pptx.md) |
**MUST**: Raw `.pptx` template plus "generate PPTX" routes to `template-fill-pptx` by default. The SVG generation route consumes only an explicit template directory path that already contains a valid template `design_spec.md`.
**MUST**: Raw `.pptx` template plus "generate PPTX" routes to `template-fill-pptx` by default. The SVG generation route consumes only an explicit template workspace path with a valid `templates/design_spec.md`, or a supported direct/legacy package root with `design_spec.md`.
**MUST**: Beautify is strictly 1:1. Any split, merge, drop, reorder, or page-count change routes to the main pipeline.
**MUST — reusable template mode boundary**: `create-template` has two distinct
contracts. `standard` and `fidelity` author new SVG prototypes and their own
Master/Layout/slot system; source visuals and assets are references, and source
Master/Layout topology is neither preserved nor distilled. `mirror` is a
restoration path: preserve the source slide roster/order, visual appearance,
Master/Layout parentage and identities, placeholder type/index/bounds, native
object ownership, and supported native-shape metadata. The lossless import is
the restoration authority; the lightweight authoring projection exists only to
keep model context small. Mechanical normalization may express the source facts
in the current explicit SVG contract and expand fixed-layer group wrappers into
direct atoms, but it MUST NOT merge, split, promote, demote, rename, or
re-parent source structure. Export compiles the selected contract and never
infers a different one. Because the current structured roster materializes only
identities referenced by emitted page prototypes, `mirror` is available only
when every source Layout is used by a source Slide and every source Master is
reachable through those Layouts. Otherwise stop and report the exact
unreachable identities; never drop them or invent a carrier page.
**FALLBACK**: Ambiguous requests such as "make this PPT more professional" require exactly one discriminator question: preserve original page count/order and slide wording, or treat the deck as source material and restructure it?
---
@@ -214,20 +234,22 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project
**Default — free design.** Proceed directly to Step 4. Do NOT query any `*_index.json` unless triggered. Do NOT ask the user. Do NOT proactively suggest, hint at, or fuzzy-match any template based on content, slug-like words, or vague style descriptions.
**Hard boundary — raw PPTX template references are not Step 3 templates.** PPTX-as-source remains valid in Step 1 / Step 2, and raw PPTX template + generated PPTX routes to `template-fill`. But if the user wants the SVG/template-based generation route from that PPTX, stop before Step 3. The user must first run [`workflows/create-template.md`](workflows/create-template.md), then return with the generated template directory path. Step 3 only consumes an explicit template directory that already contains `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck`.
**Hard boundary — raw PPTX template references are not Step 3 templates.** PPTX-as-source remains valid in Step 1 / Step 2, and raw PPTX template + generated PPTX routes to `template-fill`. But if the user wants the SVG/template-based generation route from that PPTX, stop before Step 3. The user must first run [`workflows/create-template.md`](workflows/create-template.md), then return with the generated template workspace path. Step 3 consumes an explicit workspace whose `templates/design_spec.md` declares `kind: brand` / `kind: layout` / `kind: deck`, or a compatible legacy-flat package whose root `design_spec.md` declares one of those kinds.
Do **not** reinterpret this boundary as 1:1 redesign or free SVG generation. Use `template-fill` for raw PPTX template + generated PPTX requests; use `beautify` only when the source deck's page count, order, and wording are preserved.
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message, plus one narrow workflow handoff: a project-scoped `create-template` run in the current conversation may pass its exact validated `<project>/templates/` output directly into this Step. The trigger rule is mechanical, not interpretive:
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message, plus one narrow workflow handoff: a project-scoped `create-template` run in the current conversation may pass its exact validated project workspace root directly into this Step. The trigger rule is mechanical, not interpretive:
| User input contains | Step 3 action |
|---|---|
| One or more explicit template directory paths (each resolves to a directory containing `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck` in its YAML frontmatter) | Read each spec's `kind`, dispatch per the kind matrix below, fuse if multiple |
| Current `create-template` workflow just completed project scope and validated its exact `<project>/templates/` output | Consume that single directory in place; it cannot join multi-path fusion |
| One or more explicit template workspace paths (each resolves to `templates/design_spec.md`, or to a compatible legacy-flat root `design_spec.md`, with `kind: brand` / `kind: layout` / `kind: deck` in YAML frontmatter) | Normalize each source directory, read its `kind`, dispatch per the kind matrix below, fuse if multiple |
| Current `create-template` workflow just completed project scope and validated its exact `<project>/` workspace | Consume that single workspace in place; it cannot join multi-path fusion |
| Anything else — bare template names ("用 academic_defense"), style descriptions ("麦肯锡风格"), brand mentions ("招商银行风格"), vague intent ("想用个模板"), or silence | Skip Step 3, free design |
There is no slug matching, no name lookup, no fuzzy resolution. A name without a path does not trigger — the user must give a path the AI can `cd` into.
**Structured-template preflight (before copy)**: For every deck/layout workspace, inspect all SVG roots and slots under its normalized template source. Every page must declare root Master/Layout key and picker names; Master/Layout visuals must be direct atoms rather than `<g>`; every slot must be a top-level `<g>` with positive bounds and exactly one compatible carrier, or an explicit composite `object` proxy. A zero-slot Layout is valid. If the SVG package uses a legacy semantic contract, run [`restore-pptx-structure`](workflows/restore-pptx-structure.md) first and return to Step 3 with the migrated workspace. A legacy flat directory shape alone is read compatibility and does not trigger restoration.
> Style descriptions ("麦肯锡风格" / "Keynote 风" / "极简风" / etc.) never trigger Step 3. They flow into the Strategist confirmation stage as a style brief (color / typography / tone in fields eg).
> Bare names ("academic_defense", "招商银行", "anthropic") do NOT trigger Step 3 even if a matching directory exists in the library. The user must give a path. AI must not "helpfully" resolve a name to a path.
@@ -242,9 +264,9 @@ The architecture has three independent reference bundles. Full schema in [`docs/
| Kind | Physical dir | Contains | Frontmatter |
|---|---|---|---|
| **brand** | `templates/brands/<id>/` | identity-only segment: color / typography / logo / voice / icon style | `kind: brand` |
| **layout** | `templates/layouts/<id>/` | structure-only segment: canvas / page structure / page types / SVG roster | `kind: layout` |
| **deck** | `templates/decks/<id>/` | full replica: identity + structure + middle (template overview) segments | `kind: deck` |
| **brand** | `templates/brands/<id>/templates/` inside a complete workspace | identity-only segment: color / typography / logo / voice / icon style | `kind: brand` |
| **layout** | `templates/layouts/<id>/templates/` inside a complete workspace | structure-only segment: canvas / page structure / page types / SVG roster | `kind: layout` |
| **deck** | `templates/decks/<id>/templates/` inside a complete workspace | full identity + structure reference with the middle (template overview) segment | `kind: deck` |
**Segment ownership** (governs fusion override priority):
@@ -258,48 +280,30 @@ The architecture has three independent reference bundles. Full schema in [`docs/
| User path's `kind` | Step 3 action |
|---|---|
| `kind: brand` | `design_spec.md` + non-image assets → `<project>/templates/`; logo / illustration / icon **bitmaps**`<project>/images/`. Strategist locks identity segment as truth; structure stays free. |
| `kind: layout` | `design_spec.md` + SVG roster → `<project>/templates/`; any **bitmap** assets → `<project>/images/`. Strategist locks structure; identity decided in Strategist confirmation stage eg. |
| `kind: deck` | `design_spec.md` + template SVGs → `<project>/templates/`; logos / backgrounds / other **bitmaps**`<project>/images/`. Strategist locks all segments; Strategist confirmation stage narrows to deck-content fields (audience / page count / outline / tone tweaks). |
| `kind: brand` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks identity; structure stays free. |
| `kind: layout` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks structure; identity is decided in confirmation fields eg. |
| `kind: deck` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks all segments; confirmation narrows to deck-content fields. |
```bash
TEMPLATE_DIR="<user-supplied path>"
PROJECT_TEMPLATES="<project_path>/templates"
resolve_path() {
python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$1"
}
# Bitmaps join the project's single runtime image pool (images/, referenced as
# ../images/); the spec + template SVGs + other non-image assets stay in
# templates/ as design reference the Strategist/Executor read but never render.
if [ "$(resolve_path "${TEMPLATE_DIR}")" = "$(resolve_path "${PROJECT_TEMPLATES}")" ]; then
# Project-scoped create-template output is already staged in place.
true
else
cp -r "${TEMPLATE_DIR}"/. "${PROJECT_TEMPLATES}/"
find "${PROJECT_TEMPLATES}" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.bmp' \) -exec mv {} "<project_path>/images/" \;
fi
```
Normalize every explicit path before any write:
The same split applies to all three kinds — bitmaps always land in `images/`, the rest in `templates/`. The spec's `kind` field tells Strategist how to read the `templates/` side; downstream code doesn't distinguish. (Template SVGs in `templates/` are reference material only — the rendered pages live in `svg_output/` and reference images via `../images/`.)
| Input shape | Spec / SVG source | Asset source | Install rule |
|---|---|---|---|
| Current workspace: `<root>/templates/design_spec.md` | `<root>/templates/` | Any existing `<root>/images/`, `<root>/icons/` | Map the existing portable roots to the target project's matching roots; ignore `<root>/exports/` |
| Compatible legacy-flat package: `<root>/design_spec.md` | `<root>/` | Package-local files | SVG/spec/non-bitmaps → project `templates/`; bitmaps → project `images/`; route declared icons to project `icons/` |
When `create-template` used project output scope, its directory is already the
target project's `templates/` root and its bitmap/icon runtime copies are already
in their final project pools. Resolve both paths before copying: equality means
**in-place consumption**, so skip both the copy and bitmap move. An in-place
directory is one complete bundle and cannot participate in multi-path fusion;
use external library bundles for fusion. Never place the local bundle under a
nested `templates/local_master/` directory because the confirmation and quality
gates read the project `templates/` root.
**Atomic install preflight (mandatory)**: Resolve source and destination paths, enumerate the complete file mapping, and reject every destination collision before copying any file. Equality between a current project workspace root and the target project root means in-place consumption and no copy. For an external single path, a collision stops Step 3 rather than overwriting. For multi-path fusion, do not copy packages sequentially: resolve segment conflicts and asset-name conflicts first, construct one final mapping, then write it once. Never use recursive copy as an implicit conflict policy.
A project-scoped bundle belongs only to its target project: its SVGs may refer
to sibling `../images/` and runtime `../icons/` pools that are outside the
template root. Do not copy another project's `templates/` root as an external
bundle. For cross-project reuse, recreate/promote the design with
`create-template` library scope so the package is self-contained.
Never infer that a flat directory has legacy Master/Layout semantics solely from packaging.
Legacy template packages may ship `native_structure.json` + `source_template.pptx`; keep the pair together in `<project>/templates/` for backward-compatible `preserve` export. Current `create-template` output does not package this pair: it rebuilds complete, explicitly layered SVG templates instead. During fusion, never mix one legacy contract with another template's source package.
The same current-workspace routing applies to all three kinds: source/spec in `templates/`, visual assets in `images/`, runtime icons in `icons/`, and on-demand review artifacts in `exports/`. Empty optional roots are omitted rather than retained with placeholder files, so a normal workspace has no `exports/` until a review file is explicitly generated. The spec's `kind` tells Strategist how to read the installed source. Template SVGs are not export-time overlays: visible output still lives completely in `svg_output/`. Their complete visuals and explicit Master/Layout/placeholder metadata are nevertheless the authoring prototypes selected by `page_layouts`.
The Strategist confirmation stage decides whether the selected template is used `strict` or `adaptive`. Both use `pptx_structure.mode: template` for current templates and map every page to one reference SVG plus one output PowerPoint layout. `strict` keeps the referenced layout contract unchanged; `adaptive` may create a new layout key while keeping the template Master contract. Legacy `preserve` remains strict-only compatibility behavior.
When `create-template` used project output scope, its workspace root is the target project itself and all core directories are already final. Resolve both roots before copying: equality means **in-place consumption**, so skip the installation. An in-place workspace cannot participate in multi-path fusion; use external workspaces for fusion. Never place the local source under a nested `templates/local_master/` directory because the confirmation and quality gates read the project `templates/` root.
A project-scoped workspace has the same portable routing as a library workspace. It may be copied or promoted across roots as one unit (`templates/` plus any existing `images/` and `icons/`); `exports/` stays review-only. Do not pass only another project's `templates/` subdirectory because that would omit sibling assets.
Legacy template packages may ship `native_structure.json` + `source_template.pptx`, omit root Master identity, use direct atomic placeholders, or carry old baseline/distillation metadata. Do not copy or consume those semantic contracts through Step 3. Run [`restore-pptx-structure`](workflows/restore-pptx-structure.md) on the package first, then return with the migrated workspace path. Old flat packaging remains readable when its SVG structure is already current.
The Strategist confirmation stage decides whether the selected template is used `strict` or `adaptive`. New projects use `pptx_structure.mode: structured`, map every page to one input SVG in `page_layouts`, and write complete `pptx_masters` / `pptx_layouts` output mappings before SVG generation. Strict preserves the template's declared Master/Layout/slot contract. Adaptive keeps the template Master and may assign a new Layout key during authoring when the composition genuinely changes. Non-mirror paint and typography follow the project skin rules.
#### Multi-path fusion
@@ -411,16 +415,16 @@ Steps:
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
```
Page opens at the launch-log URL such as `http://127.0.0.1:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Stage 1: it shows a "deriving…" state and polls for Stage 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** for a fresh `status: stage1-confirmed` before dropping to the chat fallback. **On success (exit 0 with a stage-1 result), do not pause or report — go straight to step 3 in the same turn.**
3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page; if Windows cleaned up the server, `--wait-only` auto-recovers it on the recorded/default port so the browser reconnects:
3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). **Stage 2 is never skipped** — an active deck/layout template (`strict` adherence included) does not exempt it: the template skin becomes the recommended color / typography candidate and real alternatives fill the remaining cards. Never jump `recommendations.json` from `stage1` to `stage3`: the server refuses to render a skipped stage (the page stays on "deriving…") and the waits exit non-zero until the stages are written in order. The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page; if Windows cleaned up the server, `--wait-only` auto-recovers it on the recorded/default port so the browser reconnects:
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2
```
This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat.
This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat — except a `stage skip detected` error, which is not a page failure: you wrote a stage out of order; rewrite `recommendations.json` with the stage the error names and re-attach.
4. **Re-derive Stage 3 from the confirmed anchors + design system, then wait for the final confirmation.** Read the stage-2 `result.json`. Author the image and execution recommendations and **overwrite** `recommendations.json` with `"stage": "stage3"`: `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive); `image_strategy.candidates` as **exactly three non-custom** rendering × palette recommendations from h.5 when `image_usage` includes `ai` (the page adds the fourth Custom card itself); enumerable `image_ai_path` / `generation_mode` and `refine_spec` (recommended `id` / boolean). If the recommendation involves several image sources, keep the source list structured in `recommend.image_usage` and write the usage rationale / page-role guidance into `image_notes` (for example, "封面和章节页用 AI 主视觉,产品页优先用户素材,行业背景页可用网络参考"). Write `image_ai_path` only when `image_usage` includes `ai`. Spot-illustration lean is **not** a candidate field here: it derives from the locked `visual_style`'s illustration propensity and is expressed only in the recommendation rationale / `image_notes`, never as a new confirmation field. Generated-image style palettes are **color behavior only**; final image colors follow the confirmed Stage-2 `color`. Custom image-strategy dimensions are handled by the built-in Custom card, are prose-only, and should not promise a gallery reference image. Then attach to the already-running page; `--wait-only` auto-recovers a dead server as above (same 600000 ms budget):
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
```
This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying Stage 1 + Stage 2 + Stage 3 fields). On a non-zero exit, re-check `result.json` once. Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices.
This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying Stage 1 + Stage 2 + Stage 3 fields). On a non-zero exit, re-check `result.json` once (a `stage skip detected` error means Stage 2 was never confirmed — go back to step 3, not the chat fallback). Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices.
5. **Close the confirm page (Mandatory cleanup — every path).** Shut the server down before leaving Step 4 so it cannot keep holding port 5050 (which Step 6 live preview reuses):
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --shutdown
@@ -593,11 +597,12 @@ Read the execution references for this deck's locked `mode` + `visual_style` (fr
```
Read references/executor-base.md # REQUIRED: common guidelines
Read references/shared-standards.md # REQUIRED: SVG/PPT technical constraints
Read references/native-shape-authoring.md # REQUIRED: stock-shape selection and fragment helper contract
Read references/modes/<locked-mode>.md # narrative skeleton (spec_lock.md `mode`)
Read references/visual-styles/<locked-style>.md # aesthetic (spec_lock.md `visual_style`)
```
> Read executor-base + shared-standards + the one locked mode file + the one locked visual-style file. For `mode: custom` or `visual_style: custom`, skip that preset file and follow `mode_behavior` / `visual_style_behavior` from `spec_lock.md` instead. Never glob `modes/` or `visual-styles/`.
> Read executor-base + shared-standards + native-shape-authoring + the one locked mode file + the one locked visual-style file. For `mode: custom` or `visual_style: custom`, skip that preset file and follow `mode_behavior` / `visual_style_behavior` from `spec_lock.md` instead. Never glob `modes/` or `visual-styles/`.
**Design Parameter Confirmation (Mandatory)**: before the first SVG, output key design parameters from the spec (canvas dimensions, color scheme, font plan, body font size). See executor-base.md §2.
@@ -616,16 +621,26 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
> Image facts: trust the `analysis/image_analysis.csv` regenerated at the end of Step 5. If `images/` changed since (the user swapped or added files), re-run `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` before laying images out — facts are re-derived on use, never a stale store (Step 4 image-facts note).
**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups and the route-specific `pptx_structure` / `pptx_layouts` native-layout contract. The contract is absent only on legacy/free inputs that have not yet been normalized; it is mandatory for every current template page. Resists context-compression drift on long decks. See executor-base.md §2.1.
**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the complete `pptx_masters` / `pptx_layouts` structure and the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups. Resists context-compression drift on long decks. See executor-base.md §2.1.
> ⚠️ **Main-agent only**: SVG generation MUST stay in the current main agent — page design depends on full upstream context. Do NOT delegate to sub-agents.
> ⚠️ **Generation rhythm**: generate pages sequentially, one at a time, in the same continuous context. Do NOT batch (e.g., 5 per group).
**Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `<project_path>/svg_output/`
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities.
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. Before drawing a literal stock shape, apply [`native-shape-authoring.md`](references/native-shape-authoring.md): use the stdout-only helper when one PowerPoint preset exactly matches, keep basic SVG primitives for rect/round-rect/ellipse, and keep free SVG for custom semantics. Never infer a preset from contour similarity.
Every `baseline` / free-design page root MUST declare one canonical `data-pptx-page-role`; `template` / `preserve` pages already declare `data-pptx-layout` and MUST NOT duplicate that identity merely to satisfy this rule. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by `data-pptx-layer`, `data-pptx-placeholder`, or `data-pptx-native`; such an element needs a stable unique `id`. Do not add generic content roles to ordinary titles, body text, cards, KPIs, diagrams, charts, icons, or images. Full contract: [`references/semantic-svg.md`](references/semantic-svg.md).
Template pages MUST start from the complete `page_layouts` SVG, keep all inherited visible objects in `svg_output/`, and preserve the locked root Master/Layout identity plus stable atomic Master/Layout and slot ids. Strict keeps the prototype structure unchanged. Adaptive keeps its Master contract and, when Layout atoms or slot topology/bounds genuinely evolve, assigns a new key/name and updates `spec_lock.md` immediately. Non-mirror fill/stroke/effects/font sizes still follow `spec_lock`.
Free-design and brand-only pages are also structured from the first draft. Use the planned Master roster and page Layout mapping, author Master/Layout fixed visuals only as direct root atoms (never `<g>`), and author reusable content zones as top-level slot groups with positive bounds plus exactly one compatible carrier. Every mapped page MUST mark the standard slots it actually has — `title` / `subtitle` / `body` / `picture` / `slide-number` / `footer` — and its `data-pptx-layer` marks: the deck-wide background and every-page chrome as `master`, this layout key's static framing (including chrome repeated on content pages but absent from the cover) as `layout`, per [`executor-base.md`](references/executor-base.md) §1.2. Repeated chrome stays on its appropriate Master/Layout layer; a per-page heading that should not remain Slide-local is authored as a `title` or `subtitle` slot, never as a fixed Layout atom. Any text or image whose value varies across pages sharing one Layout key MUST be carried by a slot or remain Slide-local. A page shipping only a marked background exports a bare Master and an empty Layout. A Layout may intentionally have zero slots — a stated decision for a fixed composition, never a silent default. Do not add `data-pptx-layout-kind`, `distilled`, `utility`, or a full-page fake slot.
Do not duplicate specialized identity with `data-pptx-role`. Add it only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by `data-pptx-layer`, `data-pptx-placeholder`, or `data-pptx-native`; such an element needs a stable unique `id`. Do not add generic content roles to ordinary titles, body text, cards, KPIs, diagrams, charts, icons, or images. Full contract: [`references/semantic-svg.md`](references/semantic-svg.md).
**First-page gate (Mandatory)** — after the **first** SVG page, before drawing page 2:
```bash
python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>/svg_output/<first_page>.svg
```
Fix every `error` on page 1 first — structural violations are systematic, and a first-page error repeated deck-wide costs a whole-deck rewrite.
**Quality Check Gate (Mandatory)** — after all SVGs, BEFORE annotation handling and speaker notes:
```bash
@@ -633,16 +648,19 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
```
- Any `error` (banned SVG features, viewBox mismatch, spec_lock drift, etc.) MUST be fixed before proceeding — return to Visual Construction, regenerate that page, re-run check.
- `warning` entries (low-res image, non-PPT-safe font tail, etc.): fix when straightforward, otherwise acknowledge and release.
- **PPTX-structure warnings are the exception — never acknowledge-and-release.** For each empty-Layout / framing-only-Layout / bare-Master / duplicate-layout-key warning, output one disposition line: either the fix applied (merge keys in `spec_lock.md pptx_layouts` + SVG roots, mark the missing slots/layers) or why the flagged state is intended (e.g. "P01 cover is a fixed composition, zero-slot by design"). "0 errors" alone does not pass this gate.
- Run against `svg_output/` (not after `finalize_svg.py` — finalize rewrites SVG and masks violations).
**Logic Construction Phase**: generate speaker notes → `<project_path>/notes/total.md`
**✅ Checkpoint — Confirm all SVGs and notes are fully generated and quality-checked. Proceed directly to Step 7 post-processing**:
**✅ Checkpoint — Confirm all SVGs and notes are fully generated and quality-checked. Run the applicable conditional gates below, then proceed to Step 7**:
```markdown
## ✅ Executor Phase Complete
- [x] Live preview started before the first SVG and kept available at the reported URL
- [x] First-page gate run after page 1 (errors fixed before page 2)
- [x] All SVGs generated to svg_output/
- [x] svg_quality_checker.py passed (0 errors)
- [x] PPTX-structure warnings dispositioned one by one (each fixed or stated as intended)
- [x] Speaker notes generated at notes/total.md
```
@@ -687,9 +705,6 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
# Output (default-flow mode):
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output, reads svg_output/)
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
# backup/<timestamp>/templates/{native_structure.json,source_template.pptx}
# ← preserve mode only
# backup/<timestamp>/spec_lock.md ← preserve mode only
# Add --native-objects to emit real editable chart/table objects instead of flattened shapes:
# exports/<project_name>_<timestamp>_native_charts.pptx ← native chart/table objects (data-pptx-native markers)
# Re-export with --recorded-narration audio (generate-audio workflow) embeds per-slide narration:
@@ -711,9 +726,8 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> snapshot in `backup/<timestamp>/` is always written so the project can be
> re-exported from frozen SVG sources without re-running the LLM. The SVG-rendered
> preview remains the mandatory `svg_final/` artifact from Step 7.2; it is not
> packaged as a second PPTX. Preserve mode also backs up the paired native
> structure/source files and `spec_lock.md` under the same timestamp. Use the
> default source selection for release exports. `-s final` is diagnostic-only
> packaged as a second PPTX. Use the default source selection for release
> exports. `-s final` is diagnostic-only
> when comparing conversion behavior against the post-processed SVGs; it does
> not change `svg_output/` ownership or establish a supported release route.
@@ -724,115 +738,53 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> pages remain ordinary SVG pictures unless the user independently accepts the
> results of an unsupported Office conversion.
> **PPTX structure mode** — native export first reads
> `spec_lock.md` `pptx_structure.mode`, then falls back to `baseline` when the
> section is absent. An explicit `--pptx-structure` CLI value overrides the
> lock for diagnostics. Baseline keeps the real slide master/layout
> relationship from the base deck and promotes the strict-majority identical
> native slide background into the slide master (every slide must carry an
> explicit background; minority slides keep their own, which overrides the
> master fill). Baseline may also promote a shared **leading** prefix of
> top-level SVG elements whose explicit `data-pptx-role` marks page chrome
> (`logo`, `footer`, `header`, `watermark`, `chrome`, `page-number`). Exact id
> tokens remain a compatibility fallback only when the role marker is absent.
> Promotion requires identical generated OOXML on a strict majority of slides
> sharing the master, no slide-timing reference, and z-order safety; overlay
> chrome stays slide-local. Minority slides (covers, section pages) keep every
> shape slide-local and are bound to a generated `Cover` layout with
> `showMasterSp="0"`, so promoted master chrome never appears on them. Image
> relationships for promoted chrome are copied to the master. After the SVG
> pages are complete, baseline assigns conservative semantic layout families:
> root `data-pptx-page-role` values `cover` / `toc` / `section` / `ending` become
> `Cover` / `Agenda` / `Section` / `Closing`, while `content` becomes `Content`.
> Filename tokens remain a compatibility fallback for marker-free legacy SVGs.
> It also
> preserves an existing safety `Cover` assignment made while hiding promoted
> Master chrome. It does not inspect visual similarity or infer placeholders,
> and all actual page content remains slide-local. When every slide in one
> family carries the exact same explicit background, that background moves to
> the family layout. The same family may also promote an identical leading
> prefix of explicitly named chrome when every family member carries the same
> generated OOXML, relationships are safe, no timing targets it, and z-order is
> preserved. This is
> an export-only package pass: SVG authoring, live preview, and model context are
> unchanged. Baseline also prunes base-template slide layouts no generated slide
> references, so the PowerPoint new-slide picker only offers layouts that belong
> to the deck, and
> converts `data-pptx-role="page-number"` chrome whose text exactly equals the
> slide's display number into an auto-updating PowerPoint slide-number field
> (legacy page-number id tokens remain a fallback; other numbering schemes keep
> their literal text). For projects with a
> `spec_lock.md` typography contract, baseline also installs `title_family` as
> the PowerPoint theme major font and `body_family` / `font_family` as the minor
> font; matching SVG runs use theme tokens so later theme edits can cascade
> without changing initial rendering. Baseline/template export likewise maps
> the locked background, text, primary/accent, and border roles into the
> PowerPoint color scheme. Exact matching SVG/native-object colors use
> context-safe theme tokens; unrelated local colors, inverse white/black, and
> effects stay concrete, so the first rendering remains the SVG design while
> later theme edits can cascade selectively. Add
> `--pptx-structure flat` only for debugging/comparison when all generated
> backgrounds and chrome must remain slide-local and fonts must stay concrete.
> **Legacy preserved source-template export** — an existing template package
> from a structured PPTX may ship `native_structure.json` plus
> `source_template.pptx`. This pair records a reusable capability; it does not
> select the downstream route by itself. Only a confirmed
> `template_adherence: strict` deck may set `pptx_structure.mode: preserve`.
> Add project-relative
> `source_template` / `native_structure` rows, and map every page in
> `pptx_layouts` to an exact imported layout key/name. Native export verifies the
> source SHA-256 and package parts, creates the generated slides against the
> original layouts (including multiple source masters), removes SVG-only preview
> copies of inherited master/layout visuals, and binds slide content to the
> original placeholder type/index. Preserve mode keeps the source layout picker
> roster; it does not prune unused source layouts.
> **PPTX structure mode** — release export requires
> `spec_lock.md` `pptx_structure.mode: structured`, a complete
> `pptx_masters` roster, and exactly one `pptx_layouts` row per page in
> `<master_key> | <layout_key> | <PowerPoint layout name>` form. Every SVG
> root repeats the Master/Layout keys and picker names. Master/Layout fixed
> visuals are direct root atoms; a `<g data-pptx-layer="master|layout">` is
> forbidden. Reusable slots are direct root `<g id>` elements with positive
> design-zone bounds and exactly one compatible carrier. A composite region
> uses only the explicit `object` + `proxy` fallback, and a Layout may
> intentionally have zero slots.
>
> Current template creation no longer emits this pair. `preserve` is retained
> only so existing template packages and projects remain exportable; do not
> select it for newly created templates.
> Export creates the declared Masters and Layouts, promotes the represented
> atoms, binds slot carriers, installs the locked theme/text defaults, and
> reopens the candidate package to verify Presentation → Master → Layout →
> Slide registration, picker names, static-object rosters, placeholder
> type/index/bounds, carrier bindings, hidden proxies, and zero-slot Layouts.
> It never selects pages, clusters visuals, promotes repeated chrome by
> heuristic, or invents missing structure. Legacy `baseline`, `template`,
> `preserve`, `layout_strategy`, `data-pptx-layout-kind`,
> `distilled`/`utility`, direct atomic placeholders, or missing Master
> identity must run
> [`restore-pptx-structure`](workflows/restore-pptx-structure.md) before
> export.
> **Explicit reusable template export** — deck/layout template routes set
> `spec_lock.md` `pptx_structure.mode: template`. Every SVG root declares
> `data-pptx-layout`, and direct children
> explicitly declare master/layout layers, an optional Slide background, or
> PowerPoint placeholders. This
> mode builds named reusable layouts and placeholder mappings; it does not
> infer them from visual similarity. Static master/layout elements must be
> repeated consistently in PowerPoint paint order. Chart/table placeholders
> additionally require `--native-objects`. The metadata contract and authoring
> example live in
> [`references/shared-standards.md`](references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export).
> Raw PPTX templates still route to `template-fill-pptx`; `template` structure
> mode is for explicitly annotated SVG authoring. Both `strict` and `adaptive`
> template use stay on this deterministic route. Adaptive pages may define new
> layout keys, but they repeat the same Master layer and one internally
> consistent Layout/placeholder contract.
> Template export also installs the locked `typography.title` size into every
> Master `titleStyle` level and the locked `typography.body` size into every
> `bodyStyle` and `otherStyle` level. Each text-bearing Layout placeholder also
> writes its prototype run size to `a:lstStyle/a:lvl1pPr/a:defRPr@sz`, so a
> newly inserted or reset placeholder keeps that Layout's local size before it
> falls back to the Master. Generated slide runs and Layout prompt runs keep
> their direct sizes. Missing or invalid locked title/body sizes fail template
> export. Baseline, preserve, and flat modes do not apply either text-default
> update.
> Before publishing the temporary PPTX, template export reopens the finished
> package and verifies `Presentation → Master ↔ Layout ← Slide` registration,
> named custom Layout parts and content types, collision-free placeholder
> identities, prototype bounds, and prompt/level-one sizes. This read-back gate
> is template-only and does not compare later same-Layout slides' local geometry
> or direct text formatting.
> In template mode, `pptx_layouts` contains exactly one locked
> layout key/name per page; reuse a key for a shared structure instead of
> creating one key per content instance.
> **Template structured export** — `page_layouts` records the complete
> input prototype per page while `pptx_masters` / `pptx_layouts` record the
> output structure from planning onward. Strict keeps the prototype
> Master/Layout/slot contract. Adaptive keeps its Master and may assign a new
> Layout identity during page authoring only when fixed Layout atoms or slot
> topology/bounds change; the lock is updated immediately. Non-mirror skin
> remains project-controlled, mirror preserves the reused visual identities,
> and the exporter never reads a template to add visible objects missing from
> `svg_output/`. Raw PPTX templates still route to `template-fill-pptx`;
> reusable template creation goes through `create-template`.
> **Paragraph editability vs line fidelity** — by default, mergeable dy-stacked
> paragraph blocks collapse into one editable PowerPoint text frame with multiple
> `<a:p>`, improving body-text editing and resize/reflow behavior. Add `--no-merge`
> only when the user explicitly asks for strict line-layout fidelity or when a
> layout-tight page must keep every dy-stacked line as its own text frame. The
> merge detector is conservative; mixed-layout text falls back to per-line frames.
> merge detector is conservative: adjacent lines with different effective font
> sizes retain a paragraph break, and mixed-layout text falls back to per-line frames.
> A multiline
> text carrier inside a slot must remain one native text frame; do not combine
> it with `--no-merge`. Strict-line text stays Slide-local rather than claiming
> one PowerPoint placeholder.
> **Native table/chart objects** — supported data charts and pure text-grid
> tables carry `data-pptx-native` markers by default (Executor transcribes
@@ -840,12 +792,49 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> stay dormant.
> Add `--native-objects` only when the user explicitly wants
> PowerPoint-editable native tables/charts and accepts that those objects may
> render differently across PowerPoint / Keynote / LibreOffice / WPS. Without
> render differently across PowerPoint / Keynote / LibreOffice / WPS; marker-local
> details not represented by native metadata may be omitted. This is a lossy
> editable-first contract, not a reason to disable an otherwise supported marker. Without
> the flag, marked groups export through their SVG fallback children like
> ordinary SVG content. Imported objects that carry
> `data-pptx-native-status` are fallback-only; the quality checker and
> `--native-objects` export surface their reason as warnings rather than silently
> claiming editability.
> claiming editability. An imported chart with no baked preview is a different
> case: `data-pptx-visual-status="placeholder"` plus
> `data-pptx-route-status="reconstruction-only"` records a diagnostic route.
> Default export keeps that placeholder with a warning; when the same group has
> a valid active `data-pptx-native="chart"` payload, `--native-objects` may still
> reconstruct the editable chart. Invalid or contradictory status declarations
> remain export errors. For supported parsed classic families, the importer
> instead emits a deterministic visible fallback with
> `data-pptx-visual-status="normalized"`; this is readable reconstruction, not
> a claim of Office pixel parity. Active imported table/chart markers also carry
> `data-pptx-fallback-sha256`. If their fallback, reachable SVG fragment
> definition, local reference target, or marker transform changes later, default
> export keeps that SVG, the mandatory quality checker warns, and
> `--native-objects` fails rather than discard the edit. Legacy markers
> without a baseline remain native-compatible and only warn that stale detection
> is unavailable.
> Imported table markers may also cover the verified narrow P2 subset:
> exact physical row/grid topology, canonical rectangular merges with blank
> covered cells, safe per-side borders, plain multi-paragraph cells, and closed
> run-level rich text. A rich paragraph contains non-empty `runs`; each run
> requires `text` and may use only
> `bold` / `italic` / `underline` / `strike` / `color` / `font_size` /
> `font_family` / `lang` / `alt_lang`. Presentation-only source run XML
> normalizes, while relationship-bearing text, extensions, line breaks, fields,
> tabs, bullets, broken text topology, noncanonical merges, and unsafe direct
> formatting remain fallback-only. Imported classic charts
> additionally cover verified column/line/area combos, canonical OHLC stock,
> area date-axis cases, verified scatter/bubble axes, radar, safe `of_pie`
> `serLines`, and the closed axis/title/legend plus bar-gap/overlap normalization
> cases. The importer also accepts the seven closed ChartEx data models:
> treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel.
> ChartEx data topology is retained for native read-back, but style, axis,
> labels, and binning details may normalize. These additions do not create a
> full `AxisSpec`, arbitrary ChartEx import, arbitrary rich OOXML, or new
> normalized-renderer coverage; unmodeled semantics continue to fail closed
> without reducing existing active-marker SVG-to-native conversion.
**Optional animation flags** (page transitions are on by default; per-element entrance is off by default — turn it on only when the user asks for it):
- `-t <effect>` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`. `none` removes only the visual transition; an explicit automatic advance remains valid.
@@ -911,6 +900,7 @@ Before switching roles, **MUST first read** the corresponding reference file. Ou
| Resource | Path |
|----------|------|
| Shared technical constraints | `references/shared-standards.md` |
| Native preset shape authoring | `references/native-shape-authoring.md` |
| Semantic SVG marker contract | `references/semantic-svg.md` |
| Canvas format specification | `references/canvas-formats.md` |
| Image-text layout patterns (Primary structures + Modifier layers — combine freely) | `references/image-layout-patterns.md` |
@@ -18,7 +18,7 @@ Global artifact ownership rules for PPT Master projects.
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches, SmartArt nodes/connections | Direct PPTX workflows use as native fill/structure contract |
| `analysis/image_analysis.csv` | Regenerated image fact view | Measured facts about the current `images/` folder | Re-run `analyze_images.py` before reading image facts after changes |
| `design_spec.md` | Human design narrative | Explains design intent, outline, rationale, and resource plan | Strategist writes; humans and later roles read for intent |
| `spec_lock.md` | Execution contract | Literal colors, typography, icons, images, page rhythm, templates, and charts | Executor re-reads before every page; values must be used verbatim |
| `spec_lock.md` | Execution contract | Literal colors, typography, icons, images, page rhythm, input template prototypes, charts, Master roster, and complete page-to-Master/Layout mapping | Strategist writes the initial structured contract; Executor re-reads it before every page and may add a new adaptive Layout identity only while authoring the page that first needs it; `restore-pptx-structure` owns legacy migration |
| `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Step 5 writes here; `analysis/image_analysis.csv` derives from current contents |
| `icons/` | Project icon inventory | Icons copied by `icon_sync.py` for this project | Executor uses locked project icons; exporter may fall back to global library only as documented |
| `templates/` | Project template reference | Step 3 imported specs, template SVGs, and non-image assets | Strategist/Executor read only when Step 3 is triggered |
@@ -42,6 +42,9 @@ Global artifact ownership rules for PPT Master projects.
| Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `<stem>.slide_library.json`) are read per Step 4 / direct-PPTX workflow, not in the `sources/` content scan. |
| PPTX structure | `slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. |
| Design contract | `design_spec.md` explains; `spec_lock.md` executes. Executor must not infer execution values from prose. |
| Free-design structure authority | Strategist plans the Master roster and complete page-to-Master/Layout mapping before SVG generation. `spec_lock.md` owns that mapping; `svg_output/` owns the matching root identities, atomic Master/Layout visuals, slot groups, bounds, and carriers. Export never infers structure. |
| Template structure authority | `page_layouts` owns the per-page input prototype. `pptx_masters` and `pptx_layouts` own the complete output structure from planning onward; strict keeps the prototype contract, while adaptive may create a new Layout identity during page authoring and updates the lock immediately. Templates validate provenance but never add missing visible objects during export. |
| Legacy structure migration | [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) owns conversion of old unmapped/distilled/preserve SVG projects and legacy template packages into the current structured contract. The exporter does not migrate or visually cluster them. |
| Image facts | `images/` is live state; `analysis/image_analysis.csv` is a regenerated view, not a durable cache. |
| SVG source | `svg_output/` is the only author source for generated pages. |
| Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. |
@@ -4,7 +4,7 @@
**Hard rule — complete page SVG**: Every visible object intended for the exported slide MUST exist in the final page SVG or be explicitly referenced by it. Templates and `spec_lock.md` guide construction; they are not export-time overlays for missing visible content.
**Hard rule — minimal semantics without semantic loss**: `baseline` / free-design roots declare `data-pptx-page-role`; `template` / `preserve` roots already use `data-pptx-layout` and do not duplicate that identity. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. Ordinary page content keeps normal SVG structure without duplicate semantic classification. See [`semantic-svg.md`](./semantic-svg.md).
**Hard rule — structure from the first page draft**: Every new page reads its locked Master/Layout row and declares `data-pptx-master`, `data-pptx-master-name`, `data-pptx-layout`, and `data-pptx-layout-name` on the root. Do not add `data-pptx-layout-kind` or duplicate identity with `data-pptx-page-role`. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. See [`semantic-svg.md`](./semantic-svg.md).
**Hard rule — supported PPTX route**: The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter. Step 7.2 still generates `svg_final/` as a mandatory self-contained visual preview that may be inserted as an SVG picture. Do not treat PowerPoint's manual Convert-to-Shape operation as an authoring target or compatibility requirement.
@@ -21,7 +21,6 @@
| Source list | Read path |
|---|---|
| Chosen template's `design_spec.md` (read frontmatter to detect `replication_mode`) | `templates/design_spec.md` |
| Legacy preserve-mode native layout/placeholder contract, when present | `templates/native_structure.json` |
| Every distinct `<basename>` in `spec_lock.md page_layouts` | `templates/<basename>.svg` |
| Every distinct chart name in `spec_lock.md page_charts` | `templates/charts/<chart_name>.svg` |
| Chart types in `design_spec.md §VII` not covered above | `templates/charts/<chart_name>.svg` |
@@ -36,17 +35,16 @@
> Note: batched prefix reads stay in the cached prompt prefix; per-page `spec_lock.md` re-reads append below and benefit from that cache. Scattered on-demand reads of layout/chart SVGs would invalidate downstream cache and sit in the compression-vulnerable mid-context region.
Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative). The legacy page-type table below is a **last-resort fallback** for legacy decks where `page_layouts` is missing.
Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative). There is no filename/page-type fallback.
**Resolution order (per page):**
1. **Mirror-mode template** (template's `design_spec.md` frontmatter has `replication_mode: mirror`) → see §1.1 below. The page is consumed as a **visual reference**, not as a placeholder shell.
2. `spec_lock.md page_layouts` has `P<NN>: <basename>` for this page → inherit the structure of `templates/<basename>.svg` (already in context from §1.0).
3. `pptx_structure.mode: template` but this page has no `page_layouts` entry → stop; the template contract is incomplete. Adaptive mode must still select a reference SVG.
4. `page_layouts` section absent in a legacy project and `templates/` exists → fall back to the page-type table below, matching by SVG filename keyword. Read the matched file at first use if §1.0 did not cover it.
5. No deck/layout template at all → free design.
3. `template_adherence` is present but this page has no `page_layouts` entry → stop; the template contract is incomplete. Adaptive mode must still select a reference SVG.
4. No deck/layout template at all → free design using the complete `pptx_masters` / `pptx_layouts` contract already planned in `spec_lock.md`.
> Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot.
> Note: `page_layouts` disambiguates the multiple content variants a template may ship; missing mappings are contract errors.
**Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, palette, **and font sizes** are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `<linearGradient>` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim.
@@ -78,15 +76,7 @@ When the project's chosen template is a `mirror` template (`design_spec.md` fron
**Mirror + chart pages**: chart structures inside a mirror SVG are already drawn (axis, series, labels). Treat them as visual references — replace the data labels and series text content to match the project's chart spec, but do not redraw the chart from a `templates/charts/<name>.svg` baseline. A mirror template's `page_charts` entries are normally absent for this reason.
**Legacy fallback table** (used only when `page_layouts` is absent):
| Page Type | Corresponding Template | Adherence Rules |
|-----------|----------------------|-----------------|
| Cover | `01_cover.svg` | Inherit background, decorative elements, layout structure; replace placeholder content |
| Chapter | `02_chapter.svg` | Inherit numbering style, title position, decorative elements |
| Content | `03_content.svg` | Inherit header/footer styles; **content area may be freely laid out** |
| Ending | `04_ending.svg` | Inherit background, thank-you message position, contact info layout |
| TOC | `02_toc.svg` | **Optional**: Inherit TOC title, list styles |
**Legacy template boundary**: A template with missing root Master identity, direct atomic placeholders, `data-pptx-layout-kind`, unmapped `baseline`, `preserve`, or `layout_strategy: distill` is not a fallback input. Stop and run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation.
### Page-Template Mapping Declaration (Required Output)
@@ -102,25 +92,35 @@ Before generating each page, output which template is used:
### 1.2 PowerPoint Master / Layout Mapping
`page_layouts` selects an SVG design reference; `pptx_layouts` declares the native PowerPoint layout produced at export. They are independent contracts.
`page_layouts` selects an input SVG prototype only on deck/layout template routes. `pptx_masters` and `pptx_layouts` always declare the output PowerPoint structure before the first page is drawn.
`pptx_structure.template_adherence` records the Strategist's confirmed template-use policy. Both `strict` and `adaptive` require one real `page_layouts` reference and one `pptx_layouts` output mapping per page. Strict keeps the referenced Layout contract; adaptive may create a new explicit Layout while retaining the template Master.
**Hard rule — current mode only**: `pptx_structure.mode` is `structured`. Missing mode or legacy values (`baseline`, `template`, `preserve`, `flat`), `layout_strategy`, Layout-kind fields, partial mappings, and old direct placeholders must stop generation and route to [`restore-pptx-structure`](../workflows/restore-pptx-structure.md).
| `pptx_structure.mode` | Executor behavior |
|---|---|
| `baseline` or missing | Free design or brand-only route. Author complete standalone SVG pages with root page-role and only necessary structural hints; export assigns conservative marker-backed Layout families after generation. |
| `template` | Every generated SVG MUST implement the matching `pptx_layouts` row and the explicit structure contract below. |
| `preserve` | Legacy strict-only compatibility. Use the locked source layout key/name and retain marked preview layers for source-package reuse. |
**Hard rule — root identity**: A row `P<NN>: <master_key> | <layout_key> | <layout name>` binds the page to a Master listed in `pptx_masters`. Put that Master key/name and Layout key/name on the root SVG. A Layout key belongs to exactly one Master and remains globally unique.
**Hard rule — every structured page references one layout**: In `template` or `preserve` mode, read `P<NN>: <layout_key> | <layout name>` from `pptx_layouts`, then put both values on the root SVG as `data-pptx-layout` and `data-pptx-layout-name`. Strict copies the selected template key/name. Adaptive may use the new key/name already locked by Strategist; Executor must not improvise another.
**Hard rule — atomic fixed layers**: Every `data-pptx-layer="master|layout"` visual is one direct root child that compiles to one DrawingML object. A marked `<g>` is forbidden. When reconstructing source PPTX groups, recursively push supported transforms, paint, opacity, and z-order into atomic children. Repeat the identical ordered Master atom contract on every page using that Master and the identical ordered Layout atom contract on every page sharing that `(master, layout)` pair.
**Hard rule — PowerPoint paint order**: Direct visual children appear in this order: Master background, Layout background, optional Slide background, shared Master shapes, same-key Layout shapes, then slide-local content/placeholders. Backgrounds are the special plane beneath all inherited shapes. Repeat the same Master contract on every page and the same Layout contract on every page sharing a key.
**Hard rule — PowerPoint paint order**: Direct children appear in this order: Master background atoms, Layout background atoms, optional Slide background, remaining Master atoms, remaining Layout atoms, then slot groups and Slide-local content groups. Backgrounds are the inheritance plane beneath all shapes.
**Placeholder ownership**: Keep actual title/subtitle/body/picture/chart/table/object/media/date/footer/slide-number content on the Slide and add the matching `data-pptx-placeholder`. Do not duplicate placeholder identity with `data-pptx-role`; in particular, `data-pptx-placeholder="slide-number"` already owns page-number field behavior. A reconstructed `title` normally omits `data-pptx-placeholder-idx`; preserve an explicit imported title index when present. Every indexed placeholder on one layout uses a unique index. Chart/table placeholders require native markers and a later `--native-objects` export; object/media placeholders must each resolve to one top-level DrawingML object.
**Mandatory — slot authoring**: A reusable content slot is one direct root `<g id>` carrying `data-pptx-placeholder` and positive `data-pptx-placeholder-bounds`. A normal slot contains exactly one compatible direct drawable child marked `data-pptx-placeholder-carrier="true"`. Export unwraps that child into the real Slide placeholder binding. Decorations do not belong in the slot; move reusable decoration to a root Layout atom and keep page-specific labels/captions in another slot or Slide-local group.
**Adaptive Layout creation**: Start from the selected template SVG, keep every Master element identical, and change only the Layout layer/placeholder contract needed for the new composition. Pages sharing the new key repeat the same static Layout elements and placeholder ids/types in the same order.
**Mandatory — slot identity**: Preserve imported `data-pptx-placeholder-idx` values where available; otherwise omit the title index and assign unique indices only where repeated roles need disambiguation. Pages sharing one Layout key repeat the same slot ids/types/effective indices/default bounds/binding modes. Current text, crop, and Slide-local carrier geometry may differ.
**Legacy preserve identity**: In `preserve` mode only, copy source `data-pptx-placeholder-idx` values and retain the source placeholder type/index pairing. Master/Layout preview elements keep `data-pptx-layer` plus `data-pptx-editable="false"`.
**Composite proxy fallback**: A genuinely composite region may use a direct `<g data-pptx-placeholder="object" data-pptx-placeholder-binding="proxy">` with positive bounds. Its visible group remains Slide-local and export creates one hidden transparent matching placeholder proxy. This downgrade is valid only for `object`; do not use it for an ordinary title, body, picture, chart, table, or media slot.
**Zero-slot Layout**: A Layout may have no slot groups. Covers, posters, and fixed visual pages still declare their named Master/Layout and fixed atoms. Do not manufacture a full-page `object` slot or empty `utility` identity.
**Mandatory — per-page slot coverage**: On every mapped page, declare a slot for each standard role the page actually has: the page heading as `title`, a cover tagline as `subtitle`, the page number as `slide-number`, running footer text as `footer`, a hero / content image as `picture`, and a body block already authored as one merged text frame as `body`. A page shipping zero slots exports a Layout with no insertable placeholders — valid only for a genuinely fixed composition (see Zero-slot Layout above), never as the deck-wide default. Pages sharing one layout key ship the same slot set.
**Hard rule — variable slot content**: “Per-page headings never stay Slide-local by default” means authoring them as `title` / `subtitle` slots; it never permits page-varying text or images to become fixed Layout atoms. Any such value that varies across pages sharing one Layout key MUST be carried by a slot or remain Slide-local.
**Mandatory — master/layout layer coverage**: On every mapped page, mark the deck-wide background and every-page chrome (footer bar, running logo) `data-pptx-layer="master"`, and mark the static framing that defines this layout key's composition (header rule, divider band, zone panels — including chrome repeated on every content page but absent from the cover) `data-pptx-layer="layout"`. A mapped page with zero `data-pptx-layer` marks exports a bare Master and an empty Layout — the layer marks, not the slide content, give each Layout its visible design.
**Layout identity**: Different keys differ in fixed Layout atoms or slot topology/default bounds/binding modes. Identical contracts should share one key. Current wording, imagery, crop, and Slide-local geometry never define identity.
**Template adherence**: Strict copies the prototype Master/Layout/slot contract exactly. Adaptive keeps the prototype Master and may change reusable Layout atoms or slots only under a new explicit Layout key/name. When the completed composition genuinely needs that change, update `spec_lock.md pptx_layouts` immediately while authoring the first affected page; later pages may reuse the new key only by repeating its exact contract. Changing only a label is not a new Layout.
**Layout-content boundary**: Mark only genuinely reusable fixed framing as a Master/Layout atom. Concrete titles, body copy, metrics, chart marks, images, and page-specific groups remain inside slot groups or ordinary Slide-local content groups. The exporter never infers or clusters structure.
**Background ownership**:
@@ -146,7 +146,7 @@ Before the first SVG page, output a confirmation listing: canvas dimensions, bod
**Per-block expression**: render each `design_spec.md §IX Content` block in its written texture — a full-sentence block as wrapped prose, a fragment/label block as bullets/keywords. **Never split a full-sentence block into a bullet list** — splitting loses the information that the block was continuous reasoning, not a set of parallel points; not because a bullet lays out easier, and not because an inherited template slot is shaped as a list. If a block carries no clear texture, infer the mode from its wording and the page layout.
- **Prose render recipe**: one `<text>` per paragraph; wrap lines with sibling `<tspan>` that reset `x` to the block's left edge and advance `dy` by the font size × a line-height factor. **Default — line-height by density (may override per content fit)**: ~1.41.5× for dense / small-body blocks (CLReq comfortable minimum), 1.62.0× for large-type, sparse, or `breathing` blocks. Fit about width ÷ font-size CJK glyphs per line (Latin fits roughly twice that); the last line runs short. Use the body ramp size, not a new one.
- **Prose render recipe**: one `<text>` per paragraph; wrap lines with sibling `<tspan>` where the first line uses `dy="0"` and every subsequent line repeats the parent `<text>`'s **exact `x`** and the **same positive relative `dy`** (the line-height). Equal relative `dy` + matching `x` + the same effective `font-size` lets lines flow inside one PowerPoint paragraph; a font-size change preserves a new paragraph inside the same text frame, while a growing/cumulative `dy`, an irregular gap, or a mismatched `x` (e.g. `x="0"` under `<text x="60">`) may split them into separate single-line boxes. Set the line-height `dy` from the font size × a line-height factor. **Default — line-height by density (may override per content fit)**: ~1.41.5× for dense / small-body blocks (CLReq comfortable minimum), 1.62.0× for large-type, sparse, or `breathing` blocks. Fit about width ÷ font-size CJK glyphs per line (Latin fits roughly twice that); the last line runs short. Use the body ramp size, not a new one.
- **Template precedence**: when an inherited template slot is a bullet list but the §IX block is prose, the prose wins — widen or reflow the container to hold the paragraph, or drop that card; do not pour the sentence back into the list slot.
- **Mode precedence**: the locked mode shapes voice / register, not §IX's authored titles or page order. When a `§IX` title is a user-authored topic label, keep it — do not upgrade it to an assertion just because the mode (e.g. `pyramid`) favors them; mode title-tendencies apply only to AI-drafted titles.
@@ -188,17 +188,49 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
Before drawing each page, look up its entry in `page_layouts` to decide which basename to inherit (the SVG itself was loaded in §1.0):
- Entry present (e.g., `P04: 03a_content_image_text`) → inherit the corresponding SVG already in context. The basename **must match** an actual file in the chosen template directory. If it does not, stop before drawing and report the invalid mapping; neither `strict` nor `adaptive` may fall back to free design inside a template deck.
- No entry for this page with `template_adherence: strict|adaptive` → stop before drawing and report the missing Strategist mapping. Adaptive mode still selects the closest template SVG as its visual/structural reference; flexibility applies to the new output Layout, not to whether a template reference exists.
- Whole section absent with `pptx_structure.mode: template` → stop before drawing; the current template contract is incomplete.
- Whole section absent only in a legacy project without the current template contract → see §1 fallback (legacy page-type matching).
- No entry for this page with `template_adherence: strict|adaptive` → stop before drawing and report the missing Strategist mapping. Adaptive mode still requires one selected complete template SVG; flexibility applies to the post-design output Layout, not to whether an input prototype exists.
- Whole section absent while `template_adherence` is present → stop before drawing; the current template contract is incomplete.
Do **not** invent a layout entry, and do **not** assume a template just because `templates/` exists. For either template-adherence value, a missing or invalid `page_layouts` row is an upstream contract error. Free design is a separate deck route, never a per-page fallback inside template mode.
Do **not** invent a prototype entry, and do **not** assume a template just because `templates/` exists. For either template-adherence value, a missing or invalid `page_layouts` row is an upstream contract error. Free design is a separate deck route, never a per-page fallback.
**Per-page PowerPoint layout lookup — `pptx_layouts` section**:
- `pptx_structure.mode: template` or `preserve` → a `P<NN>` row is mandatory; apply §1.2 to the root and direct children. Preserve mode uses the exact source key/name and placeholder indices.
- `pptx_structure.mode: baseline` or missing → omit explicit PPTX layer/layout metadata, but keep the root page role and only necessary structural hints. Baseline export owns conservative Master/background/chrome promotion plus page-role-backed Layout family assignment; filenames and ids are legacy fallbacks only when the corresponding marker is absent.
- A layout key may repeat across non-adjacent pages. Reuse is based on identical static/placeholder contracts, not page proximity or content wording.
- `pptx_structure.mode` must equal `structured`; any other or missing value routes to legacy restoration.
- Read the current page row as `<master_key> | <layout_key> | <layout name>` and resolve `master_key` in `pptx_masters`. Missing, malformed, or partial mappings stop before drawing.
- Write matching root Master/Layout key and picker names. Do not write `data-pptx-layout-kind` or `data-pptx-page-role`.
- On strict template use, the row and SVG contract match the selected prototype exactly.
- On adaptive template use, retain the prototype Master. If the final composition changes fixed Layout atoms or slot topology/bounds, allocate a new key/name and update this row before completing the page.
- A Layout key may repeat across non-adjacent pages only when its fixed atoms and slot contracts are identical.
**Structured-page scaffold**:
```xml
<svg viewBox="…"
data-pptx-master="<master-key>" data-pptx-master-name="<master-name>"
data-pptx-layout="<layout-key>" data-pptx-layout-name="<layout-name>">
<rect id="master-bg" data-pptx-layer="master" /> <!-- one atomic Master object -->
<text id="master-footer" data-pptx-layer="master" ></text> <!-- no Master/Layout g -->
<path id="layout-rule" data-pptx-layer="layout" /> <!-- one atomic Layout object -->
<g id="title-slot" data-pptx-placeholder="title"
data-pptx-placeholder-bounds="60 36 1160 64">
<text id="title-carrier" data-pptx-placeholder-carrier="true" ></text>
</g>
<g id="body-slot" data-pptx-placeholder="body"
data-pptx-placeholder-idx="1"
data-pptx-placeholder-bounds="60 120 470 500">
<text id="body-carrier" data-pptx-placeholder-carrier="true" ></text>
</g>
<g id="picture-slot" data-pptx-placeholder="picture"
data-pptx-placeholder-idx="2"
data-pptx-placeholder-bounds="570 120 650 500">
<image id="picture-carrier" data-pptx-placeholder-carrier="true" />
</g>
<g id="content-block-1"></g> <!-- 38 content groups -->
<g id="content-block-2"></g>
</svg>
```
Master/Layout atoms and slot groups are direct root children and precede ordinary content groups. Structural metadata nested inside an ordinary content group fails export.
**Per-page chart reference — `page_charts` section**:
@@ -213,16 +245,55 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
## 3. Execution Guidelines
- **Proximity**: group related elements with tight spacing; separate unrelated groups
- **Element grouping (Mandatory)**: wrap every logical Slide-local content unit — title, core-message line, each content block, card, list item, and diagram — in a top-level `<g id="...">` with a descriptive id. Slot `<g>` elements are already semantic groups and are excluded from the 38 ordinary content-group budget. Direct Master/Layout atoms are the required exception to grouping and may never be wrapped in a layer `<g>`. Authored native preset fragments (`preset_shape_svg.py`) already are one atomic `<g id>` each and count as one ordinary content group; keep their labels in a sibling parent `<g>`.
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec
- **Template structure**: if templates exist, inherit the visual framework
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
- **Default — stage each page with the style's composition geometry (may override when the content genuinely calls for a plain grid)**: an SVG page is a canvas, not a DOM. Before defaulting to stacked rounded-rect cards or uniform equal columns, pick one page-scale move from the locked visual style's §1 `Composition geometry` (a bleed shape, diagonal split, oversized numeral, orbit rings, …) to stage the page's primary zone. Card grids are one option among many, not the house layout.
- **Reference — image-led promotional pages (not a constraint)**: for travel, venue, product-introduction, hospitality, event, real-estate, and brochure-style decks, let images define the page skeleton before placing text. Consult [`image-layout-patterns.md`](image-layout-patterns.md) §Imported Deck Patterns and prefer patterns such as `#74` TOC image-navigation cards, `#75` asymmetric chapter banners, `#77` photo mosaic with a text cell, `#78` ambient banner + evidence photo + text panel, `#79` ribbon-header image cards, and `#80` side hero image + staggered evidence cards before falling back to plain left/right image-text splits.
- **Phased batch generation** (recommended):
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers — and **native object metadata** per §3.2 on every eligible data-chart page.
2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py <project_path>` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations.
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers — and **native object metadata** per §3.2 on every eligible data-chart page. **First-page gate (Mandatory)**: after completing the first page, run `python3 scripts/svg_quality_checker.py <project_path>/svg_output/<first_page>.svg` and fix every error before drawing page 2 — structural violations are systematic, and a first-page error repeated deck-wide costs a whole-deck rewrite.
2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py <project_path>` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. **PPTX-structure warnings (empty Layout, framing-only Layout, bare Master, duplicate layout keys) are never acknowledge-and-release**: list each one and either fix the page/lock or state per warning why the flagged state is intended (e.g. a zero-slot cover) before proceeding. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations.
3. **Logic Construction Phase**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity.
### 3.0 Native Preset Shape Selection
**Reach for a native preset whenever one expresses a complete object — this is
the default, not the exception.** Block arrows, chevrons, banners / ribbons,
callouts, flowchart nodes, stars, and other Office symbols should be **authored
as presets** via `preset_shape_svg.py`, not drawn as plain `<path>`s or faked
with rectangles: presets are what give the slide real PowerPoint shapes with
adjustment handles and the designed, non-flat-card look. When a page calls for
one of these, use the preset. Apply the decision gate in
[`native-shape-authoring.md`](./native-shape-authoring.md) to pick the right
shape and to keep only the exceptions below as ordinary SVG.
| Decision | Action |
|---|---|
| Plain rect / symmetric round rect / circle / ellipse | Keep the ordinary SVG primitive; it is already natively editable. |
| Exact single-preset match | Call `preset_shape_svg.py render` and paste its complete stdout fragment into the current hand-authored SVG. |
| Page-specific, compound, organic, branded, icon, or data geometry | Keep ordinary SVG path/polygon geometry. |
| Similar-looking contour only | Never guess; keep ordinary SVG. |
This automatic decision applies only before drawing a new object. Do not scan
existing SVG, classify path contours, or upgrade ordinary SVG during export.
**Hard rule**: do not hand-write `data-pptx-authoring`, `data-pptx-prst`,
`data-pptx-frame`, adjustment, carrier, preview, or fingerprint metadata. The
helper generates them atomically from the shared 187-shape registry. Rerun the
helper when geometry or paint changes.
Connector-family presets require `--object-kind connector`, `fill="none"`, and
a visible stroke. They export as unconnected `p:cxnSp`; do not hand-add
endpoint/site metadata. `actionButton*` presets provide visual geometry only,
not actions or hyperlinks.
**Hard rule — narrow helper scope**: the helper prints one shape fragment to
stdout. It does not write a page or choose layout. Read the fragment and insert
it through the normal `apply_patch` page edit; never redirect, loop, or batch it
into `svg_output/`.
### 3.1 Chart Plot-Area Marker (MANDATORY on every chart page)
> The [`verify-charts`](../workflows/verify-charts.md) workflow enumerates chart pages from `design_spec.md §VII`, then reads each page's plot-area marker to feed `svg_position_calculator.py`. Missing marker → verify-charts has to re-derive the plot area from axis lines, paying the cost on every run.
@@ -273,7 +344,7 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
**Hard rule**: every data chart whose type appears in the **Supported chart types** list of [shared-standards.md](shared-standards.md) "Native PPTX Table / Chart Markers" (the single authority for the eligible set, marker contract, and JSON schemas) gets `data-pptx-native="chart"` plus a `<metadata data-pptx-native="chart">` JSON child on its top-level `<g>`, transcribing the same data just plotted. Every pure text-grid data table gets `data-pptx-native="table"` the same way, transcribing all visible cell text into `columns` / `rows`.
- Chart types absent from that list and conceptual/diagrammatic graphics (process flows, cycles, quadrant cards, timelines, KPI cards) get **no marker**`svg_quality_checker.py` rejects unsupported marker types.
- Tables with merged, spanning, or graphical cells (icons, harvey balls, rating dots) get **no table marker** — the exporter rejects merged-cell metadata; they stay on the SVG fallback route.
- Canonical rectangular merged text cells may carry a table marker by putting anchor-only `row_span` / `col_span` in metadata and leaving covered cells blank. Nonrectangular/overlapping merges, nonblank covered cells, and graphical cells (icons, harvey balls, rating dots) get **no table marker** and stay on the SVG fallback route.
- Transcribe, don't restyle: `categories` / `series[].values` are the numbers just plotted; `style.colors` carries the series HEX values already used on the page (from `spec_lock.colors`).
- Data-point color: when a single column/bar series uses data-point colors in the fallback, copy those fills into `series[].point_colors` in category order.
- Data labels: when visible point values are part of the fallback chart, write `data_labels` instead of companion text; use `data_labels.points` for selected labels, and use `number_format`, `font_size`, `font_family`, and per-point `colors` / `color` when the fallback labels carry suffixes or color-coded text.
@@ -0,0 +1,152 @@
> See [`shared-standards.md`](./shared-standards.md) §§1.41.5 for the native-shape metadata and validation contracts.
# Native Preset Shape Authoring Reference
Use this reference during Executor SVG construction when one standard
PowerPoint shape can express one complete geometric object. The helper does
not create the preset shape's own `p:txBody`; keep visible text outside the
atomic fragment.
## 1. Selection Gate
Apply this decision order before drawing a stock geometric object.
> This gate is for picking the **right** native shape, not for avoiding presets.
> When a page needs an arrow, chevron, callout, banner, flowchart node, or a
> literal Office symbol, authoring it as a preset is the **default** — the
> ordinary-SVG rows below are deliberate exceptions, not the norm.
| Condition | Action |
|---|---|
| Plain rectangle, symmetric rounded rectangle, circle, or ellipse | Write the ordinary SVG primitive; the exporter already emits an editable native shape. |
| One DrawingML preset exactly expresses the intended object | Run `preset_shape_svg.py render`, then insert its complete stdout fragment into the hand-authored page. |
| The visual meaning or contour exceeds one stock shape | Write ordinary `<path>` / `<polygon>` geometry; export keeps it as editable custom geometry. |
| The shape only resembles a preset | Keep ordinary SVG; never infer a preset from contour similarity. |
| Mirror/preserve input already owns native-shape metadata | Keep the existing object and metadata; never reselect its preset. |
**Hard rule**: `preset_shape_svg.py` is the only authoring entry for
`data-pptx-authoring="preset"`. Never add `data-pptx-prst`, frame, adjustment,
carrier, preview, or fingerprint attributes to a hand-written path.
---
## 2. Semantic Preset Candidate Guide
Use the table below as the **go-to menu**: match the page's visual intent to a
candidate preset *before* defaulting to a plain rect or path. Reaching here
first is exactly how presets get used instead of forgotten.
"Automatic" means the Executor independently applies this semantic decision
gate before drawing a new object. It does not scan existing SVG, classify
paths or contours, or upgrade ordinary SVG during export.
| Visual intent | Candidate presets | Boundary |
|---|---|---|
| Literal geometric body | `triangle`, `diamond`, `pentagon`, `hexagon`, `octagon`, `star5` | Use only when the named geometry itself is the intent. |
| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Thin relationship geometry remains an ordinary SVG `<line>` / `<path>` with no attachment semantics. |
| Standard flowchart node | `flowChartProcess`, `flowChartDecision`, `flowChartInputOutput`, `flowChartTerminator`, `flowChartDocument` | Use only for an actual flowchart; ordinary content cards remain cards. |
| Standard connector route | `straightConnector1`, `bentConnector*`, `curvedConnector*` | The SVG route must match the selected preset; otherwise use a free path. |
| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | Brand-specific or custom-tail callouts remain free SVG. |
| Stock ribbon or scroll | `ribbon*`, `ellipseRibbon*`, `verticalScroll`, `horizontalScroll` | Select only when the stock contour is visually acceptable. |
| Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Inline formulas and prose symbols remain text/formula assets. |
| Literal Office symbol | `heart`, `sun`, `moon`, `lightningBolt`, `gear6`, `gear9` | Never replace an icon required by `spec_lock.icons`. |
Use registry search for a less common literal shape:
```bash
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py list --search arrow
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py describe rightArrow
```
**Forbidden — false native semantics**:
- `actionButton*` when navigation or trigger behavior is expected; the helper
maps its visual preset geometry only and never creates an action or hyperlink;
- `chartX`, `chartStar`, or `chartPlus` as a substitute for native charts;
- logo, icon glyph, illustration, brand contour, or data-chart marks.
---
## 3. Fragment Generation
Run one command for one selected object. Colors must come from the current
page's re-read `spec_lock.md`.
```bash
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \
--id p03-growth-arrow \
--frame 160 210 320 112 \
--fill "#2563EB" \
--stroke none \
--adjust "adj1=val 50000"
```
For a native connector:
```bash
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render bentConnector3 \
--id p03-flow-connector \
--object-kind connector \
--frame 420 180 220 140 \
--fill none \
--stroke "#475569" \
--stroke-width 2
```
Every connector-family preset requires `--object-kind connector`, `--fill none`,
and a visible stroke. It exports as an unconnected `p:cxnSp`; a connector
preset can never be authored as an ordinary `shape`.
**Hard rule — stdout-only exception**: the helper prints one deterministic
`<g>` fragment. Read that output and insert it with the normal page
`apply_patch` edit. Do not redirect it into `svg_output/`, loop over pages,
batch shapes, or let it choose layout. The main Agent still authors every
complete SVG page sequentially.
---
## 4. Atomic Fragment Contract
The helper emits one logical group containing exactly one hidden native carrier
and one complete visible preview.
| Component | Ownership |
|---|---|
| Logical `<g data-pptx-authoring="preset">` | Stable id, object kind, preset, frame, adjustments, and preview fingerprint. |
| `path[data-pptx-part="geometry"]` | Native PowerPoint export authority; hidden from the browser. |
| `g[data-pptx-part="geometry-preview"]` | Browser-visible preset drawing. |
| `path[data-pptx-part="geometry-detail"]` | One visible layer per DrawingML path, including path-level fill/stroke behavior. |
**Hard rule**: treat the returned group as atomic. Put labels, icons, or other
decorations beside it in a parent `<g>`, never inside the preset group.
**Frame coordinate space**: `--frame x y w h` is expressed in the coordinate
space where you insert the fragment. At the page root that is page coordinates;
inside a `<g transform="translate(…)">` use **group-local** coordinates — the
ancestor transform stacks on top, so page-absolute values would double-offset
the shape off-canvas.
**Regeneration rule**: rerun the helper when preset, frame, adjustment, fill,
stroke, or stroke width changes. Moving, scaling, rotating, or flipping the
complete logical group is allowed; zero-scale transforms and shear/skew are
forbidden, and the transformed frame must remain inside DrawingML's coordinate
range. Stroke width must remain inside DrawingML's line-width range. To freely
edit the contour, replace the whole fragment with ordinary SVG rather than
modifying a `geometry-detail` path.
---
## 5. Boundaries
| Concern | Behavior |
|---|---|
| Shape text | Keep visible SVG `<text>` outside the atomic fragment. It remains editable but may export as a grouped text box rather than the preset's own `p:txBody`. |
| Connector attachment | Authoring helper v1 creates an unconnected `p:cxnSp` and does not accept endpoint/site metadata. Do not hand-add it. The imported-shape contract may preserve an attachment that already exists in a source PPTX; creating a new attached connector is currently unsupported. |
| Action button behavior | `actionButton*` presets map visual geometry only. No action, navigation target, or hyperlink is created automatically. |
| Gradient/pattern paint | Authoring helper v1 accepts solid HEX paint only. Use ordinary SVG when a complex paint treatment is essential. |
| Multi-path darken/lighten | Browser preview uses the shared normalized paint behavior from the PPTX importer. |
| External edits | Any preview or semantic mismatch fails quality check and export; regenerate the fragment. |
**Validation**: `svg_quality_checker.py` independently rerenders every authored
preset from registry metadata and compares the carrier, visible layers, style,
and fingerprint before export.
@@ -1,126 +1,126 @@
# Minimal Semantic SVG Markers
PPT Master uses a small set of rendering-neutral compiler hints only where
ordinary SVG cannot reliably express a required PowerPoint packaging decision.
These markers are not a second content model and do not abbreviate SVG.
PPT Master uses rendering-neutral compiler hints only where ordinary SVG cannot express PowerPoint Master, Layout, placeholder, native-object, or package behavior.
## 1. Boundary
| Marker | Placement | Purpose |
|---|---|---|
| `data-pptx-page-role` | Root `<svg>` | Select the baseline PowerPoint Layout family. Required on newly generated baseline/free-design pages; template/preserve pages already use `data-pptx-layout`. |
| `data-pptx-role` | A structural page-frame element | Identify the few objects whose package or animation behavior is not already expressed by specialized metadata. The element also needs a stable unique `id`. |
| `data-pptx-master` / `data-pptx-master-name` | Root `<svg>` | Bind the page to one named PowerPoint Slide Master. |
| `data-pptx-layout` / `data-pptx-layout-name` | Root `<svg>` | Bind the page to one named Layout under that Master. |
| `data-pptx-layer="master"` | Direct atomic child of root | Promote one fixed visual object to the named Master. |
| `data-pptx-layer="layout"` | Direct atomic child of root | Promote one fixed visual object to the named Layout. |
| `data-pptx-placeholder` | Direct child `<g id>` of root | Declare one reusable Layout slot whose visible content remains Slide-local. |
| `data-pptx-role` | Structural page-frame element | Supply package, page-number, or animation behavior not already expressed by specialized metadata. |
The complete geometry, text, styles, grouping, and asset references remain in
ordinary SVG. Removing these markers must not change browser rendering. Do not
copy visible values into metadata, and do not mark ordinary titles, body text,
cards, KPIs, diagrams, charts, icons, or images merely to describe their
content.
The completed SVG remains the full visible page. Removing the metadata must not change browser rendering. Do not copy visible text, geometry, style, or asset values into metadata.
Use the existing specialized contracts for specialized facts:
**Hard rule — structure exists at design time**: Every newly generated page declares its final Master and Layout before drawing begins. The exporter compiles this contract; it never selects, clusters, distills, or visually infers it.
- `data-pptx-layout` and `data-pptx-layer` own Master/Layout/Slide structure;
- `data-pptx-placeholder` owns PowerPoint placeholder identity;
- `data-pptx-native` owns native chart/table reconstruction.
**Hard rule — specialized metadata wins**: Use Master/Layout/placeholder metadata for native structure, `data-pptx-native` for chart/table reconstruction, and the imported/authored shape metadata defined in [`shared-standards.md`](./shared-standards.md) §§1.41.5. Do not duplicate those facts with `data-pptx-role`.
Do not duplicate those facts with `data-pptx-role`. Consumers resolve semantics
in this order: specialized metadata, minimal compiler hints, then legacy
filename/id conventions.
---
## 2. Canonical Values
## 2. Master and Layout Atoms
### Page roles
Master and fixed Layout visuals are atomic root children:
| Value | Meaning | Baseline Layout |
|---|---|---|
| `cover` | Opening cover | `Cover` |
| `toc` | Agenda or contents page | `Agenda` |
| `section` | Chapter divider or transition | `Section` |
| `content` | Ordinary information page | `Content` |
| `ending` | Closing, thanks, Q&A, or contact page | `Closing` |
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-master="master-default"
data-pptx-master-name="Default Master"
data-pptx-layout="content-two-column"
data-pptx-layout-name="Two Column">
<rect id="master-bg" data-pptx-layer="master"
x="0" y="0" width="1280" height="720" fill="#F8FAFC"/>
<path id="layout-rule" data-pptx-layer="layout"
d="M72 132H1208" stroke="#CBD5E1"/>
</svg>
```
### Structural roles
| Requirement | Rule |
|---|---|
| Placement | Every Master/Layout atom is a direct child of the root SVG and has a stable unique `id`. |
| Grouping | A `<g>` may not carry `data-pptx-layer="master|layout"`. Imported PowerPoint groups are recursively flattened and their transform/style/opacity/z-order semantics are pushed into atomic children. |
| Atomicity | One marked child must compile to one DrawingML object. A nested crop `<svg>` is allowed only when it is the supported single-picture carrier, not an arbitrary container. |
| Consistency | Pages sharing one Master key repeat the identical ordered Master atom contract. Pages sharing one `(master, layout)` pair repeat the identical ordered Layout atom contract. |
| Ownership | Concrete titles, body text, metrics, charts, tables, images, and page-specific decoration stay Slide-local or inside a declared slot. |
> Note: Flattening a source PPTX group preserves supported appearance and native-layer ownership, but intentionally does not preserve the source group-editing hierarchy.
---
## 3. Layout Slots
### 3.1 Carrier-bound slot
Use one direct root group as the authoring boundary and one compatible direct child as the visible PowerPoint placeholder carrier:
```xml
<g id="title-slot"
data-pptx-placeholder="title"
data-pptx-placeholder-bounds="72 48 1136 72">
<text id="title-carrier"
data-pptx-placeholder-carrier="true"
x="72" y="100">Actual title</text>
</g>
```
| Requirement | Rule |
|---|---|
| Placement | The slot `<g id>` is a direct root child. Structural metadata may not be nested below it. |
| Bounds | `data-pptx-placeholder-bounds="x y width height"` is mandatory, finite, and positive. It describes the reusable design zone, not the current glyph/content tight bounds. |
| Carrier | The group contains exactly one compatible direct drawable child marked `data-pptx-placeholder-carrier="true"`. Export unwraps that child into the real Slide placeholder binding. |
| Identity | `data-pptx-placeholder-idx` is optional; effective indices must be unique within one Layout. Preserve a source index when reconstructing an existing PPTX. |
| Fixed decoration | Reusable decoration does not belong in the slot. Author it as a root Layout atom. Page-specific labels/captions use another slot or remain Slide-local. |
Canonical placeholder values are `title`, `subtitle`, `body`, `picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, and `slide-number`. Carrier compatibility is defined in [`shared-standards.md`](./shared-standards.md) §7.
### 3.2 Explicit composite proxy
When one reusable region is a composite object that cannot bind to one real PowerPoint placeholder, declare the downgrade explicitly:
```xml
<g id="hero-composite-slot"
data-pptx-placeholder="object"
data-pptx-placeholder-binding="proxy"
data-pptx-placeholder-bounds="544 160 664 472">
<rect x="544" y="160" width="664" height="472" fill="#E2E8F0"/>
<text x="576" y="214">Visible composite content</text>
</g>
```
The visible group stays Slide-local. Export creates one hidden transparent matching placeholder proxy. Proxy binding is valid only for `object`; it is an explicit fallback, not the default slot form.
### 3.3 Zero-slot Layout
A Layout may contain no slot groups. Cover, poster, full-visual, or other fixed-composition pages still declare their Master/Layout root identity and any fixed atoms; do not manufacture a full-page `object` placeholder merely to make the Layout non-empty.
---
## 4. Minimal Structural Roles
Use `data-pptx-role` only when no specialized marker owns the behavior:
| Value | Compiler behavior |
|---|---|
| `background` | Treat an otherwise unmarked background as static page framing for animation purposes. |
| `decoration` | Treat decorative page framing as static for animation purposes. |
| `header` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `footer` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `logo` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `watermark` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `chrome` | Generic repeated page-frame object eligible for conservative promotion. |
| `page-number` | Identify a free-design page-number object; template `data-pptx-placeholder="slide-number"` already owns this behavior. |
| `background` | Treat an otherwise unmarked background as static page framing for animation. |
| `decoration` | Exclude decorative framing from automatic entrance animation. |
| `header`, `footer`, `logo`, `watermark`, `chrome` | Identify Slide-local static framing without claiming Master/Layout ownership. |
| `page-number` | Identify a Slide-local number when no `slide-number` placeholder exists. |
`background` and `decoration` do not by themselves authorize Master/Layout
promotion. The existing background and exact-shared-structure safety checks
continue to own that decision.
Do not add structural roles to ordinary titles, body copy, cards, KPIs, diagrams, charts, icons, or images.
## 3. Examples
---
### Free-design page
## 5. Validation and Migration
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-page-role="content">
<rect id="page-bg" data-pptx-role="background"
x="0" y="0" width="1280" height="720" fill="#F7F9FC"/>
The quality checker rejects:
<!-- Ordinary content keeps normal SVG structure; no duplicate role needed. -->
<g id="growth-story">
<text x="72" y="82" font-size="32" fill="#172033">Quarterly growth</text>
</g>
- a missing root Master/Layout identity or a page-to-lock mismatch;
- a Master/Layout `<g>`, nested structure marker, missing/stale id, or inconsistent shared atom contract;
- a slot without positive bounds, a carrier-bound slot without exactly one compatible carrier, or a proxy binding on a non-`object` slot;
- incomplete page mappings, cross-Master Layout-key reuse, or conflicting same-key Layout contracts.
<text id="slide-number" data-pptx-role="page-number"
x="1200" y="680" font-size="14" fill="#667085">7</text>
</svg>
```
### Reusable template page
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-layout="content-default"
data-pptx-layout-name="Content Default">
<rect id="master-bg"
data-pptx-layer="master"
data-pptx-editable="false"
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<g id="layout-header"
data-pptx-layer="layout"
data-pptx-editable="false">
<!-- Complete reusable header drawing remains here. -->
</g>
<!-- Placeholder identity is already sufficient; no generic title role. -->
<text id="title-slot" data-pptx-placeholder="title"
x="72" y="92" font-size="32" fill="#172033">{{PAGE_TITLE}}</text>
<!-- Logo has no specialized marker, so the minimal structural hint is useful. -->
<text id="brand-mark" data-pptx-role="logo"
x="1180" y="46" text-anchor="end">ACME</text>
<!-- The placeholder already owns slide-number behavior; do not add a role. -->
<text id="page-number" data-pptx-placeholder="slide-number"
x="1200" y="680" text-anchor="end">7</text>
</svg>
```
## 4. Validation and Compatibility
The quality checker validates marker placement, canonical values, and stable
unique IDs. Baseline export consumes explicit markers before compatibility
heuristics:
- root page role is preferred over filename-based Layout classification;
- `data-pptx-placeholder="slide-number"` is preferred over a generic role or id;
- explicit structural role is preferred over id-token chrome detection;
- animation target scanning uses the structural role before id-token fallback.
Filename and id heuristics remain compatibility fallbacks only for older SVGs
that lack the corresponding marker. A canonical page role is authoritative over
the filename. Any explicit structural role prevents id-based reinterpretation;
an unknown role remains renderable but produces a quality-check warning.
Legacy SVGs using unmapped `baseline`, `preserve`, `layout_strategy: distill`, `data-pptx-layout-kind`, `distilled`, `utility`, direct atomic placeholders, or missing Master identity are not a second supported authoring contract. Run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation or export. When original PPTX/native facts exist, migration restores those identities first; otherwise the main Agent explicitly derives a structure from the complete SVG pages. Export never performs that derivation.
@@ -7,7 +7,7 @@ Other files link here instead of restating its contracts.
| Section | Owns | Strength |
|---|---|---|
| §1 Required Foundation, Forbidden Features, and Conditional Interfaces | XML validity, the exhaustive structural blacklist, native line ends, image clipping, and static local reuse | Required / Forbidden / Conditional |
| §1 Required Foundation, Forbidden Features, and Conditional Interfaces | XML validity, the exhaustive structural blacklist, native line ends, image clipping, static local reuse, and imported/authored native-shape semantics | Required / Forbidden / Conditional |
| §2 Conditional Compatibility Mappings | Inline geometry and approximate group opacity | Conditional |
| §3 Canvas Format Quick Reference | Pointer to the complete canvas catalog | Reference |
| §4 Required Page Contract and Conditional Packaging | Complete-page authority, semantic markers, editable text/grouping, and package promotion | Required / Conditional |
@@ -28,6 +28,8 @@ Other files link here instead of restating its contracts.
| Text treatments | Mixed runs, tracking, underline, strikethrough, gradient fill, outline, transparency, watermark text, and text glow | §4.2, §6.7 |
| Transforms and composition | Translate, scale, rotate, mirror, supported matrix composition, layering, and static local reuse | §1.3, §6.8 |
| Freeform geometry | Full SVG path vocabulary, curves, organic containers, multi-subpaths, and asymmetric rounded rectangles | §6.9 |
| Imported PowerPoint shapes | Lossless import payload, lightweight inspection projection, and selective restoration of preset/custom geometry, connectors, and unchanged native text bodies | §1.4 |
| Authored PowerPoint preset shapes | Registry-generated visible fragments that export as one native preset shape or connector | §1.5; [`native-shape-authoring.md`](./native-shape-authoring.md) |
| Radial/chart geometry | Pie/donut arcs, dashed-circle ring segments, gauges, progress rings, sunbursts, and diagonal polygon arrowheads | §6.10 |
| Constructed visual styles | Faux glass, hand-drawn marks, ink wash, Riso offset, pixel grid, halftone, isometric facets, paper cut, and line-plus-area data treatment | §6.11 |
| Unsupported-effect fallbacks | Raster baking or explicit-geometry alternatives for blur, inner shadow, soft edge, reflection, turbulence, blend modes, and arbitrary masks | §6.12 |
@@ -100,6 +102,10 @@ under the conditional contracts below.
>
> **Static same-document `<use>` is conditional** — see §1.3.
>
> **Imported native-shape metadata is conditional** — see §1.4.
>
> **Authored native preset fragments are conditional** — see §1.5.
>
> **Inline CSS geometry, group opacity, simple gradients, and filters are
> conditional** — see §2 and §6.
>
@@ -205,6 +211,146 @@ the original `<use>` / `<symbol>` structure.
---
### 1.4 Imported Native PowerPoint Shapes (Conditional Contract)
`pptx_to_svg.py` emits rendering-neutral metadata when a visible SVG object
originates from `p:sp`, `p:cxnSp`, or `p:grpSp`. This contract is for lossless
import SVGs and unchanged imported objects that remain Slide-local or inside a
slot during mirror restoration. Ordinary authored SVG does not need these
attributes, and no separate source-payload opt-in marker exists.
| Metadata | Placement | Required behavior |
|---|---|---|
| `data-pptx-object` | Logical `<g>` and native carrier | `shape`, `connector`, `group`, or `picture`; never infer the object kind from path appearance. |
| `data-pptx-shape-id` + `data-pptx-shape-scope` | Logical `<g>` and carrier | Preserve the source part-scoped identity. Export remaps duplicate Master/Layout/Slide ids into page-unique ids before restoring connector references. |
| `data-pptx-frame="x y width height"` | Logical `<g>` and carrier | Own native `a:xfrm` position and size. Values use sufficient precision for exact EMU recovery; path bounds, stroke, markers, shadows, and text glyph bounds never replace this frame. |
| `data-pptx-prst` | Preset carrier and logical `<g>` | One of the locked 187 DrawingML `ST_ShapeType` values. |
| `data-pptx-av-*` | Preset carrier and logical `<g>` | Preserve the complete validated DrawingML adjustment formula, including non-`val` formulas. |
| `data-pptx-part="geometry"` | One hidden carrier path | The single native export authority for frame, base fill/line/effect, preset/custom geometry, and object identity. |
| `data-pptx-part="geometry-preview"` / `geometry-detail` | Visible preview group/paths | Render the preset's independent path fill/stroke layers. These elements are never emitted as duplicate PowerPoint shapes. |
| `data-pptx-preview-sha256` | Logical preset `<g>` and carrier | Detect edits to visible preset paths or paint. A stale preview fails quality check/export instead of silently restoring old native metadata. |
| `data-pptx-geometry-kind="custom"` + `data-pptx-custgeom` | Custom-geometry carrier | Preserve the validated original `a:custGeom` subtree. If the visible path hash is unchanged, export restores formulas, handles, connection sites, text rectangle, and path list exactly; edited paths compile from current SVG geometry. |
| `data-pptx-start/end-shape-id/site` | Connector logical `<g>` and carrier | Restore `a:stCxn` / `a:endCxn` after scoped shape-id allocation. A connector may retain one zero frame axis; it must not be expanded from visible stroke or marker bounds. |
| `data-pptx-shape-style` | Native carrier | Preserve a relationship-free `p:style` independently of text, including shapes with no visible text. |
| `metadata[data-pptx-part="txbody"]` | Logical shape `<g>` | Preserve unchanged `p:txBody`, including an empty text body. Content, whitespace, positioning, or visible typography edits invalidate the payload and use the normal SVG text fallback. |
**Import/authoring representation split**:
| Representation | Contract |
|---|---|
| Lossless import SVG | Keep complete native payload, hidden carriers, and preview evidence in the temporary analysis workspace. This is the round-trip source, not the model-facing authored page. |
| Lightweight authoring projection | Exclude opaque payload and duplicate hidden carriers from model context while retaining visible shape intent and logical ids needed to locate an adopted object in the lossless import. It is not an export source. |
| `standard` / `fidelity` output | Use compact canonical metadata for newly authored shapes; do not transplant opaque import payload or source topology. |
| `mirror` output | Keep supported imported metadata only on unchanged Slide-local/slot objects. Expand fixed Master/Layout group wrappers into direct atoms while preserving source ownership, paint order, and visible appearance. |
**Hard rule — structural-layer boundary**: An unchanged imported logical object
may keep currently supported metadata while it remains Slide-local or inside a
slot. A logical `<g>` cannot be assigned to Master/Layout because those layers
require direct atoms. Mechanically expand a fixed-layer source group into direct
atoms, rebuilding a preset when supported and otherwise retaining the visible
SVG fallback. Do not use this normalization to change ownership or appearance.
**Hard rule — selective payload**: Do not copy every imported metadata block into
an authored template. Keep the full lossless import SVG separately as the
audit/fallback source. Mirror may reuse only metadata already supported by the
converter on unchanged Slide-local/slot objects; unsupported or edited objects
use the current SVG fallback. `data-pptx-native` remains reserved for native
chart/table markers.
**Registry and rendering rules**:
- The hash-locked shared registry must equal the independent 187-value shape
catalog. Missing, duplicate, unknown, or corrupt definitions fail closed.
- Preset preview paths come from the shared DrawingML formula evaluator; do not
add per-shape Python geometry handlers.
- Preset size is controlled only by `data-pptx-frame` / `a:xfrm`. Adjustment
formulas control the contour inside that frame and are not rescaled when the
frame changes.
- A group transform may move, scale, rotate, or flip the complete logical
shape without invalidating its preview fingerprint. Editing a generated
`geometry-detail` path directly is unsupported unless the carrier metadata
and preview fingerprint are regenerated together.
- Unknown or malformed SVG transform operations fail closed. DrawingML cannot
represent arbitrary shear, so a non-orthogonal transform must stop native
export instead of being silently approximated as rotation and scale.
- Opaque XML payloads containing any `r:*` relationship attribute are never
copied into a new slide part. Relationship-bearing text content and
shape-level `a:blipFill` use the existing rebuilt visual fallback and are
not covered by atomic `p:sp + p:txBody` restoration.
- Unknown future presets and explicit `unsupported` geometry status never
downgrade silently to `rect`; native export stops with the recorded reason.
**Fidelity boundary**: native preset/custom geometry, logical frame, scoped
identity, connector topology, and relationship-free unchanged horizontal
text-body semantics on ordinary shape fills are `Native-stable`. The SVG
preview paint for gradient/pattern
`darken`/`lighten` layers is `Native-normalized`; original group child
coordinates, shape-level image-fill reconstruction, and vertical-text
reconstruction are also normalized rather than byte-identical OOXML.
---
### 1.5 Authored Native PowerPoint Presets (Conditional Contract)
New SVG pages may opt one complete geometric object into a native DrawingML
preset through the deterministic fragment helper. Selection behavior lives in
[`native-shape-authoring.md`](./native-shape-authoring.md); this section owns
the machine contract. This is compact canonical authoring metadata: it describes
the intended preset, frame, adjustments, paint, and preview fingerprint without
embedding source OOXML or relying on an imported-payload marker.
| Metadata / structure | Required behavior |
|---|---|
| `data-pptx-authoring="preset"` | Appears on the logical group and hidden carrier; distinguishes the strict new authoring contract from legacy/imported metadata. |
| `data-pptx-object` | `shape` or `connector`; connector-family presets must use `connector`, and `connector` must use a connector-family preset. Authored connectors require `fill="none"` plus a visible stroke and export as unconnected `p:cxnSp`. |
| `data-pptx-prst`, `data-pptx-frame`, `data-pptx-av-*` | Generated together from the locked registry; group and carrier values must be identical. |
| One direct `path[data-pptx-part="geometry"]` | Hidden native export authority; carries the same semantics and solid paint used for regeneration. |
| One direct `g[data-pptx-part="geometry-preview"]` | Complete browser-visible preview with one ordered detail path per registry path. |
| `data-pptx-preview-sha256` | Identical on group/carrier and equal to an independent registry rerender of metadata, paint, and preview. |
Generate one fragment at a time:
```bash
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \
--id p03-growth-arrow \
--frame 160 210 320 112 \
--fill "#2563EB" \
--stroke none \
--adjust "adj1=val 50000"
```
**Hard rule — helper-only metadata**: never add or edit authored preset
metadata on a hand-written leaf. The helper output is atomic. Regenerate it
when preset, frame, adjustment, fill, stroke, or stroke width changes. Replace
the whole fragment with ordinary SVG when free contour editing is required.
**Hard rule — visible page closure**: the helper prints a complete visible
fragment to stdout; export never invents its preview. The main Agent inserts
that output into the hand-authored page. The helper cannot write a project,
select layout, or generate a page.
**Authoring paint boundary**: v1 accepts `none` or six-digit solid HEX fill and
stroke, optional fill/stroke opacity, stroke width, line cap, and line join.
Colors come from `spec_lock.md`. Use ordinary SVG for gradients, patterns,
filters, or other treatments outside this narrow contract.
**Validation**: quality check and export both rerender authored fragments from
`preset + frame + adjustments + carrier paint`. They require one carrier, one
preview, equal group/carrier semantics, exact ordered registry paths, and a
matching fingerprint. Preview edits, metadata-only edits, unknown adjustments,
out-of-range frames/transforms, zero-scale transforms, and shear/skew fail
closed.
**Fidelity boundary**: an unchanged authored fragment is `Native-stable` as
one `p:sp` or `p:cxnSp`. Text remains outside the atomic fragment and may export
as a grouped editable text box. Authoring v1 creates only unconnected
`p:cxnSp`; it does not accept hand-written endpoint/site metadata. An
`actionButton*` preset maps visual geometry only. Preset appearance never
invents connector attachment, action behavior, navigation targets, or
hyperlinks.
---
## 2. Conditional Compatibility Mappings
### 2.1 Literal Inline Geometry
@@ -269,17 +415,11 @@ table/chart markers are rejected; omit that flag to export their SVG fallback.
### 4.1 Semantic SVG Marker Contract
Semantic markers are minimal compiler hints orthogonal to native SVG semantics.
Existing `data-pptx-layout` / layer / placeholder / native-object metadata is
authoritative and read first. A `baseline` / free-design root declares
`data-pptx-page-role`; template/preserve roots already declare their Layout.
Add `data-pptx-role` only when no specialized marker expresses the required
page-frame behavior; the element also uses a stable unique `id`. Do not classify
ordinary page content or move visible facts out of SVG attributes/text into
metadata. See
[`semantic-svg.md`](semantic-svg.md) for the canonical vocabulary and examples.
Root Master/Layout identity, atomic layer elements, grouped slots, and native-object metadata are authoritative and read first. Every new page carries its final structured contract from the start of SVG authoring. Add `data-pptx-role` only when no specialized marker expresses the required page-frame behavior; the element also uses a stable unique `id`. Do not classify ordinary page content or move visible facts out of SVG attributes/text into metadata. See [`semantic-svg.md`](semantic-svg.md) for the canonical vocabulary and examples.
- **Canvas authority**: `viewBox` MUST match the selected canvas dimensions.
Root `width` and `height` are optional and do not override it.
Root `width` and `height` are optional and do not override it. Root `<svg>`
`transform` is forbidden; apply transforms to child elements or groups.
- **Font portability**: font families used by the deck must resolve to installed
export faces. `@font-face` remains forbidden; the typography contract lives in
[`strategist.md §g`](strategist.md).
@@ -294,13 +434,56 @@ These forms are needed only when the stated PPT behavior matters:
| Desired behavior | Required form |
|---|---|
| One editable PPT text frame with mixed inline formatting | Put the logical line in one `<text>` and use non-positional `<tspan>` children. A `tspan` with `x`, `y`, or `dy` starts a new positioned line and is flattened to another text frame. Separate `<text>` elements remain valid when separate frames are intended. |
| Stable object grouping or object-level animation anchor | Wrap the intended object in `<g id="...">`. Raw top-level primitives and anonymous groups remain valid when neither behavior is needed. |
| One editable PPT text frame with mixed inline formatting | Put the logical line in one `<text>` with non-positional `<tspan>` children. A `<tspan>` with `x`/`y`/`dy` starts a new positioned line. Evenly `dy`-stacked lines that repeat the parent `<text>`'s `x` stay in one frame: equal effective `font-size` may flow in the current paragraph, while a font-size change, list marker, or accepted larger gap starts a new paragraph. An unmergeable gap or mismatched `x` flattens to separate frames. Separate `<text>` elements stay valid when separate frames are intended. |
| 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 | Use a direct, full-canvas, solid `<rect>` without transform, filter, clip, rounding, or visible stroke. Other SVG backgrounds remain ordinary slide shapes. Template routes add the ownership metadata in §7. |
| Free-design page family/chrome extraction | Use the semantic markers in §4.1. Marker-free pages retain conservative filename/id fallbacks, but no visual content is inferred. |
| Reusable free-design PowerPoint Layout | Strategist declares the Master roster and complete page-to-Layout map before drawing. Executor writes the root identities, atomic fixed layers, and grouped slots while authoring each page. Export compiles only those declarations. |
| Reusable template-based PowerPoint Layout | Select one complete input SVG per page in `page_layouts` and declare the output Master/Layout mapping at planning time. Strict preserves the prototype contract; adaptive retains its Master and may assign a new explicit Layout key during page authoring. Non-mirror skin follows `spec_lock`. |
**Hard rule — supported shape conversion**: Every PPT editability claim in this specification refers to the project converter reading `svg_output/` and emitting native DrawingML. `svg_final/` is a self-contained visual preview that may be inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is unsupported; do not narrow the authoring contract to its undocumented SVG subset.
### 4.3 Element Grouping (Mandatory)
Wrap logically related Slide-local elements in top-level `<g id="...">` groups. This is **required on every generated page**, not an optional convenience: it produces real PowerPoint groups in the exported PPTX and gives each content unit a stable animation anchor. Plain `<g>` is the normal grouping primitive; `<g opacity="0..1">` additionally maps to the per-descendant alpha approximation in §2.2. Direct atomic Master/Layout elements are the required exception and may never be layer groups. A top-level slot `<g>` is already a semantic group and is excluded from the ordinary content-group budget.
**Semantic-group rule**: apart from atomic Master/Layout objects and the optional Slide background, direct Slide content uses semantic groups. Aim for **38 ordinary top-level content `<g id>` groups per slide**; slot groups and structural atoms are excluded. Each ordinary group becomes one entrance step under the chosen animation trigger. Leaving Slide-local titles, body lines, list items, cards, or decorative clusters as ungrouped top-level atoms is a contract violation.
**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 §7 contract.
**What to group** (one `<g id>` per unit):
| Grouping unit | Contains |
|---|---|
| Card / panel | Background rect + optional shadow (only if it floats over a photo/colored panel, §6.4) + icon + title + body text |
| Process step | Number/marker + icon + label + description |
| List item | Bullet / number + icon + title + description |
| Icon-text combo | Icon element + adjacent label |
| Page header | Title + subtitle + accent decoration |
| Page footer | Page number + branding |
| Decorative cluster | Related decorative shapes (rings, dots, orbs) |
An authored native preset fragment (§1.5) is already an atomic `<g id>` and counts as one content group; keep its labels / decorations in a sibling parent `<g>`, never inside the preset group.
**Forbidden**:
- One giant `<g>` around the whole slide (collapses to a single animation step).
- Many ungrouped Slide-local `<rect>` / `<text>` / `<path>` atoms — fallback animation caps at 8 primitives, dense pages may skip animation, and selection/editing degrades.
- One group per icon / text line / mark (too many steps).
- Anonymous top-level groups — every top-level semantic group needs a descriptive `id`.
**Naming — required.** A descriptive `id` on every top-level content `<g>` (`card-1`, `step-discover`, `header`, `footer`) is mandatory; it is the animation anchor and the group identity in PPTX. Without it, the exporter falls back to at most 8 top-level primitives or skips animation on dense pages.
```xml
<g id="card-benefits-1">
<!-- Shadow only if the card floats over a colored panel; on flat white, omit it. -->
<rect x="60" y="115" width="565" height="260" rx="20" fill="#FFFFFF" filter="url(#shadow)"/>
<use data-icon="chunk-filled/bolt" x="108" y="163" width="44" height="44" fill="#0071E3"/>
<text x="105" y="270" font-size="56" font-weight="bold" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" font-weight="bold" fill="#1D1D1F">Faster</text>
<text x="105" y="310" font-size="18" fill="#6E6E73">Reduce production time from days to hours.</text>
</g>
```
---
## 5. Workflow Authority
@@ -454,7 +637,15 @@ color for glow; black reads as diffuse shadow.
| Raised | Primary CTA, focused card, overlay | 610 | 1016 | 0.120.20 |
| Glow | Short display text, metric, focus accent | 0 offset | 48 | 0.350.55 |
**Reference — not a constraint**: keep one light direction and at most two
**Strong default — single light source per page**: every `feOffset` shadow on
one slide shares the same `dx`/`dy` direction (default `dx="0"`, `dy="4"``dy="8"`,
light from upper front). Contradictory shadow directions read as multiple light
sources — a clear low-quality tell. The one sanctioned exception is a deliberate
upward paper-layer light, where every affected layer flips direction together;
never mix directions on the same plane. This is a strong default, not a
checker-enforced hard rule.
**Reference — not a constraint**: keep at most two
non-floor tiers; two or three shadowed objects usually suffice. Do not lift
every peer card or stack strong shadow, border, gradient, and tint on one
container. Same-family colored shadow is reserved for a focal accent. On dark
@@ -807,10 +998,12 @@ the preset is outside this enum.
Native PowerPoint tables and Excel-backed charts activate at export time only. The default chart/table route remains hand-authored SVG geometry so the deck stays pixel-stable across PowerPoint / Keynote / LibreOffice / WPS.
**Authoring — markers are standard on supported data charts and text-grid tables**: Executor writes the marker at draw time on every data chart whose type falls in the supported set and on every pure text-grid data table ([executor-base.md §3.2](executor-base.md)), so any deck can later form native objects without regeneration. Tables with merged or graphical cells stay unmarked on the SVG fallback route. The marker group supplies both: visible SVG fallback children for browser/live-preview rendering, and JSON metadata for `svg_to_pptx` native export.
**Authoring — markers are standard on supported data charts and text-grid tables**: Executor writes the marker at draw time on every data chart whose type falls in the supported set and on every pure text-grid data table ([executor-base.md §3.2](executor-base.md)), so any deck can later form native objects without regeneration. Canonical rectangular merged text cells may use the narrow `row_span` / `col_span` contract below; graphical cells stay unmarked on the SVG fallback route. The marker group supplies both: visible SVG fallback children for browser/live-preview rendering, and JSON metadata for `svg_to_pptx` native export.
**Hard rule — activation is the opt-in, dormant unless exported with `--native-objects`**: A marker only declares that a group is eligible for native export. Normal `svg_to_pptx.py` runs keep the fallback SVG children. Pass `--native-objects` only when editability in PowerPoint matters more than cross-renderer layout fidelity: it emits the PowerPoint object and skips the fallback children to avoid duplicates. Native styling preserves the core palette, text, axis, grid, and background colors where possible, but it is still a PowerPoint chart/table object rather than a pixel-identical SVG drawing.
The native route is deliberately editable-first and may be lossy: marker-local labels, callouts, KPIs, guide lines, custom split/bin semantics, or styling that is absent from the payload may disappear or normalize. Export warns about this route-level risk and any narrower issue it can detect. Loss of visual parity is not grounds to remove an active marker that the emitter can otherwise convert; use the default SVG-fallback export when exact authored artwork matters more than editability.
| Marker | Native output | Required metadata |
|---|---|---|
| `<g data-pptx-native="table">` | `<p:graphicFrame>` with `<a:tbl>` | bounds + `columns` or `rows` |
@@ -831,7 +1024,38 @@ to at least one EMU per resolved row and column.
**Validation**: `svg_quality_checker.py` validates native marker kind, JSON
metadata, bounds/fallback availability, table rows/columns, supported chart
type, and chart data shape before export.
type, chart data shape, and any imported fallback baseline before export.
**Hard rule — imported fallback freshness**: active table/chart markers emitted
by `pptx_to_svg.py` carry `data-pptx-fallback-sha256`, a canonical hash of the
marker fallback plus reachable document-level SVG fragment definitions. Editing
geometry/text/paint, switching a local `url(#...)` or `href="#..."` target,
changing a reachable definition, or changing the marker transform makes the
native metadata stale. The mandatory quality checker warns and the default route
keeps the edited SVG; `--native-objects` hard-fails before replacement so it
cannot discard that edit. Metadata/title/description nodes, `data-pptx-*`
runtime attributes, marker-local stable ID renames, and marker-local
`display:none` subtrees are excluded. `visibility:hidden` content,
marker-local unused definitions, and explicitly referenced document-level
target roots (even when hidden) remain conservatively hashed. External
image/font file bytes are not read.
Hashless legacy markers remain native-compatible and warn in the checker/native
route that stale detection is unavailable. A stale hash is an integrity mismatch,
not a visual-parity gate on an unchanged active marker.
**Hard rule — imported visual/route status**: A PPTX chart with a complete baked preview
may carry `data-pptx-visual-status="source-preview"`. Supported parsed classic
families without a preview use a deterministic readable fallback marked
`data-pptx-visual-status="normalized"`; it is explicitly not source-exact.
When no current renderer exists, the importer emits its typed reconstruction aid with both
`data-pptx-visual-status="placeholder"` and
`data-pptx-route-status="reconstruction-only"`. The valid pair is diagnostic:
quality checking and export warn, default export keeps the placeholder, and
`--native-objects` may reconstruct an editable chart when the same group has a
valid active `data-pptx-native="chart"` payload. The allowed values and pair
remain closed; unknown, whitespace-padded, or contradictory values fail.
`data-pptx-native` and `data-pptx-native-status` are mutually exclusive on the
same visible group.
```xml
<g id="p03-revenue-chart" data-pptx-native="chart">
@@ -861,8 +1085,29 @@ If present, `header_rows` must be an integer from `0` through the resolved row
count. Write `strict_grid`, `style.band_row`, and cell `bold` as JSON booleans.
Cell objects accept `text`, `fill`, `color`,
`align`, `valign`, `bold`, `font_size`, `padding`, `border_color`, and
`border_width`; the same `padding`, `border_color`, and `border_width` keys may
also live under `style` as table defaults. Native table typography mirrors the
`border_width`, plus optional `lang`; the same `padding`, `border_color`,
`border_width`, and `lang` keys may also live under `style` as table defaults.
For multi-paragraph text, replace cell `text` with a non-empty `paragraphs`
list. Each entry is either a string or an object containing optional
`align: "l|ctr|r"` and exactly one of `text` or non-empty `runs`; empty
paragraph strings are preserved, and cell `text` / `paragraphs` are mutually
exclusive. Each run is an object with required string `text` and optional JSON
boolean `bold`, `italic`, `underline`, and `strike`, plus optional `color`,
`font_size`, one-typeface `font_family`, `lang`, and `alt_lang`. Unknown fields,
wrong types, empty run lists, multi-typeface `font_family`, and unsupported
colors fail fast. PPTX import requires exact physical row/grid topology and
normalizes source presentation-only run XML outside this closed schema, but
relationship-bearing text, extensions, structural line breaks, fields, tabs,
bullets, malformed run topology, and unsupported text-body structure remain
fallback-only.
Per-side cell borders use `borders.left|right|top|bottom`, where each value is
either `{ "style": "none" }` or
`{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`.
Per-side borders are cell-only; legacy uniform `border_color` / `border_width`
remain supported as defaults that an individual side may override.
When `lang` is absent, export derives `zh-CN` for CJK text and `en-US`
otherwise. `style.band_row: false` disables both `<a:tblPr bandRow>` and
materialized alternating row fills. Native table typography mirrors the
visible SVG fallback: put `style.font_family` and `style.font_size` on the
marker from the table text already drawn, then use `style.header_font_size` or
per-cell `font_size` only when the fallback visibly differs. If the fallback
@@ -877,12 +1122,16 @@ fallback `<text>` inside a native table marker does not appear in metadata.
For numeric or currency columns, use cell objects with `align: "r"`; SVG
`text-anchor="end"` does not carry into the native table.
**Forbidden — native merged table cells**: Do not use `rowSpan`, `colSpan`,
`gridSpan`, `hMerge`, `vMerge`, or top-level merge lists in native table
metadata. `svg_to_pptx.py --native-objects` rejects them so merged-cell tables
do not silently degrade into incorrect grids. Keep merged-cell tables on the
default SVG fallback route, or merge cells manually in PowerPoint after native
export.
**Merged table cells — canonical rectangular contract only**: Put positive JSON
integer `row_span` / `col_span` values on the merge anchor and keep every
covered grid cell blank. Spans must stay within the resolved rectangular grid
and may not overlap. The exporter emits the canonical DrawingML topology
(`rowSpan` on the top edge, `gridSpan` on the left edge, `hMerge` / `vMerge` on
covered cells). CamelCase aliases, raw OOXML merge fields, top-level merge lists,
nonblank covered cells, invalid spans, and overlaps fail fast. The PPTX importer
activates native reconstruction only for that same explicit rectangular topology
with empty merge-slave text bodies; other merge encodings remain fallback-only
with `unsupported-merge-topology`.
**Category chart schema**: `column`, `bar`, `line`, `area`, `pie`,
`doughnut`, `pieOfPie`, `barOfPie`, and `radar` use `categories` plus
@@ -906,6 +1155,16 @@ zero-based `idx` plus optional per-point `position`, `number_format`,
**Combo chart schema**: `combo` uses shared `categories` plus either `plots[]`
or typed `series[]`. Each plot supports `type: "column" | "line" | "area"`,
its own `series`, and optional `axis: "secondary"` for a right-side value axis.
When primary and secondary plots genuinely use different category caches,
`plots[]` may also carry its own `categories` and `category_numeric`; the
workbook writer allocates independent category/value ranges. Typed `series[]`
continues to require the shared top-level categories.
Imported `plots[]` may carry `series_indices` so the verified source identity
where each `c:idx` equals its `c:order` survives when physical plot order differs
from legend order. If one plot supplies it, every plot must supply a same-length
list of unique non-negative JSON integers, and the combined values must form one
contiguous `0..N-1` range. Sources whose `idx` and `order` differ stay
fallback-only; typed `series[]` does not accept this plot-scoped field.
Typed `series[]` accepts the same `type` and `axis` fields per series, and
adjacent compatible series are grouped into the same PowerPoint plot. Area
series may set `fill_opacity` / `fillOpacity` as a `0..1` SVG opacity value
@@ -916,7 +1175,35 @@ area plots below columns and lines while preserving the original series indices.
Line and area series may set `line_width` / `lineWidth` in SVG px units to
match fallback `stroke-width`.
**XY chart schema**: `scatter` and `bubble` use `series[].x` + `series[].y`; `bubble` also requires one `series[].size` / `series[].sizes` value per point. `series[].points` is also accepted as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
**Narrow classic-axis schema**: `axes` is a closed object with the roles
`category`, `value`, `secondary_category`, and `secondary_value`. Each role may
set only `kind` (`text`, `date`, or `value`, as appropriate), `position`,
`visible`, `label_position` (`next_to`, `none`, `low`, or `high`),
`number_format`, `minimum`, `maximum`, `major_unit`, `reverse`, and
`major_gridlines`. `major_unit` applies to value axes only. PPTX date-axis
**import** is deliberately narrow: numeric Excel date serials are accepted for
area charts and OHLC stock charts; arbitrary date-axis source families are not.
This contract is not a full `AxisSpec`: logarithmic scales, minor units/gridlines,
crossing values, display units, tick skipping, and other unlisted OOXML semantics
remain unsupported and fail closed on import.
**Narrow XY-axis schema**: `scatter` and `bubble` may use a closed `axes` object
with only `x` and `y` roles. Both roles have `kind: "value"`; `x.position`
is `bottom` or `top`, while `y.position` is `left` or `right`. Each accepts the
same closed fields above, and `major_unit` is valid on both value axes. PPTX
import requires the plot to reference exactly two mutually cross-linked
`c:valAx` nodes and separately enforces the closed field/topology gates. The
native writer emits and the importer reads back every field in this closed
contract. Scatter import derives the effective `scatter_style` from a uniform
per-series line/marker/smooth state; unsupported or nonuniform states remain
fallback-only. The normalized SVG fallback newly consumes only
`axes.x.major_gridlines` and `axes.y.major_gridlines`; the other fields do not
imply full visual-axis parity.
**XY chart schema**: `scatter` and `bubble` use `series[].x` + `series[].y`;
`bubble` also requires one `series[].size` / `series[].sizes` value per point.
`series[].points` is also accepted as `[x, y]` / `[x, y, size]` tuples or
`{x, y, size}` objects.
**Chart typography**: Metadata sizes use the same px-style unit as SVG text
(`1px = 0.75pt`). `style.font_family` and the role-specific
@@ -972,6 +1259,10 @@ OOXML RGB. Bar and column series also disable PowerPoint's negative-value
inversion so negative bars keep the same series fill instead of turning into
white/theme fill.
For ChartEx native charts, valid payload `style.colors` (or root `colors`)
populate the ChartEx color-style part instead of being replaced by a fixed
accent1accent6 list. Other ChartEx style semantics remain normalized.
**PowerPoint chartEx schema**: `treemap`, `sunburst`, `histogram`, `pareto`,
`boxWhisker`, `waterfall`, and `funnel` use Office 2016+ chartEx parts. Use
these input shapes:
@@ -990,7 +1281,34 @@ these input shapes:
**Stock chart schema**: `stock` uses numeric Excel date serials in
`categories` or `dates`, plus exactly four series in open / high / low / close
order. Use either `series` with four entries, or top-level `open`, `high`,
`low`, and `close` arrays.
`low`, and `close` arrays. PPTX import currently recognizes only canonical OHLC
stock charts with shared numeric date caches, `hiLowLines`, and `upDownBars`.
Safe stock series style may pass the structural gate, but stock series,
`hiLowLines`, and up-down bar local styling can still normalize under the
editable-first contract. HLC, volume, noncanonical structure, and style XML
outside the safe parsing boundary stay fallback-only.
**PPTX chart-import boundary**: The importer recognizes conservative classic
single-plot charts plus the verified scatter/bubble XY-axis, column/line/area
combo, area date-axis, canonical OHLC stock, radar, safe `of_pie` `serLines`,
axis/title/legend normalization, and bar/column gap/overlap subsets. Imported
`gapWidth` must be one canonical integer in `0..500`; imported `overlap` must be
one canonical integer in `-100..100`. Both values intentionally normalize to
the native writer contract rather than claiming exact source-style retention.
Malformed, duplicate, or out-of-range values fail closed.
ChartEx import is closed to seven validated data models: `treemap`, `sunburst`,
`histogram`, `pareto`, `box_whisker`, `waterfall`, and `funnel`. The importer
retains their supported hierarchy/category/value/series/subtotal topology for
native read-back. Numeric cache values must be non-empty and finite, and cache
counts/indexes must be canonical non-negative decimal integers with exact,
contiguous topology; malformed, non-numeric, `NaN`, infinite, sparse, duplicate,
or mismatched caches fail closed. ChartEx style, axis, label, and binning details
outside the payload normalize. Full `AxisSpec`, arbitrary ChartEx families or
presentation fidelity, arbitrary stock variants, and axis/combo/date-axis
semantics outside the closed fields above remain fallback-only. The C4/C5
import work does not expand the normalized SVG renderer and does not reduce
existing SVG-marker-to-native writer support.
**Deferred chart types**: Exploded pie / doughnut variants, `map`, `heatmap`,
`bullet`, and `gantt` are intentionally outside the current native-object
@@ -1023,66 +1341,29 @@ defaults to `bottom` and accepts `top`, `left`, or `right`.
**Forbidden — native marker transforms**: Do not rotate, skew, or matrix-transform native table/chart marker groups. Translate / scale is accepted; complex transforms fail export because PowerPoint native table/chart frames do not preserve arbitrary SVG transforms.
### Baseline Layout Family Extraction
### Structured Layout Routing
Native `baseline` export assigns layout families after every SVG page has been
converted. This package-only pass does not change SVG authoring or live preview.
Every new SVG project has one deterministic route: `pptx_structure.mode: structured`. `spec_lock.md` contains a complete Master roster and one page-to-Master/Layout row before the first page is drawn. Free design defaults to one Master. `standard` / `fidelity` templates use their newly authored contract; mirror templates use the restored source identities and parentage expressed through the same explicit interface.
| Root `data-pptx-page-role` | Output layout |
|---|---|
| `cover` | `Cover` |
| `toc` | `Agenda` |
| `section` | `Section` |
| `ending` | `Closing` |
| `content` | `Content` |
**Hard rule — no structure inference**: Export does not assign Layout families, promote repeated chrome, cluster pages, infer placeholders, or repair missing metadata. It compiles only the declared root identities, atomic fixed layers, and slot groups. Legacy or unmapped projects must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) first.
Marker-free legacy SVGs retain conservative filename-token fallback: explicit
cover / agenda / section / closing tokens select those families, and every
other page becomes `Content`. When a valid root marker exists, it is
authoritative even if the filename suggests another family.
**Layout reuse**: Reuse one Layout key only when its ordered fixed Layout atoms and slot ids/types/effective indices/default bounds/binding modes are identical. Different wording, data, imagery, crop, or Slide-local carrier geometry does not create a new Layout. A genuinely different reusable contract gets a new key even when both pages are semantically `content`.
Keep an existing `Cover` assignment when the Master chrome safety pass already
used it to hide promoted Master shapes from a minority page.
**Zero-slot Layout**: A named Layout may contain no slots and no fixed Layout atoms. This is valid for a cover, poster, full-visual page, or other fixed composition. Do not manufacture an empty `utility` kind or full-page fake `object` slot.
**Hard rule — no visual inference**: Keep every actual title, body, picture,
chart, table, and page-specific shape on the Slide. Baseline layouts do not
infer placeholders or promote visually similar content.
**Adaptive change**: Template `strict` preserves the selected prototype contract. `adaptive` retains the prototype Master and may create a new Layout identity only when fixed Layout atoms or slot topology/bounds change. Update the page mapping immediately while authoring the first such page; never mutate a reused key silently.
**Background rule**: Move a Slide `p:bg` to its family Layout only when every
slide in that family carries exactly the same explicit background. Otherwise,
keep each background on its Slide. Preserve whether each family shows or hides
the parent Master shape tree.
### Explicit PPTX Master / Layout / Placeholder Metadata
**Layout chrome rule**: After family assignment, move only the identical
leading prefix of explicitly marked chrome (`logo`, `footer`, `header`,
`watermark`, `chrome`) carried by every family member. Legacy id tokens are
consulted only when `data-pptx-role` is absent. Generated OOXML and
image relationships must match exactly, no animation may target the shapes,
and moving them behind Slide content must preserve z-order. Keep page numbers
and every non-identical object Slide-local.
**Trigger**: Every newly generated SVG page uses this interface. `spec_lock.md` declares `pptx_structure.mode: structured`, a complete `pptx_masters` roster, and one `pptx_layouts` row per page before authoring begins. Template routes additionally keep `page_layouts` as input-prototype provenance.
### Explicit PPTX Master / Layout / Placeholder Metadata (Template Export)
**Project lock**: A Master row is `<master_key>: <PowerPoint picker name>`. A page row is `P<NN>: <master_key> | <layout_key> | <PowerPoint layout name>`. The SVG root values MUST match those rows. A Layout key belongs to exactly one Master and must be globally unique. Reuse one key only when pages share identical ordered Layout atoms and slot ids/types/effective indices/default bounds/binding modes. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows.
**Trigger**: Deck/layout template routes set `spec_lock.md`
`pptx_structure.mode` to `template`; direct diagnostics may pass
`--pptx-structure template`. Both strict and adaptive template adherence use
this mode. Without either trigger, metadata stays visually dormant.
**Template behavior**: Strict preserves the selected prototype's declared Master/Layout/slot contract. Adaptive retains its Master and may allocate a new Layout key/name only when fixed Layout atoms or slot topology/bounds change; update the lock during authoring. Mirror-created prototypes preserve restored source identity, literal paint, typography, effects, atomic geometry, and referenced assets. `standard` / `fidelity` never make source topology authoritative; mirror does not synthesize a replacement topology.
**Project lock**: In the standard project pipeline, template mode requires one
`pptx_layouts` row per page using
`P<NN>: <layout_key> | <PowerPoint layout name>`. The SVG root values MUST
match that row. Strict uses the selected template key/name. Adaptive may create
a new key/name while repeating the same Master contract. Reuse one layout key
only when pages share the same static Layout layer and placeholder contract;
different content is not a reason to create a new layout. Direct diagnostic
exports may select the mode with the CLI flag instead of a locked
`pptx_structure.mode` row, but still require `spec_lock.md` typography
`title` / `body` rows.
**Master text-style contract**: Template mode requires numeric `title` and
`body` rows under `spec_lock.md` `typography`. Export maps the locked `title`
size to every `a:defRPr` in Master `p:titleStyle`, and maps the locked `body`
size to every level in both `p:bodyStyle` and `p:otherStyle`.
**Master text-style contract**: Structured export maps the
locked `title` size to every `a:defRPr` in Master `p:titleStyle`, and map the
locked `body` size to every level in both `p:bodyStyle` and `p:otherStyle`.
| Master style | Locked source | XML field changed |
|---|---|---|
@@ -1093,11 +1374,10 @@ size to every level in both `p:bodyStyle` and `p:otherStyle`.
**Hard rule — narrow scope**: This Master update changes only Master
`p:txStyles//a:defRPr@sz`. It does not rewrite direct run sizes on generated
slides, so the initial slide rendering remains controlled by the authored SVG.
Missing `title` or `body` rows fail template export. `baseline`, `preserve`,
and `flat` modes do not apply this Master text-style update.
Missing `title` or `body` rows fail structured export.
**Layout level-one text-default contract**: For every text-bearing placeholder
whose first prototype run has a direct `a:rPr@sz`, template export copies that
whose first prototype run has a direct `a:rPr@sz`, explicit Layout export copies that
size to the generated Layout prompt run and
`p:txBody/a:lstStyle/a:lvl1pPr/a:defRPr@sz`. It does not rewrite Slide direct
runs or Layout levels 29. This preserves the layout-specific size when
@@ -1106,24 +1386,25 @@ prototype size remain unchanged.
| Metadata | Placement | Behavior |
|---|---|---|
| `data-pptx-master="master-default"` | root `<svg>` | Binds the slide to one generated Slide Master key |
| `data-pptx-master-name="Default Master"` | root `<svg>` | Sets the Master picker/display name |
| `data-pptx-layout="content"` | root `<svg>` | Binds the slide to one generated reusable layout key |
| `data-pptx-layout-name="Title and Content"` | root `<svg>` | Sets the PowerPoint layout-picker name; defaults from the layout key |
| `data-pptx-layer="master"` | direct visual child | Moves one repeated static object/background into the slide master |
| `data-pptx-layer="layout"` | direct visual child | Moves one repeated static object/background into the selected layout |
| `data-pptx-layer="master"` | direct atomic visual child | Moves one repeated static object/background into the named Slide Master; `<g>` is forbidden |
| `data-pptx-layer="layout"` | direct atomic visual child | Moves one repeated static object/background into the selected Layout; `<g>` is forbidden |
| `data-pptx-layer="slide"` | direct full-canvas solid `<rect>` only | Writes a one-page override as Slide `p:bg` |
| `data-pptx-placeholder="..."` | direct visual child | Keeps actual content on the slide and maps it to a generated layout placeholder |
| `data-pptx-placeholder-bounds="x y width height"` | placeholder element | Overrides the reusable placeholder frame in SVG user units |
| `data-pptx-placeholder-idx="1"` | placeholder element | Retains an imported source layout placeholder index; optional for reconstructed layouts |
| `data-pptx-placeholder="..."` | direct slot `<g id>` | Declares a reusable Layout slot whose visible content remains Slide-local |
| `data-pptx-placeholder-bounds="x y width height"` | slot `<g>` | Supplies the positive reusable design-zone frame in SVG user units |
| `data-pptx-placeholder-idx="1"` | slot `<g>` | Retains an imported source Layout placeholder index; optional for reconstructed layouts |
| `data-pptx-placeholder-carrier="true"` | one compatible direct child of a normal slot | Binds that visible child as the real Slide placeholder carrier |
| `data-pptx-placeholder-binding="proxy"` | composite `object` slot `<g>` only | Keeps the visible group ordinary and creates one hidden transparent binding proxy |
| `data-pptx-editable="false"` | master/layout element or slide background | Declares intentional editing outside ordinary slide content |
**Hard rule — explicit only**: Template export never promotes visually similar
content by inference. Every SVG requires a root `data-pptx-layout`; every
master/layout/placeholder element requires a unique `id` and must be a direct
child of the root SVG.
**Hard rule — explicit only**: Structured export never promotes visually similar content by inference. Every SVG requires the four root Master/Layout identity attributes. Every Master/Layout atom and slot requires a unique stable `id` and is a direct root child. Layouts with zero slots are valid. `data-pptx-layout-kind`, `distilled`, and `utility` are legacy metadata and fail the current contract.
**Layer order**: Author the SVG in PowerPoint paint order: Master background,
Layout background, optional Slide background, Master shapes, Layout shapes,
then slide-local content/placeholders. Backgrounds are a special inheritance
Layout background, optional Slide background, remaining Master atoms, remaining Layout atoms,
then slot groups and Slide-local content groups. Backgrounds are a special inheritance
plane beneath every shape; this order keeps standalone SVG preview and
PowerPoint rendering aligned. The exporter rejects interleaved layers.
@@ -1138,105 +1419,70 @@ cover/section/content variants under the same design language. Gradients,
images, textures, transformed rects, and visible-stroke rects are not promoted
by this solid-background rule.
| Placeholder value | SVG element | PowerPoint placeholder |
| Placeholder value | Direct carrier inside slot `<g>` | PowerPoint placeholder |
|---|---|---|
| `title`, `subtitle`, `body` | direct `<text>` | `title`, `subTitle`, `body` |
| `date`, `footer`, `slide-number` | direct `<text>` | `dt`, `ftr`, `sldNum` |
| `picture` | direct `<image>` or imported crop `<svg>` | `pic` |
| `chart`, `table` | direct matching `data-pptx-native` marker group | `chart`, `tbl` |
| `object` | one direct text, image, or basic SVG shape | `obj` |
| `media` | direct `<image>` or imported crop `<svg>` | `media` |
| `title`, `subtitle`, `body` | one `<text data-pptx-placeholder-carrier="true">` | `title`, `subTitle`, `body` |
| `date`, `footer`, `slide-number` | one `<text data-pptx-placeholder-carrier="true">` | `dt`, `ftr`, `sldNum` |
| `picture` | one `<image>` or supported imported crop `<svg>`, marked as carrier | `pic` |
| `chart`, `table` | one matching `data-pptx-native` marker group, marked as carrier | `chart`, `tbl` |
| `object` | one text, image, or basic SVG shape marked as carrier; alternatively the slot group declares `binding="proxy"` | `obj` |
| `media` | one `<image>` or supported imported crop `<svg>`, marked as carrier | `media` |
**Text slot carrier**: A multiline text placeholder must remain one
native text frame. Use the default paragraph merge; `--no-merge` cannot supply
several line shapes as one
PowerPoint placeholder prototype/binding. Leave strict-line text Slide-local
when separate frames are the required result.
`title` is normally type-matched without an index in reconstructed layouts; if
an imported source title explicitly has one, preserve that exact index. Every
indexed placeholder on one layout uses a unique OOXML UInt32 index. Template
export writes the semantic type on both the Layout and Slide placeholder
(except `obj`, whose OOXML default is already
`obj`) so PowerPoint and `python-pptx` retain the same identity. Date, footer,
and slide-number placeholders enable their matching Layout `p:hf` flags; a
date placeholder also gets a `datetimeFigureOut` field in the reusable Layout
definition. The current Slide keeps its authored date content.
indexed placeholder on one layout uses a unique OOXML UInt32 index. Structured export writes the semantic type on both the Layout and Slide carrier (except `obj`, whose OOXML default is already `obj`) so PowerPoint and `python-pptx` retain the same identity. A composite object slot instead keeps its visible group ordinary and uses a hidden transparent proxy.
Date, footer, and slide-number placeholders enable their matching Layout `p:hf`
flags; a date placeholder also gets a `datetimeFigureOut` field in the reusable
Layout definition. The current Slide keeps its authored date content.
Because an omitted `p:ph@idx` has the effective value `0`, an omitted-index
title reserves `0`; no other placeholder on that Layout may use the same
effective index.
**Placeholder prototype**: The first slide using a layout key supplies that
layout's placeholder formatting. `data-pptx-placeholder-bounds` supplies the
reusable frame; when omitted, the exporter uses the prototype object's native
DrawingML bounds. Repeat the same placeholder ids/types on every slide using
that layout. Actual slide content and local geometry may differ.
**Slot prototype**: The first slide using a Layout key supplies that Layout's placeholder formatting. `data-pptx-placeholder-bounds` supplies the reusable default frame and is mandatory on every slot. Derive it from
the intended design zone, column, panel inset, safe area, or picture frame —
never from text length, glyph width, line count, or a tight content bounding
box. Repeat the same slot ids/types/effective indices/default bounds/binding modes on every slide using that Layout. The Layout owns the reusable `p:ph`; normal visible carriers keep a matching Slide binding so approved rendering stays identical. A composite `object` proxy adds one hidden transparent binding shape to suppress empty inherited placeholder paint. Bounds define the Layout default only; actual Slide content and local carrier geometry may differ.
**Final-package read-back gate**: After writing the temporary template-mode
PPTX and before publishing it, export reopens the package and verifies that
**Final-package read-back gate**: After writing a temporary structured PPTX and before publishing it, export reopens the package and
verifies that
each Slide targets exactly one Layout, one layout key always resolves to the
same part, different keys do not collapse onto one part, and every Layout is
registered through its Master and the Presentation. It also verifies the
Layout picker name/content type, placeholder type and effective index,
matching `p:hf` flags, prototype-derived or explicitly overridden frame,
direct prompt size, and level-one default size. Later slides may keep different
slide-local geometry; only the reusable Layout frame is checked against the
explicit/prototype contract. Any mismatch fails export without replacing the
requested output.
This gate applies only to `template`; `baseline`, `preserve`, and `flat` retain
their existing behavior.
registered through its Master and the Presentation. Physical Slide/Layout/
Master part rosters, their content-type overrides, and their Presentation/
Master registrations must be exact. It also verifies the Layout picker name,
Master picker identity, placeholder type and effective index, matching `p:hf` flags, explicit design-zone frame, direct prompt size, and level-one default size.
Every owned `p:bg` is checked as an exact zero-or-one payload against the pre-
promotion result; this includes preserving the base Master background when no
authored Master background replaces it. During the same export, every finished
Slide, Layout, and Master must reproduce its exact top-level shape-name roster
and order after packaging. The gate verifies that each carrier-bound slot owns the expected Slide binding, each composite visible carrier remains ordinary, and every composite binding proxy is hidden. A zero-slot Layout must read back with no placeholder. Later slides may keep different Slide-local geometry; only the reusable
Layout frame is checked against the explicit/prototype contract. Any mismatch
fails export without replacing the requested output.
**Static structure consistency**: Repeat the same master element ids on every
slide and the same layout element ids on every slide sharing a layout. Their
generated OOXML must be identical within the affected master/layout group.
Static structure may carry shapes, text, or images; non-image/external
relationships are rejected. A full-canvas first rect/group may be marked as a
master or layout background.
Static structure may carry shapes, text, or images; non-image/external relationships are rejected. Every static object is atomic; a `<g data-pptx-layer="master|layout">` is forbidden. A full-canvas first rect may be marked as a Master or Layout background.
**Native object placeholders**: `chart` / `table` placeholders require
**Native object slot carriers**: `chart` / `table` slots require
`--native-objects`; fallback groups contain several shapes and cannot map to one
PowerPoint placeholder. `object` is the generic PowerPoint content slot and
must still resolve to one top-level DrawingML object. `media` currently binds
uses either one carrier object or the explicit composite proxy downgrade. `media` currently binds
an authored image/crop to a native `media` placeholder; it does not synthesize
video or audio media from a decorative SVG group.
### Legacy Preserved Source Master / Layout Contract
### Legacy Structure Migration Boundary
**Trigger**: An existing project already ships `native_structure.json` and `source_template.pptx`, has strict template adherence, and sets `pptx_structure.mode: preserve`. Current `create-template` output does not emit this pair; retain this contract only for backward compatibility.
Existing projects or packages that carry `native_structure.json` / `source_template.pptx`, `pptx_structure.mode: baseline|template|preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled` / `utility`, direct atomic placeholders, or no root Master identity must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation or export.
| Artifact | Authority |
|---|---|
| `source_template.pptx` | Original master/layout/theme/package parts |
| `native_structure.json` | Stable layout keys, picker names, parent masters, placeholder types/indices, source SHA-256 |
| `pptx_layouts` | Per-generated-page source layout selection |
| SVG metadata | Standalone preview layers and slide-content placeholder binding |
**Hard rule — source package wins**: Mark source master/layout visuals as direct `data-pptx-layer="master|layout"` preview children. Preserve export removes those generated copies and renders the original source parts. Unmarked content stays slide-local.
**Placeholder identity**: Keep actual content on the slide. Copy the source placeholder index into `data-pptx-placeholder-idx` when present; the exporter restores the source placeholder type/idx pair. Imported `subTitle`, `obj`, `media`, and `dt` placeholders retain distinct `subtitle`, `object`, `media`, and `date` semantic roles instead of collapsing into body/other. Multiple placeholders with the same semantic role require explicit indices.
**Multi-master boundary**: Preserve every source master already present in the package. Do not synthesize a new master merely for cover/section differences; rebuilt templates continue to prefer one master plus semantic layouts.
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-layout="content"
data-pptx-layout-name="Title and Content">
<rect id="master-bg" data-pptx-layer="master"
data-pptx-editable="false"
width="1280" height="720" fill="#F8FAFC"/>
<rect id="content-bg" data-pptx-layer="layout"
data-pptx-editable="false"
width="1280" height="720" fill="#FFFFFF"/>
<g id="content-rule" data-pptx-layer="layout"
data-pptx-editable="false">
<line x1="48" y1="96" x2="1232" y2="96"
stroke="#CBD5E1" stroke-width="2"/>
</g>
<text id="page-title" data-pptx-placeholder="title"
data-pptx-placeholder-bounds="80 112 1120 72"
x="80" y="158" font-size="40">Actual page title</text>
<image id="hero-image" data-pptx-placeholder="picture"
data-pptx-placeholder-bounds="680 210 480 320"
x="680" y="210" width="480" height="320"
href="../images/hero.png"/>
</svg>
```
When original PPTX/native facts exist, migration preserves the reachable source Master roster, Layout parent relationships and picker names, placeholder type/index/bounds, and visible supported geometry while normalizing the package into the explicit contract. Source Master/Layout groups are recursively flattened into atomic SVG elements. The current structured roster cannot materialize a source Layout that no output page references, or a Master reachable only through such Layouts; stop and report those identities instead of silently dropping them or inventing a carrier page. A subsequent `create-template` run treats the result according to its selected mode: `standard` / `fidelity` author a new topology, while mirror keeps the restored source topology only when the source graph satisfies that reachability boundary. When no native facts exist, the main Agent explicitly derives a structured contract from the complete SVG pages; the exporter never performs that derivation.
---
@@ -100,13 +100,13 @@ Write the locked value to `spec_lock.md` `- visual_style:` and the rationale to
> **Template vs preset**: a style mention may sound like a template name ("academic style" vs the `academic_defense/` template directory). Step 3 only triggers on an explicit template directory path supplied by the user — bare names and style words never copy templates; they map to a visual-style preset here. If a template was triggered upstream, its files are already in `<project_path>/templates/` and its fused design_spec governs.
**Legacy native contract**: When `<project_path>/templates/native_structure.json` exists, treat it as backward-compatible `preserve` input only. Current templates declare their PowerPoint structure directly in the SVG roster.
**Legacy template boundary**: A template containing `native_structure.json`, `source_template.pptx`, missing root Master identity, direct atomic placeholders, or old `baseline` / `preserve` / distillation metadata must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before Step 3. Do not plan against a legacy compatibility contract.
**Template adherence confirmation**: Surface this closed Stage-1 choice only when Step 3 loaded a `kind: deck` or `kind: layout` template. Omit it for free design and brand-only templates.
| Value | Planning and export behavior |
|---|---|
| `strict` | Map every page to one template SVG. Keep its explicit Master/Layout/placeholder contract and output layout key unchanged. |
| `strict` | Map every page to one template SVG. Keep its explicit Master/Layout/slot contract and output Layout key unchanged. |
| `adaptive` | Map every page to the closest template SVG. Keep the template Master contract, but allow a new explicit Layout key when the selected composition must adapt to the content. |
**Default — adaptive (may override when the user requests exact template use)**: Recommend `adaptive` when the user supplied only a template path with no stricter instruction. Preselect `strict` when the user explicitly asks to follow or preserve the template closely; the Stage-1 confirmation still records the final choice. Record the confirmed value in `design_spec.md §I` and as `template_adherence` under `spec_lock.md pptx_structure`.
@@ -845,17 +845,20 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
1. Read reference template: `templates/design_spec_reference.md`
2. Generate complete spec from scratch based on analysis
3. Save to: `projects/<project_name>.../design_spec.md`
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a compact, machine-readable form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** / **pptx_masters** / **pptx_layouts** decisions above. Master/Layout structure is part of this planning contract before the first SVG. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
- **pptx_structure is mandatory**: Write `mode: baseline` for free design and brand-only routes. When a deck/layout template is confirmed, write `mode: template` plus `template_adherence: strict|adaptive`. Both adherence values stay on explicit template export. Use `mode: preserve` only when reopening a legacy strict project that already carries a validated `native_structure.json` + `source_template.pptx` pair. Do not choose `flat`; it is a CLI-only diagnostic override.
- **pptx_layouts (template/preserve structure)**: In template mode, write one `P<NN>: <layout_key> | <PowerPoint layout name>` row for every page. Strict uses the selected template SVG's exact key/name. Adaptive may introduce a new stable key/name, but pages reuse a key only when their static Layout layer and placeholder contract match. In legacy preserve mode, use the exact source key/name. Omit the whole section in baseline mode.
- **page_layouts (deck/layout templates only)**: Add one `P<NN>: <svg_basename>` row for every page. Strict inherits that SVG contract unchanged. Adaptive uses it as the architecture reference and may evolve the Layout layer under a new `pptx_layouts` key; it must not drop into free design. Omit the section only for free design and brand-only routes.
- **pptx_structure is mandatory**: Every new project writes `mode: structured`. Free design and brand-only routes default to one Master; add another only when the design genuinely needs it. Deck/layout templates also record `template_adherence: strict|adaptive`. Do not write `baseline`, `template`, `preserve`, `flat`, `layout_strategy`, or Layout-kind rows into a new project.
- **Master roster**: Write one `pptx_masters` row per Master as `<master_key>: <picker name>`. For a template, copy the restored prototype roster. For free design, define stable deck-wide background/chrome atoms at planning time; Master visuals are root-level atomic elements and may never be `<g>`.
- **pptx_layouts (native output contract)**: Write exactly one row per page before generation as `<master_key> | <layout_key> | <PowerPoint layout name>`. Plan semantic Layout families from the outline and reuse a key only when fixed Layout atoms plus slot ids/types/indices/bounds/binding modes are identical. Name each key after its composition (`timeline-spine`, `kpi-band-trio`, `content-image-right`), never after a PowerPoint stock role or the page's topic. A Layout may intentionally have zero slots; never manufacture an empty `utility` kind or full-page fake slot. **Self-check before writing the section**: compare distinct keys against the distinct compositions in §IX. Distinct compositions collapsing into a handful of stock-role keys (`cover` / `title-content` / `section`) means pages were clustered by role; one shared skeleton splitting into per-topic keys (`allocation-fengqing` / `allocation-luoping`) means pages were split by topic. Both misstate the roster — re-derive from composition.
- **Slot planning**: Each reusable slot is a direct root `<g id>` with `data-pptx-placeholder`, positive design-zone bounds, and exactly one compatible direct carrier. Bounds come from the intended safe area, column, panel inset, or media frame—not sample text ink. A genuinely composite region may use only the explicit `object` + `proxy` downgrade.
- **Adaptive refinement boundary**: The initial mapping is complete, not provisional. If actual adaptive construction genuinely changes reusable framing or slot topology/bounds, Executor creates a new Layout key and updates `spec_lock.md` while authoring that first page. It never mutates a reused contract silently. Export only compiles declared structure and never discovers or clusters Layouts.
- **page_layouts (deck/layout templates only)**: Add one `P<NN>: <svg_basename>` row for every page. This is the complete input-prototype mapping. Strict preserves that SVG's structural contract; adaptive keeps its Master and may explicitly assign a new output Layout identity during page authoring. Omit the whole section for free-design and brand-only routes.
- **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/<name>.svg`, add `P<NN>: <chart_name>`. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section.
- **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible template shell. For a chart page without an exact roster match, adaptive mode selects the closest neutral content template and assigns a new explicit Layout key; strict mode must select an existing compatible Layout or revise the outline. Never omit `page_layouts` inside a deck/layout template route.
- **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible template shell. For a chart page without an exact roster match, adaptive mode selects the closest neutral input prototype and assigns an explicit output Layout key in the planning map; strict mode must select an existing compatible Layout or revise the outline. Never omit `page_layouts` inside a new deck/layout template route.
---
@@ -4,32 +4,34 @@
## Core Mission
Generate reusable page templates at the output scope confirmed by `create-template`, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
Generate reusable page templates inside the complete workspace selected by `create-template`, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
> This is a standalone role: only triggered via the `/create-template` workflow. It may write a global library package or a project-local thin bundle; it is not the template selection step in the main PPT generation pipeline.
> This is a standalone role: only triggered via the `/create-template` workflow. Library and project outputs use one workspace shape; it is not the template selection step in the main PPT generation pipeline.
## Usage
- **Trigger**: `/create-template` workflow
- **Output location**: `library` (default) → `skills/ppt-master/templates/<kind_dir>/<template_name>/`; `project` → the confirmed `<target_project>/templates/` root with no nested template-ID directory
- **Workspace root**: `library` (default) → `skills/ppt-master/templates/<kind_dir>/<template_name>/`; `project` → the confirmed `<target_project>/`
- **Template source**: `<template_workspace>/templates/` in both scopes
- **Input**: finalized template brief (output scope, target project when project-scoped, template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms)
**Hard rule — scope is execution metadata**: Use `output_scope` and `target_project` to route files, but do not write either field into portable `design_spec.md` frontmatter. Do not create a new PPTX structure mode; deck/layout output still declares `native_structure_mode: template`.
**Hard rule — scope is execution metadata**: Use `output_scope` and `target_project` to route files, but do not write either field into portable `design_spec.md` frontmatter. Do not create a new PPTX structure mode; deck/layout output declares `native_structure_mode: structured`.
**Project-scope precondition**: The workflow has already confirmed an initialized target project, an empty `<target_project>/templates/`, and collision-free destination filenames in `images/` and `icons/`. Do not begin final writes before that all-at-once preflight passes.
**Workspace precondition**: The workflow has already resolved the selected root, confirmed an empty `<template_workspace>/templates/`, and checked collision-free destination filenames in `images/`, `icons/`, and `templates/icons/`. Check `exports/` only when an on-demand review PPTX was requested. Optional directories may be absent until their first real file is written. Project scope additionally requires an initialized target project. Do not begin final writes before that all-at-once preflight passes.
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
- finalized template brief
- `manifest.json` — single source of truth (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates)
- `manifest.json` — single source of truth for source-deck facts (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates)
- `native_structure.json` — stable source master/layout keys, picker names, parent-master relationships, placeholder type/index/geometry, source hash, and source-graph quality facts
- `source_template.pptx` — byte-preserved analysis copy for visual/package cross-checking; never a final template asset
- `summary.md` — short orientation digest derived from manifest.json
- exported `assets/`
- `svg/master_*.svg` / `svg/layout_*.svg` — every master / layout in the deck rendered once as standalone SVG, including ones no sample slide references (template packages often ship more design surfaces than the embedded samples exercise)
- `svg/slide_NN.svg`each slide's own shapes and slide-local background only; master / layout decoration and backgrounds are **not** inlined here
- `svg/master_*.svg` / `svg/layout_*.svg` lossless layered import evidence; every master / layout in the deck rendered once, including ones no sample slide references
- `svg/slide_NN.svg`lossless slide-local import evidence; do not bulk-read because opaque native payload is retained
- `svg/inheritance.json` — which layout / master each slide consumes
- `svg-flat/slide_NN.svg`companion view; each slide is self-contained so you can preview or screenshot a single page without losing the surrounding chrome. Use it as a sanity check for "what would PowerPoint actually show", not as an authoring source — the master/layout chrome will be duplicated across every flat slide.
- `svg-flat/slide_NN.svg`lossless complete-page import evidence; use it as mirror restoration authority, not as model-facing context
- `authoring-svg/` / `authoring-svg-flat/` — lightweight non-destructive projections created by `svg_authoring_view.py`; these are the model-facing layered and complete-page views
- optional screenshots for visual cross-checking
PPTX import interpretation:
@@ -38,20 +40,38 @@ PPTX import interpretation:
- Charts, SmartArt, diagrams, and OLE objects may appear as typed placeholders in layered SVGs. In flat SVGs they may show preview images. Treat them as source intent markers, not reusable decorative assets.
- The asset filenames referenced by SVGs are governed by the manifest asset map. Prefer those references over inventing duplicate asset names.
Input priority for PPTX-backed template creation:
Input priority for PPTX-backed template creation depends on replication mode:
1. `manifest.json` for all factual metadata (theme, assets, unique layout/master structure, slide reuse, page-type guidance)
2. `native_structure.json` for source PowerPoint identities, placeholder indices, relationship completeness, and reconstruction facts
3. `svg/master_*.svg` + `svg/layout_*.svg` — the **primary source for the deck's shared visual language**: backgrounds, page chrome, decorative bars, recurring brand motifs. These are what the new template's fixed structure should adopt or reinterpret. Read these before any slide SVG.
4. `svg/inheritance.json` for confirming which slide uses which layout / master
5. exported `assets/` for reusable visual resources
6. `svg/slide_NN.svg` — each slide's unique content, useful for judging composition rhythm and content density (not for fixed structure)
7. `summary.md` only as a fast scan; never as the canonical fact source
8. screenshots / original PPTX only for style verification
| Mode | Authoritative inputs | Model-facing inputs |
|---|---|---|
| `standard` / `fidelity` | Finalized brief for the newly designed output; `manifest.json` for factual canvas/theme/assets | Lightweight `authoring-svg-flat/` pages and exported assets as visual references. Source Master/Layout topology is informational only and is not mined into output structure. |
| `mirror` | `manifest.json`, `native_structure.json`, lossless layered `svg/`, lossless complete-page `svg-flat/`, and `svg/inheritance.json` | Matching lightweight `authoring-svg/` and `authoring-svg-flat/` projections for inspection. The projections never replace the lossless restoration source. |
**Native structure output**: Always set `native_structure_mode: template`. Do not ship `native_structure.json` or `source_template.pptx`. Reconstruct one clean Master plus semantic Layouts through explicit SVG structure metadata. Every page remains a complete standalone SVG preview.
Use `summary.md` only for orientation. Open screenshots or the original PPTX only for visual cross-checking.
**Downstream boundary**: The Strategist confirmation stage selects `strict` or `adaptive` when a project consumes this package. Both export through explicit template mode. Strict keeps the referenced Layout contract; adaptive may create a new Layout while retaining the template Master. Template_Designer does not preselect that project-level choice.
**Native structure output**: Always set `native_structure_mode: structured`.
| Mode | Output structure contract |
|---|---|
| `standard` / `fidelity` | Author new SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. Use compact canonical metadata for authored objects. |
| `mirror` | Restore the source graph one-to-one: keep source Master/Layout identities and parentage, slide assignments, placeholder type/index/bounds, and supported visual/native-object facts. Mechanical normalization maps fixed-layer source groups into the direct atoms required by the current explicit SVG contract while preserving ownership, paint order, and appearance; it must not semantically redesign the graph. |
Every page remains a complete standalone SVG preview.
**Hard rule — mirror graph reachability**: The current structured template
compiler materializes only Master/Layout identities referenced by emitted SVG
prototypes. Before accepting `mirror`, verify that every source Layout has a
non-empty `usedBySlides` set and that every source Master owns at least one such
Layout. If the source package contains an unused Layout or an otherwise
unreachable Master, stop and report that full-graph mirror is not representable
by the current template roster. Never silently drop it, merge it, or invent a
carrier page.
**Hard rule — no duplicate authored Layout contracts**: In `standard` / `fidelity`, distinct output Layout keys must differ in fixed Layout atoms or slot topology/type/index/bounds/binding. Topic, sample wording, or Slide-local content alone never justifies another authored key. Mirror keeps source Layout identities even when two source contracts are visibly equivalent.
**Downstream boundary**: The Strategist confirmation stage selects `strict` or `adaptive` when a project consumes this package. Both export through `pptx_structure.mode: structured`. Strict keeps the referenced Layout contract; adaptive may create a new Layout identity while retaining the template Master. Template_Designer does not preselect that project-level choice.
For `mirror`, `design_spec.md §V` must be followed by a `Source Restoration Map` that records each source slide's Master/Layout assignment and output file. The map is evidence of one-to-one preservation, not a design-decision log. `standard` and `fidelity` record only their newly authored output roster and structure; do not add a source-topology disposition table.
---
@@ -62,8 +82,10 @@ The output page set is determined by **replication mode**, declared in the final
| Mode | When to use | Roster |
|------|-------------|--------|
| `standard` (default) | Most templates — clean, reusable, balanced coverage | `01_cover`, `02_chapter`, `03_content`, `04_ending`, optional `02_toc` |
| `fidelity` | User explicitly wants strict replication of a source PPTX, but still wants the AI to clean / cluster / cap variants | Standard roster + one variant per distinct layout cluster found in `manifest.json` |
| `mirror` | Creation-time harvesting mode: every source slide keeps literal visual geometry while gaining explicit Master/Layout metadata | One SVG per source slide, named `<NNN>_<page_type>.svg` by source order |
| `fidelity` | User wants a broader, source-aligned but newly designed template | Standard roster + intentionally designed variants that cover useful reference compositions |
| `mirror` | User wants the source template restored | One SVG prototype restored from the lossless source per source slide, named `<NNN>_<page_type>.svg` by source order |
**Hard rule — mode controls authorship**: `standard` and `fidelity` create new SVG documents and their own Master/Layout system. `mirror` restores the imported source contract and must not reauthor, distill, or reinterpret its structure.
### Standard mode
@@ -81,7 +103,7 @@ The output page set is determined by **replication mode**, declared in the final
### Fidelity mode
When the brief sets `Replication mode: fidelity`, derive the page roster from `manifest.json` page-type clusters and emit one SVG per distinct visual cluster.
When the brief sets `Replication mode: fidelity`, design a broader reusable roster that stays close to the source's visual language and useful composition examples. The output Master/Layout system is authored independently from source topology.
**Variant naming**: append a lowercase letter suffix to the parent type's index, preserving sort order:
@@ -95,27 +117,28 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
**Roster decision**:
- Cluster slides from `manifest.json` by `pageType` + visual structure (column count, hero-image vs. icon-grid vs. quote, etc.)
- One SVG per cluster — do **not** emit a variant for a cluster represented by a single source slide unless that slide is structurally distinct from existing variants
- One variant per visually distinct cluster — let the source's structural diversity drive the count. Collapse only **near-duplicates** (same column count, same hero element, same content density); do not collapse genuine structural differences just to keep the variant count down. If you find yourself wanting one variant per source slide, that is the signal the user should be in `mirror` mode, not `fidelity`
- Record every emitted page in `design_spec.md §V Page Roster`; in library scope, `register_template.py` generates the corresponding index entry from the directory's actual SVG files. Project scope skips registration
- Choose variants from useful visual composition types such as two-column content, hero image, icon grid, data card, and quote
- Keep only variants that add a genuinely useful authored composition; source Layout keys and repeated source chrome are not clustering inputs
- Design each variant's Master/Layout/slot contract directly from its intended reusable behavior
- Record every emitted page in `design_spec.md §V Page Roster`; in library scope, `register_template.py` generates the corresponding index entry from `<template_workspace>/templates/*.svg`. Project scope skips registration
> Variants reuse the parent type's placeholder set — see §4 (Placeholder Reference) below.
### Mirror mode
When the brief sets `Replication mode: mirror`, preserve literal page appearance while reconstructing layer ownership:
When the brief sets `Replication mode: mirror`, restore the imported template rather than designing a new one:
- Visual source: `<import_workspace>/svg-flat/slide_NN.svg` (the self-contained "what PowerPoint shows" view). Structural source: `svg/master_*.svg`, `svg/layout_*.svg`, and `svg/inheritance.json`.
- Output: `<template_target>/<NNN>_<page_type>.svg`, where `<template_target>` is the library package directory or the project `templates/` root. `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates``cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
- Required metadata rewrite: declare the output Layout on the root, mark inherited Master/Layout visuals as direct preview layers, and map source content slots to semantic `data-pptx-placeholder` markers where the imported contract exposes them. Add `data-pptx-role` only to structural page-frame objects whose behavior is not already expressed by those specialized markers.
- Other allowed modifications: rewrite `<image href="...">` paths to local assets and rename assets semantically. Keep geometry, decoration, sprite-sheet wrappers, original example text, chart previews, and fonts visually unchanged.
- Forbidden: simplifying decorative complexity, merging similar slides, or dropping inherited preview chrome.
- `design_spec.md` §V Page Roster lists every emitted file with a one-line content-fit description; SVG metadata owns native reconstruction.
- Restoration source: lossless `svg/master_*.svg`, `svg/layout_*.svg`, `svg/slide_NN.svg`, `svg-flat/slide_NN.svg`, `svg/inheritance.json`, and `native_structure.json`. Use matching lightweight projections only to inspect the result without loading opaque payload into model context.
- Precondition: every source Layout is referenced by at least one source slide, and every source Master is reachable through those referenced Layouts. Otherwise report that full-graph mirror is currently unsupported and stop before writing output.
- Output: `<template_workspace>/templates/<NNN>_<page_type>.svg` in both scopes. `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates``cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
- Required restoration: preserve source Master/Layout keys and picker names, Layout-to-Master parentage, slide assignments, placeholder type/index/bounds, supported native-object metadata, geometry, decoration, sprite-sheet wrappers, original example text, chart previews, fonts, effects, and paint order whenever the importer represents them.
- Allowed normalization: add or normalize explicit root declarations and asset paths, and recursively expand fixed Master/Layout group wrappers into direct atoms. The mapping must remain one-to-one at the ownership level and must not change paint order or appearance.
- Forbidden: commonality extraction, semantic synthesis, merging, splitting, promotion, demotion, renaming, re-parenting, decorative simplification, placeholder invention, or replacement of supported source-native metadata / SVG fallback with a model-authored approximation.
- `design_spec.md` §V Page Roster lists every emitted file, and `Source Restoration Map` records the preserved source Master/Layout assignment.
**Mirror consumption boundary**: `mirror` applies only while creating the template package from the source deck. Once created, the package is consumed as an ordinary deck / layout template roster: downstream generation may select, repeat, skip, reorder, or adapt pages according to the new content. The `replication_mode: mirror` field must not force the generated deck to preserve the source page count, source order, or one-output-slide-per-template-slide mapping.
**What mirror is not**: a pixel-perfect re-rendering pipeline test. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — the user should be told before generation begins.
**What mirror is not**: a redesign or topology-cleanup mode. It may mechanically transcode the imported representation into the current explicit SVG/package contract, so byte identity is not promised. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — report them before restoration begins.
---
@@ -155,7 +178,7 @@ source_canvas_height: 720
source_viewbox: "0 0 1280 720"
replication_mode: standard | fidelity | mirror
# Required for every deck/layout template. Source packages remain analysis-only.
native_structure_mode: template
native_structure_mode: structured
# Optional — only when this template overrides canonical placeholder vocabulary.
# Omit only when the page truly exposes no replaceable content slots.
# placeholders:
@@ -184,7 +207,9 @@ native_structure_mode: template
- Optional XML snippet for any reusable component unique to this template
## V. Page Roster
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). Record the rebuilt Layout key and PowerPoint picker name. For `fidelity` mode, note the cluster source and visual differentiator. For `mirror` mode the roster is the load-bearing content-fit index, so each row must distinguish siblings by column count, hero element, density, and suitable content. Roster entries must match the actual SVG files on disk.
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). For `standard` / `fidelity`, record the newly authored Layout key and PowerPoint picker name. For `mirror`, record the restored source Master/Layout keys and picker names without redesigning them. Roster entries must match the actual SVG files on disk.
For `mirror`, add `### Source Restoration Map` immediately after the roster with columns `Source slide`, `Source Master`, `Source Layout`, `Output SVG`, and `Restoration status`. This is a one-to-one mapping record. Do not add synthesis rationale or source-structure disposition rows to `standard` / `fidelity` templates.
## VI. Assets (omit when none)
Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage.
@@ -198,11 +223,11 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
| Don't write | Source |
|---|---|
| General SVG technical / compatibility rules | `shared-standards.md` |
| Generic layout pattern library (centered card / 三栏 / timeline / …) | `design_spec_reference.md` §V |
| Generic layout pattern library (centered card / three-column / timeline / …) | `design_spec_reference.md` §V |
| Generic spacing bands (margin 40-60px, card gap 20-32px, etc.) | `design_spec_reference.md` §V |
| Generic font-size hierarchy (cover 2.5-5x body, page title 1.5-2x, …) | `design_spec_reference.md` §IV |
| Canonical placeholder table (`{{TITLE}}`, `{{PAGE_NUM}}`, …) | §4 below |
| Content methodology (pyramid / SCQA / MECE / 金字塔) | `strategist.md` |
| Content methodology (pyramid / SCQA / MECE) | `strategist.md` |
| "Usage Instructions" boilerplate (copy template / select page / …) | `create-template.md` |
| Created Date / Page Count rows | not a library-level field |
@@ -228,66 +253,96 @@ If PPTX import output exists:
**Precondition**:
- When PPTX import output is provided, do not generate any template SVG or `design_spec.md` until every file under `<import_workspace>/svg/` has been read — including `master_*.svg`, `layout_*.svg`, and every `slide_*.svg`
- Before template generation begins, explicitly report the read slide indexes
- For `standard`, inspect enough lightweight complete-page projections to understand the requested visual direction and reusable assets; do not analyze source topology.
- For `fidelity`, inspect every lightweight complete-page projection so the newly designed roster covers the useful source composition range; do not derive output ownership from source Master/Layout recurrence.
- For `mirror`, verify every projected Master, Layout, and Slide against `native_structure.json` and `svg/inheritance.json`, then restore from the matching lossless files. Before restoration begins, report the verified source slide indexes.
### 2.1 PPTX Import Simplification Rule
### 2.1 PPTX Import Mode Rule
The imported PPTX is a **reference source**, not a direct conversion target.
The imported PPTX has a different authority level in each replication mode.
Do:
- preserve brand assets, recurring backgrounds, and stable structural motifs
- rebuild the layout into a clean SVG structure aligned with PPT Master constraints
- simplify repeated decorative fragments into a smaller number of maintainable SVG elements
- use a background image asset when the original decorative layer is too complex to recreate cleanly
- use cleaned slide SVG references to inspect composition, spacing, text hierarchy, and fixed decorative structure only after factual metadata has been anchored
- read every reference SVG under `svg/``master_*.svg`, `layout_*.svg`, and every `slide_*.svg` regardless of slide count. Master / layout files describe the deck's shared visual language (read first); slide files describe per-page content (read after). Partial coverage drops template fidelity.
- rename adopted assets to semantic names (`cover_bg.png`, `brand_emblem.png`) rather than carrying raw `image3.png` into the final template
| Mode | Required behavior |
|---|---|
| `standard` | Use source visuals/assets as references, then author the compact canonical roster and its Master/Layout/slot structure from the confirmed brief. Do not preserve or distill source topology. |
| `fidelity` | Use the complete visual roster as design reference, then author a broader canonical roster and its own Master/Layout/slot structure. Match the source visual language closely, but do not cluster, merge, or split source Layouts into output families. |
| `mirror` | Restore source pages, inheritance, placeholders, native objects, and visuals from the lossless import. Do not simplify, redesign, rename structure, or infer new common structure. |
Do not:
- attempt 1:1 translation of every PowerPoint shape, group, shadow, or decorative fragment
- mirror PPT-specific complexity when it makes the resulting SVG brittle or hard to edit
- introduce dense low-value vector detail that does not materially improve template reuse
**Hard rule — restoration is mechanical**: Mirror may normalize namespaces,
portable asset paths, explicit root declarations, and fixed-layer group wrappers
required by the current compiler. Expanding a source Master/Layout group must
produce direct atoms with the same ownership, transforms, paint order, and
appearance. A maintainability preference is not authority to alter the source
template.
### 2.2 Native Shape Payload and Authoring Projection
| Representation | Purpose | Payload rule |
|---|---|---|
| Lossless import SVG | Round-trip and mirror-restoration source | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity. It is authoritative for mirror restoration. |
| Lightweight authoring projection | Model-readable inspection surface | Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and logical ids needed to locate an adopted object in the lossless import |
| `standard` / `fidelity` output | Newly authored contract | Use compact canonical metadata for new shapes. Reuse exported image/vector assets, not opaque source shape payload or source topology. |
| `mirror` output | Restored contract | Keep currently supported imported metadata on unchanged Slide-local/slot objects. Normalize fixed structural layers into direct atoms from the lossless source. The projection never becomes the restoration source. |
**Validation**: Mirror does not silently use stale metadata or a lightweight
projection. If an imported object cannot use the converter's supported native
metadata after normalization, keep its current SVG fallback and report the
limitation. `standard` / `fidelity` regenerate compact canonical shapes instead
of transplanting opaque source payload. `data-pptx-native` remains reserved for
native chart/table markers.
**Explicit template SVG contract**:
| Rebuilt fact | Template SVG declaration |
| Authored/restored fact | Template SVG declaration |
|---|---|
| Output semantic layout | Root `data-pptx-layout` + stable `data-pptx-layout-name` |
| Rebuilt master/layout visual | Direct preview child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"` |
| Semantic content slot | Direct content child with `data-pptx-placeholder`; add `data-pptx-placeholder-idx` when same-role slots require stable disambiguation |
| Master/Layout identity | Root `data-pptx-master` / `data-pptx-master-name` plus `data-pptx-layout` / `data-pptx-layout-name`; authored keys for `standard` / `fidelity`, source keys for `mirror` |
| Authored Master/Layout visual | In `standard` / `fidelity`, use a direct atomic child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"`; `<g>` is forbidden for a structural layer |
| Restored Master/Layout visual | In `mirror`, recursively expand each fixed-layer source group into direct atoms with the same Master/Layout ownership, transforms, styles, paint order, and appearance; semantic regrouping is forbidden |
| Content slot | Direct `<g id>` with `data-pptx-placeholder` and explicit `data-pptx-placeholder-bounds`; `standard` / `fidelity` author the slot, while `mirror` preserves source type/index/bounds and carrier identity |
| Page-only background | Direct full-canvas solid rect with `data-pptx-layer="slide"` |
| Structural page-frame hint | Optional `data-pptx-role` only when background/decoration/header/footer/logo/watermark/chrome/page-number behavior is not already expressed by layer/placeholder metadata; stable unique `id` required |
Repeat inherited visuals in every standalone SVG so browser preview remains complete. Template export validates their equality, moves one copy into the generated Master/Layout parts, and removes the repeated Slide copies. Do not flatten inherited visuals into unmarked slide content.
Repeat inherited visuals in every standalone SVG so browser preview remains complete. Template export validates their equality and restores or creates the declared Master/Layout parts. It does not infer ownership.
Use the imported placeholder types verbatim: `title`, `subtitle`, `body`,
`picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, and
`slide-number`. In particular, do not collapse source `subTitle`, `obj`,
`media`, or `dt` placeholders into a generic body marker. A reconstructed
title normally has no index. Assign stable indices only when repeated roles need
disambiguation inside the rebuilt Layout.
**Forbidden — legacy structure contract**: Do not carry `data-pptx-layout-kind`, `distilled`, `utility`, unmapped `baseline`, `preserve`, or direct atomic placeholders into a reusable template package. Route that source through [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) first.
**Composite slot boundary**: A normal slot group has exactly one compatible direct carrier. Only a genuinely composite region may declare `data-pptx-placeholder="object"` with `data-pptx-placeholder-binding="proxy"`; the visible group stays Slide-local and export creates a hidden transparent binding proxy. Do not use proxy binding as the default template slot form.
In `mirror`, preserve imported placeholder types, indices, bounds, and carrier
identity exactly when the importer supports them. Do not replace source
`subTitle`, `obj`, `media`, or `dt` roles with generic body content. In
`standard` / `fidelity`, assign the canonical authored types deliberately:
`title`, `subtitle`, `body`, `picture`, `chart`, `table`, `object`, `media`,
`date`, `footer`, and `slide-number`. An authored title normally has no index;
assign stable indices only when repeated roles need disambiguation inside the
new Layout.
**Hard rule — explicit design-zone bounds**: Every slot carries `data-pptx-placeholder-bounds="x y width height"`. Mirror uses the source Layout placeholder frame. `standard` / `fidelity` author bounds from the intended safe area, column, panel inset, or media frame. Do not use character count, glyph width, current wrapping, or the tight sample-content box. An authored Layout may intentionally have zero slots.
### 3. Placeholder Markers
> Mirror may retain literal example text instead of `{{...}}` authoring markers, but imported semantic content slots still receive native `data-pptx-placeholder` metadata. The rest of this section defines the preferred authoring vocabulary for standard and fidelity modes.
> Mirror retains literal source example text and source placeholder metadata. It does not insert `{{...}}` markers. The rest of this section defines the preferred authoring vocabulary for standard and fidelity modes.
Use clear placeholder markers for replaceable content:
```xml
<!-- Text placeholder -->
<text id="title-slot" data-pptx-placeholder="title"
x="80" y="320" fill="#FFFFFF" font-size="48" font-weight="bold">
{{TITLE}}
</text>
<!-- Text slot -->
<g id="title-slot" data-pptx-placeholder="title"
data-pptx-placeholder-bounds="80 280 1120 96">
<text id="title-carrier" data-pptx-placeholder-carrier="true"
x="80" y="320" fill="#FFFFFF" font-size="48" font-weight="bold">
{{TITLE}}
</text>
</g>
<!-- Content area placeholder (content page only) -->
<rect x="40" y="90" width="1200" height="550" fill="#FFFFFF" rx="8"/>
<text id="body-slot" data-pptx-placeholder="body"
data-pptx-placeholder-bounds="40 90 1200 550"
x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">
{{CONTENT_AREA}}
</text>
<g id="body-slot" data-pptx-placeholder="body"
data-pptx-placeholder-bounds="40 90 1200 550">
<text id="body-carrier" data-pptx-placeholder-carrier="true"
x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">
{{CONTENT_AREA}}
</text>
</g>
```
### 4. Placeholder Reference (canonical convention, overridable per template)
@@ -334,7 +389,7 @@ Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` fo
Variants reuse their parent type's placeholder set by default: every `03*_content*.svg` shares the content placeholder list above, unless the spec frontmatter declares an override for that specific stem.
When rebuilding from imported PPTX references, placeholder insertion takes priority over visual mimicry. If the original layout leaves insufficient room for canonical placeholders, adjust the layout instead of inventing one-off placeholder families — or, if the deviation is intentional and meaningful, declare it in frontmatter.
For `standard` / `fidelity`, canonical placeholder insertion takes priority over visual mimicry; adjust the newly designed layout or declare an intentional vocabulary override. Mirror preserves the source placeholders and literal text instead of inserting canonical authoring markers.
---
@@ -342,24 +397,37 @@ When rebuilding from imported PPTX references, placeholder insertion takes prior
### File Save Location
Library scope keeps the existing self-contained package. Standard mode (default):
Both scopes use one complete workspace shape. Only the workspace root differs:
| Scope | `<template_workspace>` |
|---|---|
| `library` | `skills/ppt-master/templates/<kind_dir>/<template_name>/` |
| `project` | `<target_project>/` |
Standard mode (default):
```
templates/<kind_dir>/<template_name>/
├── design_spec.md # Design specification (required)
├── 01_cover.svg
├── 02_chapter.svg
├── 02_toc.svg # Optional
├── 03_content.svg
├── 04_ending.svg
├── icons/ # Extracted vector assets (if any)
└── *.png / *.jpg # Bitmap assets (if any)
<template_workspace>/
├── templates/
│ ├── design_spec.md
├── 01_cover.svg
├── 02_chapter.svg
├── 02_toc.svg # Optional
├── 03_content.svg
│ ├── 04_ending.svg
│ └── icons/ # Package/validation copy, when used
├── images/ # Optional; omit when unused
│ └── *.png / *.jpg # SVG href is ../images/<name>
├── icons/ # Optional; omit when unused
│ └── *.svg # Runtime copy, when used
└── exports/ # Optional; created only for on-demand review
└── <template_id>_template_preview.pptx
```
Fidelity mode adds variants and extension pages in the same package, e.g.:
Fidelity mode changes only the roster under `templates/`, e.g.:
```
templates/<kind_dir>/<template_name>/
<template_workspace>/templates/
├── design_spec.md
├── 01_cover.svg
├── 02a_chapter_full.svg
@@ -369,14 +437,13 @@ templates/<kind_dir>/<template_name>/
├── 03b_content_data_card.svg
├── 03c_content_quote.svg
├── 04_ending.svg
── 05_section_break.svg
└── *.png / *.jpg
── 05_section_break.svg
```
Mirror mode emits one SVG per source slide, named by source order:
```
templates/<kind_dir>/<template_name>/
<template_workspace>/templates/
├── design_spec.md
├── 001_cover.svg
├── 002_toc.svg
@@ -386,41 +453,24 @@ templates/<kind_dir>/<template_name>/
├── 006_content.svg
├── ...
├── 049_content.svg
── 050_ending.svg
└── *.png / *.jpg
── 050_ending.svg
```
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. Literal source text may remain, but every page still carries explicit native structure metadata.
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. Literal source text and the source native structure are restored; the lightweight projection is not copied into the output.
Project scope writes directly into an initialized project's existing roots. The roster names still follow the selected replication mode, but the package is not nested under `<template_name>`:
```
<target_project>/
├── templates/
│ ├── design_spec.md
│ ├── 01_cover.svg
│ ├── 02_chapter.svg
│ ├── 03_content.svg
│ ├── 04_ending.svg
│ ├── ... # Fidelity variants or mirror pages
│ └── icons/ # Package/validation copy
├── images/
│ └── *.png / *.jpg # Bitmap assets; template SVG href is ../images/<name>
└── icons/
└── *.svg # Identical runtime copy of extracted icons
```
**Hard rule — project routing**: Keep `design_spec.md`, template SVGs, and non-bitmap package assets in `templates/`; place every bitmap in `images/`; duplicate each extracted icon into `templates/icons/` and runtime `icons/`. Do not write bitmaps into the project template root and do not create `<target_project>/templates/<template_name>/`. This output belongs only to the target project; use library scope for a self-contained cross-project package.
**Hard rule — common routing**: Keep `design_spec.md`, template SVGs, and non-bitmap template-source assets in `templates/`; place every bitmap in `images/`; duplicate each extracted icon into `templates/icons/` and runtime `icons/`. Write a review deck to `exports/` only when explicitly requested. Create optional directories only when they contain real files; never add placeholders for empty directories. Do not branch asset placement by output scope.
### Template Preview
After each template is generated, provide a brief summary table listing each template's status.
When the user requests a PowerPoint review file, run `template_preview_pptx.py <template_workspace>` after SVG validation. The command creates `exports/` on demand and verifies one slide per SVG prototype plus the expected Master/Layout counts. The first export refuses a collision; an intentional post-fix replacement uses `--force`. The review PPTX is derived evidence and never a template-application input.
When a review deck was generated, include its path in the completion summary. Otherwise omit `exports/` from the workspace inventory.
If the template is based on PPTX import output, briefly note:
- which extracted assets were reused directly
- which complex original decorations were intentionally simplified
- whether any page-type mapping required judgment beyond the import heuristic
- how the source Master/Layout graph was consolidated into the rebuilt Master and semantic Layout roster
- for `standard` / `fidelity`, which visual references influenced the newly authored roster
- for `mirror`, whether any source feature could not be restored and the exact affected source object/page
- whether any page-type filename mapping required judgment beyond the import heuristic
---
@@ -428,13 +478,13 @@ If the template is based on PPTX import output, briefly note:
If suitable template resources already exist, use them directly instead of generating new ones:
1. **Copy template**: copy the spec + explicitly layered template SVGs into the project's `templates/`, and any bundled bitmaps into the project's `images/` (the runtime image pool, referenced as `../images/`).
1. **Copy template workspace**: copy or stage `templates/` plus any existing `images/` and `icons/`; exclude `exports/` from template application.
2. **Adjust colors**: Modify colors per the project design spec
3. **Customize**: Make project-specific adjustments
This section describes downstream reuse of an existing library package. The `Template_Designer` role may instead create the same contract directly in project scope when `create-template` selected that output.
This section describes downstream reuse of an existing workspace. Library and project scopes carry the same portable template contract.
**Example library structure** (query the appropriate kind's index — `templates/layouts/layouts_index.json` for structure-only templates, `templates/decks/decks_index.json` for full-PPT replicas, `templates/brands/brands_index.json` for identity-only presets):
**Example library structure** (query the appropriate kind's index — `templates/layouts/layouts_index.json` for structure-only templates, `templates/decks/decks_index.json` for complete identity + structure templates, `templates/brands/brands_index.json` for identity-only presets):
```
templates/
@@ -445,8 +495,8 @@ templates/
│ ├── academic_defense/ # Academic-defense structure (no identity)
│ └── pixel_retro/ # Pixel retro / cyberpunk structure (no identity)
└── decks/
├── 招商银行/ # China Merchants Bank full PPT replica
└── 中国电建/ # PowerChina full PPT replica
├── <bank_deck>/ # Example banking deck
└── <engineering_deck>/ # Example engineering deck
```
---
@@ -457,13 +507,16 @@ templates/
## Template_Designer Phase Complete
- [x] Read `references/template-designer.md`
- [x] Output scope confirmed: `library` | `project`; project-scope preflight passed before final writes
- [x] Output scope confirmed: `library` | `project`; the common workspace preflight passed before final writes
- [x] Replication mode confirmed: `standard` | `fidelity` | `mirror`
- [x] Every page listed in `design_spec.md §V Page Roster` saved to the selected template target (library package directory or project `templates/` root)
- [x] Every page listed in `design_spec.md §V Page Roster` saved to `<template_workspace>/templates/`
- [x] Naming convention applied (standard / fidelity: letter-suffix variants; mirror: `<NNN>_<page_type>.svg`)
- [x] Templates follow design spec (colors, fonts, layout)
- [x] Placeholder markers are clear and standardized; mirror may keep literal text but still maps imported semantic slots
- [x] Every SVG is a complete preview with explicit Master/Layout/Slide/placeholder metadata and `native_structure_mode: template`
- [x] Project scope, when selected: bitmaps routed to `images/`; extracted icons copied to both `templates/icons/` and runtime `icons/`
- [ ] **Next step**: Validate assets; register only library scope, otherwise hand the in-place project bundle to main Step 3
- [x] `standard` / `fidelity` SVGs and Master/Layout contracts were newly authored; `mirror` SVGs restore the lossless source graph without semantic redesign
- [x] Placeholder markers are clear and standardized for `standard` / `fidelity`; mirror preserves literal source text plus source placeholder type/index/bounds
- [x] Every SVG is a complete preview with explicit root Master/Layout identity and `native_structure_mode: structured`; authored modes use canonical fixed layers/slots, while mirror preserves source ownership and mechanically expands fixed-layer groups into direct atoms
- [x] Authored `standard` / `fidelity` Layout keys are non-duplicative; mirror keeps distinct source Layout identities even when their current visible contracts are equivalent
- [x] Model context used lightweight projections; lossless imports remained authoritative for mirror restoration, while `standard` / `fidelity` used compact canonical authored metadata
- [x] Both scopes route bitmaps to `images/` and copy extracted icons to both `templates/icons/` and runtime `icons/`
- [ ] **Next step**: Validate assets, optionally export a review PPTX, then register only library scope
```
@@ -92,7 +92,7 @@ Each style has its own file with: shape & decoration, typography character, colo
## 3. Escape hatch — `custom`
When no preset captures the intended aesthetic, set `- visual_style: custom` in `spec_lock.md` and add a `- visual_style_behavior:` line: one paragraph naming shape language, decoration density, whitespace, typographic character, and texture — **no HEX, no color names as values**. `custom` is a tail-case, not a default; reach for a preset first.
When no preset captures the intended aesthetic, set `- visual_style: custom` in `spec_lock.md` and add a `- visual_style_behavior:` line: one paragraph naming shape language, composition geometry (page-scale moves), decoration density, whitespace, typographic character, and texture — **no HEX, no color names as values**. `custom` is a tail-case, not a default; reach for a preset first.
---
@@ -7,6 +7,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj
## 1. Shape & decoration
- Shape language: thin single-weight line frames (no heavy fills); components drawn as outlined geometry; optional isometric / 3D-axonometric projection for structures. Slight or zero corner rounding.
- Composition geometry: let the schematic be the layout — a full-page annotated drawing as the skeleton with content hung on leader lines; a circular detail blow-up linked to its source; dimension-line brackets framing a hero figure; an isometric exploded stack for structure or sequence; a drawing-sheet title block anchoring one corner.
- Decoration: the engineering-drawing vocabulary — dimension lines, leader arrows, component codes, coordinate labels, a faint gridline backdrop under everything. Annotation *is* the decoration.
- Whitespace: the grid breathes through; let line work float on the dark field with measured spacing.
@@ -7,6 +7,7 @@ Brutalist editorial newspaper. Wall-to-wall small type, irregular column widths,
## 1. Shape & decoration
- Shape language: hard rectangles and ruled boxes; thick black borders / cell frames; visible column dividers. Corner radius `rx="0"` — never rounded.
- Composition geometry: a masthead numeral or headline set so large it crosses column rules; one grid cell inverted to solid ink (light type on dark) as the focal cell; full-bleed rule bars slicing sections; a single rotated stamp-box breaking the grid at one deliberate point.
- Decoration: the grid itself is the decoration — masthead bars, rule lines, boxed pull-quotes, halftone fills. No gradients, no soft cards, no shadows.
- Whitespace: tight and deliberate — narrow margins, dense columns, a newspaper's packed rhythm. Density is the point; one or two breathing zones per page keep it readable, not airy.
- Irregular multi-column layout (mixed column widths) over a uniform grid; asymmetry is intentional.
@@ -7,6 +7,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
## 1. Shape & decoration
- Shape language: chalk-stroke line work with slightly diffused, dry-medium edges; sketched boxes, brackets, arrows in chalk. Confident but never mechanical — the sketched boxes and arrows are `<path>` with non-aligned points; a primitive `<rect>` / `<line>` snaps the chalk back to mechanical.
- Composition geometry: one big chalk ring around the term that matters; a radial mind-map spreading from a central concept; a hand-drawn arc timeline sweeping the board; an oversized chalk bracket grouping half the page; a boxed corner note holding the takeaway.
- Decoration: underlines and emphasis marks; a few sprinkled chalk stars / dots. Blackboard pedagogy — organized sections, a clear central focus.
- Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd.
@@ -7,6 +7,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
## 1. Shape & decoration
- Shape language: crisp geometry; thin glowing rules; hexagon / circuit / grid motifs used sparingly. Slight rounding (`rx` 4-8) or sharp.
- Composition geometry: a glowing diagonal circuit trace splitting the field; concentric orbit rings staging a central metric; a hexagon node cluster mapping components; an oversized low-opacity numeral or glyph floating behind the content layer; thin bracket frames zoning the page.
- Decoration: glow accents, fine grid backgrounds, monospace labels, node / connector lines. Restrained — precision over clutter.
- Whitespace: dark negative space reads as depth; let elements float on it.
@@ -7,6 +7,7 @@ Bloomberg / Economist news-infographic — publication-grade information density
## 1. Shape & decoration
- Shape language: a dense multi-column grid carrying many small charts and data tables inline; editorial sidebars and pull-stats; hairline dividers; hero numbers; a running source / footnote line. Information density is the look — kept legible by a rigorous grid.
- Composition geometry: the chart as the page's spine — layout wraps the visualization instead of boxing it; a hero number at full column scale; a full-width spanner rule dropping into a stat band; a sidebar cut into the grid; small-multiple strips instead of one boxed chart.
- Decoration: minimal beyond the data — a single accent rule, sparing annotation. Charts and numbers are the visual interest, not ornament.
- Whitespace: tight but structured; the grid earns density without clutter.
@@ -7,6 +7,7 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str
## 1. Shape & decoration
- Shape language: rectilinear; thin rules and column dividers instead of cards. Minimal rounding (`rx` 0-4).
- Composition geometry: an oversized drop cap or numeral anchoring the page; a pull quote breaking across two columns; a full-height vertical rule the content hangs from; an asymmetric 2:1 column split instead of even columns; one figure deliberately crossing a column edge.
- Decoration: hairline rules, kickers / eyebrows, pull quotes, drop-style emphasis — typographic, not graphic. Sparing.
- Whitespace: structured by columns and baseline rhythm; comfortable but information-rich.
- Multi-column text flow where content suits.
@@ -7,6 +7,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
## 1. Shape & decoration
- Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20).
- Composition geometry: one hero glass panel set off-axis over a radial bloom; overlapping translucent discs building the focal cluster; a large glass ring encircling the key metric; panels stepped in depth to imply sequence; a diagonal light beam crossing the dark field.
- Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. Realize the radial bloom / glow halo as a `<circle>` / `<ellipse>` with a `<radialGradient>` fill, never a `rect rx=w/2` standing in for it.
- Whitespace: dark negative space reads as depth; let panels float on it with room to breathe.
@@ -7,6 +7,7 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work,
## 1. Shape & decoration
- Shape language: hand-drawn line work with slight, intentional wobble — boxes, arrows, dividers and brackets sketched as if on a thoughtful whiteboard; never mechanically straight — realize it as `<path>` / `<polyline>` with off-grid points, not `<rect>` / `<line>` primitives. Line defines structure; no filled cards.
- Composition geometry: a circled central concept with branch arrows carrying the page; a hand-drawn Venn or overlap sketch as the argument; strike-through-and-replace geometry for before/after; one oversized bracket grouping the evidence under the claim.
- Decoration: minimal — a few doodle marks (stars, dashes, dots, underlines) for emphasis. Restraint is the look; clutter breaks the "considered" feel.
- Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them.
@@ -7,6 +7,7 @@ New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restraine
## 1. Shape & decoration
- Shape language: minimal brush-stroke marks and hairline dividers; the occasional ink-dark block; a single seal-stamp (印章) square as a focal accent. No cards, no boxes — emptiness is the structure. The brush-stroke and ink-bleed marks are irregular `<path>` shapes with uneven control points — never an `<ellipse>` / `<circle>` standing in for a wash, which reads as fake ink. (The seal-stamp square is the one deliberate hard edge.)
- Composition geometry: one broad brush sweep as the page's diagonal spine; an open ensō-like ring framing the core phrase; a vertical scroll band ordering a sequence; a low wash horizon with content floating in the emptiness above; the seal square placed to counterweight an asymmetric field.
- Decoration: almost none; what little appears reads as brush and seal. Asymmetric, scroll-like composition with deliberate off-balance.
- Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm.
@@ -7,6 +7,7 @@ Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s-
## 1. Shape & decoration
- Shape language: bold geometric primitives — circles, triangles, zigzags, squiggles, blobs — with thick dark outlines (2-4px). Mixed corner radii allowed; playful inconsistency is on-brand.
- Composition geometry: a giant primitive bleeding off one edge as the backdrop; a diagonal two-color field split; a zigzag or squiggle band as the section divider; props scattered at angles around one dominant rotated frame or badge at the focal point.
- Decoration: scattered geometric confetti, color-block backings, pattern fills (dots / stripes), oversized punctuation. Generous decoration — but composed, not chaotic.
- Whitespace: energetic asymmetry; props float at angles. Still leave the focal content room to read against the noise.
@@ -7,6 +7,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
## 1. Shape & decoration
- Shape language: forms defined by crisp, slightly-irregular cut edges (no outlines); simplified, stylized shapes that read as cut paper rather than illustration. Those cut edges are irregular `<polygon>` / `<path>` outlines, not a clean `<rect>` / `<circle>`, which reads as a digital box rather than torn paper.
- Composition geometry: stacked wave sheets building the page bottom-up; a die-cut window revealing the layer beneath; one large cut disc (sun / moon) anchoring the composition; tabbed sheet edges ordering steps; a foreground frame layer with the content on the sheet behind it.
- Decoration: layering itself is the device — each element is a "sheet" stacked over the one beneath; small cut-out accents on the top layer.
- Whitespace: cozy, composed — the backing sheet shows through as breathing room.
@@ -7,6 +7,7 @@ Photo-led editorial — large full-bleed photography dominates the page, text po
## 1. Shape & decoration
- Shape language: large full-bleed / edge-to-edge image fields are the page's spine; text sits in restrained columns, caption blocks, kickers, or overlay headlines. Minimal chrome — the photograph carries the page.
- Composition geometry: an L-shaped text zone carved out of the full bleed; a headline straddling the photo edge; a diptych / triptych panel split; one floating caption card breaking the image boundary — moves that serve the photograph, never bury it.
- Decoration: thin rules, section numbering, small figure notes; nothing competes with the image.
- Whitespace: generous around text; the photo fills, the type breathes beside it. Asymmetric magazine composition.
@@ -7,6 +7,7 @@
## 1. Shape & decoration
- Shape language: everything aligns to a visible pixel grid — blocky shapes, stepped edges, sharp transitions, no smooth curves; optional 1-pixel darker outlines for definition.
- Composition geometry: a stepped pixel staircase as the divider; an oversized sprite anchoring the page; a HUD frame with corner brackets zoning content; a tile ground-band along the bottom edge; a pixel progress bar as the sequence device.
- Decoration: classic game framing — HUD bars, tile floors, sprite icons, chunky pixel borders. References NES / SNES / arcade composition.
- Whitespace: grid-disciplined; let blocks sit on clean tiled ground rather than crowd.
@@ -7,6 +7,7 @@ Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gen
## 1. Shape & decoration
- Shape language: rounded shapes drawn with a slight wobble; pastel block fills that slightly overshoot their outlines (hand-painted feel); simple cartoon icons. Draw that wobble as a `<path>` with non-aligned points — a `<rect rx>` is not wobble.
- Composition geometry: a wavy-arrow journey path structuring the whole page; a radial mind-map around a central doodle; a hand-drawn banner ribbon carrying the title; numbered circles hopping along a dotted route; one cloud frame around the idea that matters.
- Decoration: small doodles — stars, sparkles, dots, underlines — sprinkled sparingly for warmth; wavy hand-drawn arrows connecting ideas with short inline labels.
- Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense.
@@ -7,6 +7,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
## 1. Shape & decoration
- Shape language: rounded rectangles (`rx` 12-16), pill tags, soft containers. Consistent radius deck-wide.
- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or arc path replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Cards are the container language, not the composition — vary the stage they sit on.
- Decoration: cards as the primary container; icon accents; numbered circles; gentle dividers. Moderate, in service of clarity.
- Whitespace: comfortable padding inside cards; even gutters; balanced rather than austere.
@@ -7,6 +7,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
## 1. Shape & decoration
- Shape language: sharp rectangles, true circles, single-weight rules. Corner radius `rx="0"` by default; if rounding at all, ≤4.
- Composition geometry: one oversized geometric plane — a full-height color column, a giant disc, a heavy bar — zoning the page; an asymmetric split with content flush to one axis; a hero numeral at architectural scale; a single diagonal rule or type line as the deliberate grid break. Few, large, exact — geometry at poster scale is Swiss, clutter is not.
- Decoration: none. No gradient fills, no decorative blocks, no badges — structure carries the page.
- Whitespace: vast and deliberate; negative space carries as much weight as content. Wide margins, generous gutters.
- Layout snaps to a visible or implied modular grid; rigorous column / row alignment.
@@ -7,6 +7,7 @@ Mid-century print poster (1950s1970s) — bold rounded-geometric shapes, limi
## 1. Shape & decoration
- Shape language: bold geometric shapes with rounded organic edges, often slightly off-axis for retro tension; overlapping flat blocks; thick hand-aware lines; stylized, reduced iconography (a stylized sun, an angular mountain).
- Composition geometry: a giant sun disc or arch as the backdrop anchor; ray wedges radiating from the focal point; off-axis overlapping blocks staging the content; a horizon band splitting the page into field and sky zones; one badge or rosette seal at the emphasis point.
- Decoration: the print artifacts — halftone dot overlays, slight ink misregistration — carry the character; imagery stays reduced and graphic, never photoreal.
- Whitespace: confident poster composition — a few large blocks, deliberate negative space.
@@ -7,6 +7,7 @@ Risograph zine / DIY poster — misregistered color layers, halftone dots, a tig
## 1. Shape & decoration
- Shape language: cut-and-paste blocks, offset color shapes, rough frames; outlines in a near-black ink tone. Corner radius low or zero — print-flat, not soft-digital.
- Composition geometry: rotated pasted-on blocks (slight skew) building the collage stage; a torn-strip band as the section divider; an oversized halftone shape bleeding behind the content; a photocopy frame or taped corner anchoring the focal block; column scraps at mixed widths.
- Decoration: the riso print artifacts — 1-3px color-layer misregistration, halftone-dot `<pattern>` texture, overlapping color blocks that imply a third color where they cross. Texture is the decoration.
- Whitespace: poster-like — bold focal blocks with raw margins; deliberate roughness over clean alignment.
@@ -45,7 +45,7 @@ python3 scripts/update_repo.py
|------|-----------------|---------------|
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) |
| SVG pipeline | `preset_shape_svg.py`, `svg_authoring_view.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native preset authoring](../references/native-shape-authoring.md) |
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) |
@@ -81,8 +81,24 @@ Template source import:
python3 scripts/pptx_template_import.py <template.pptx>
python3 scripts/pptx_template_import.py <template.pptx> --manifest-only
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both
python3 scripts/svg_authoring_view.py <imported-svg-or-dir> -o <output-dir>
python3 scripts/template_preview_pptx.py <template_workspace>
python3 scripts/template_preview_pptx.py <legacy_template_workspace> --visual-only
```
`svg_authoring_view.py` creates a lightweight, non-destructive inspection copy
of PPTX-imported SVGs. It removes embedded `txbody` payloads,
duplicate hidden geometry carriers, and import-identity attributes from the
copy while retaining visible fallback geometry, text, images, stable element
ids, root Master/Layout markers, and supported compact native-shape intent.
Relative local image references are rewritten so the projected copy still
renders from its new location. The full imported SVG remains unchanged and is
the evidence source for mirror restoration; the projection is inspection-only.
The exporter never reads the import workspace or the projection. A projected
view is not a final template or release export source.
`template_preview_pptx.py` reads a template workspace, exports every `templates/*.svg` prototype as one structured review slide, and verifies the resulting Master/Layout package. This is an on-demand review action: its default output is `exports/<template_id>_template_preview.pptx`, and that directory need not exist before the command runs. It refuses an existing output unless an intentional re-export passes `--force`. `--visual-only` is an explicit migration aid for legacy SVG rosters: it creates a slide-local visual review deck without validating or claiming a reusable Master/Layout contract. New structured templates use the default mode when a review deck is requested.
Template fill (direct PPTX, no SVG conversion):
```bash
@@ -107,6 +123,16 @@ python3 scripts/native_enhance_pptx.py validate <project_path>
python3 scripts/native_enhance_pptx.py apply <project_path>
```
Native preset shape authoring (one registry-backed fragment on stdout):
```bash
python3 scripts/preset_shape_svg.py list --search arrow
python3 scripts/preset_shape_svg.py describe rightArrow
python3 scripts/preset_shape_svg.py render rightArrow --id process-arrow --frame 120 180 240 96 --fill '#2563EB'
```
The helper never writes a page or project file. Select one exact semantic stock-shape match, inspect the emitted fragment, and insert it into the hand-authored SVG with the normal patch workflow. Keep ordinary rectangles, ellipses, freeform geometry, charts, icons, and ambiguous silhouettes as regular SVG. See [`references/native-shape-authoring.md`](../references/native-shape-authoring.md) for the selection and metadata contract.
Post-processing and export:
```bash
@@ -122,13 +148,21 @@ python3 scripts/svg_to_pptx.py <project_path>
For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, transitions, `template-fill-pptx`, and `native-enhance-pptx` remain separately owned capabilities.
When `spec_lock.md` has no `pptx_structure` section, native `svg_to_pptx.py` falls back to `baseline`: the generated deck keeps a standard Master/Layout relationship and promotes only exact, z-order-safe shared backgrounds/chrome. Root `data-pptx-page-role` selects `Cover`, `Agenda`, `Section`, `Closing`, or `Content`; filenames are a fallback for marker-free legacy SVGs. A family-wide exact background and an exact leading structurally marked chrome prefix may move into that Layout; ids are consulted only when the structural role is absent. Actual titles, body content, pictures, charts, tables, page numbers, and page-specific shapes remain on Slide. No visual-similarity or placeholder inference is performed. Baseline also prunes unused layouts and maps locked typography/colors into the PowerPoint theme. Use `--pptx-structure flat` for slide-local diagnostics.
Native `svg_to_pptx.py` release export uses the project's `structured` lock. Every project supplies a complete Master roster and page-to-Master/Layout mapping before SVG generation. Every SVG root repeats that identity; fixed Master/Layout visuals are direct atomic children, and reusable slots are top-level groups with positive design-zone bounds plus one compatible carrier. Composite `object` regions use explicit proxy binding, and zero-slot Layouts are valid.
Deck/layout template routes use explicit template structure. Each complete SVG names its output Layout, repeats inherited Master/Layout preview layers, and marks supported content placeholders. Strict keeps the selected Layout contract; adaptive may create a new Layout while retaining the Master. The exporter validates cross-slide equality, creates one reusable Layout per key, removes repeated inherited copies, and keeps actual content Slide-local. The complete contract lives in [`references/shared-standards.md`](../references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export).
The exporter compiles only this declared structure, maps locked typography/colors into PowerPoint defaults, creates the named Master/Layout parts, and reads the package back before publication. It never clusters pages, promotes repeated chrome heuristically, or invents placeholders. Use `--pptx-structure flat` only for diagnostics.
Current `create-template` output always rebuilds explicit SVG structure and does not package `native_structure.json` or `source_template.pptx`. `preserve` remains available only for existing projects that already carry the legacy pair.
Template `page_layouts` records input provenance. Strict preserves its Master/Layout/slot contract; adaptive retains its Master and may use a new Layout key only when fixed Layout atoms or slot topology/bounds change. `standard` / `fidelity` author new SVGs and a new Master/Layout/slot contract. `mirror` restores the source identities and parentage without semantic synthesis, while mechanically expanding fixed-layer group wrappers into the direct atoms required by the structured contract.
`pptx_to_svg.py` annotates supported unmerged tables and conservative classic-chart caches with `data-pptx-native` metadata. Source table-style inheritance, supported solid cell fills/basic text formatting, chart title/legend/axis titles, and plot-level data-label flags for area/bar/column/line charts are retained when the current schema can represent them. Tables with direct borders, non-solid fills, or mixed rich-text formatting remain fallback-only, as do charts with unsupported label scopes/types, custom axis semantics, trendlines/error bars, or subtype options. Unsupported tables keep their rendered SVG table; unsupported charts keep a baked preview or explicit placeholder. Both carry `data-pptx-native-status`, which `svg_quality_checker.py` and `svg_to_pptx.py --native-objects` report as a warning.
Legacy `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, and missing root Master identity must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before release export.
`pptx_to_svg.py` annotates verified text-grid tables and conservative chart data with `data-pptx-native` metadata. Table import covers exact physical row/grid topology, canonical rectangular merges, safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed run-rich paragraph schema. Each rich run requires `text` and may use only `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`. A merge must use the exact `rowSpan` / `gridSpan` / `hMerge` / `vMerge` physical topology with empty merge slaves. Presentation-only source run XML normalizes, while relationship-bearing text, extensions, line breaks, fields, tabs, bullets, broken text topology, unsafe border XML, non-solid fills, and other merge encodings remain fallback-only. For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the normalized SVG fallback resolves `wholeTbl`, `firstRow`, horizontal banding, theme colors/fonts, and direct cell/run overrides; other built-in/custom style families are not implied.
Supported parsed column/bar/line/area, pie/doughnut, scatter, and bubble charts without a baked preview receive a deterministic readable fallback marked `data-pptx-visual-status="normalized"`. The importer additionally activates verified column/line/area combo charts, canonical OHLC stock charts, area charts with numeric date axes, verified scatter/bubble charts whose two value axes fit the closed `axes.x` / `axes.y` contract, radar charts, safe `of_pie` `serLines`, axis/title/legend normalization, and validated bar/column gap/overlap cases. Combo plots may retain independent primary/secondary category caches and workbook ranges. Both the category/value and XY contracts retain kind/position/visibility/label position/number format/min/max/major unit/reverse/major gridlines for native read-back. Scatter import derives effective `scatter_style` from uniform per-series line/marker/smooth state. The normalized XY fallback consumes only the two major-gridline flags; the C4/C5 additions do not expand the normalized renderer. `gapWidth` is accepted only as an integer in `0..500` and `overlap` only as an integer in `-100..100`; both normalize in native output, while malformed or out-of-range values fail closed. Safe common series paint forms and theme scheme colors are resolved; unknown series paint/style XML outside the explicit normalization boundaries still fails closed. Safe stock series style may pass the structural gate, but stock series, `hiLowLines`, and up-down bar local styling can still normalize under the editable-first contract. The editable native replacement remains allowed to normalize unmodeled no-fill/alpha/line/marker details and reports the route-level loss risk. Chart title/legend/axis titles and supported data-label flags are retained when the current schema can represent them. Fallback-only objects keep rendered SVG content or a baked chart preview and carry `data-pptx-native-status`, which validation and `--native-objects` export report as a warning. An active marker without a renderer keeps `data-pptx-visual-status="placeholder"` plus `data-pptx-route-status="reconstruction-only"`; default export keeps the placeholder and native opt-in may still reconstruct it.
The ChartEx importer accepts exactly the validated treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel data models. Supported hierarchy/category/value/series/subtotal data round-trips to native output; source style, axes, labels, and binning may normalize. Numeric caches must be non-empty and finite with exact contiguous point topology. This is not arbitrary ChartEx import or presentation fidelity, and the ChartEx native writer still only promises valid payload palette entries rather than full source styling.
Active imported table/chart markers carry `data-pptx-fallback-sha256`. Visible fallback edits, reachable SVG fragment-definition changes, marker-local reference-target changes, and marker transforms make the baseline stale: the mandatory quality checker warns, default export remains available, and `--native-objects` fails instead of discarding the SVG edit. Legacy markers without a baseline remain convertible with a checker/native-route warning.
Exporter-canonical classic charts also recover canonical solid series/slice
colors and exact one- or two-paragraph title styling; two paragraphs retain
@@ -270,6 +270,11 @@ def _wait_for_result(
except OSError:
pass
skip_error = _stage_skip_error(result_file.parent)
if skip_error:
logger.error('%s', skip_error)
return 2
returncode = proc.poll()
if returncode is not None:
logger.error('confirm UI exited before a fresh result was written')
@@ -346,6 +351,44 @@ def _result_stage_number(stage: Optional[str]) -> int:
return 0
def _stage_skip(rec_stage_number: int, result_stage: Optional[str]) -> bool:
"""Detect a staged recommendation running ahead of the confirmed progression.
Stages confirm strictly in order (stage1 stage2 stage3): a
recommendation may only run one stage past the last confirmed result, so
e.g. a ``stage3`` file while only stage 1 is confirmed is a skip the
page must not render it (an active template never exempts stage 2).
Legacy single-pass recommendations (no ``stage``) are not staged and are
exempt, as is any state after the final confirmation.
"""
if rec_stage_number <= 1 or result_stage == 'final':
return False
return rec_stage_number > _result_stage_number(result_stage) + 1
def _stage_skip_error(confirm_dir: Path) -> Optional[str]:
"""Return a directive error when recommendations.json skips a stage."""
try:
rec_data = _read_json_object(confirm_dir / RECOMMENDATIONS_NAME)
except (OSError, json.JSONDecodeError, ValueError):
return None
rec_stage_number = _recommendation_stage(rec_data)
result_stage = _result_stage(confirm_dir / RESULT_NAME)
if not _stage_skip(rec_stage_number, result_stage):
return None
expected = _stage_name(_result_stage_number(result_stage) + 1)
reattach = (
'--daemon --wait' if expected == 'stage1'
else '--wait-only --wait-stage stage2'
)
return (
f'stage skip detected: recommendations.json is {_stage_name(rec_stage_number)} but the last '
f'confirmed result is {result_stage or "absent"} — the page will not render a skipped stage. '
f'Stages confirm in order and an active template does not exempt stage2 (SKILL.md Step 4). '
f'Overwrite recommendations.json with the {expected} recommendations, then re-run with {reattach}.'
)
def _file_version(path: Path) -> Optional[float]:
"""Return a cheap file version for polling state, or None when absent."""
try:
@@ -389,7 +432,10 @@ def _build_session_state(
result_stage = _result_stage(result_file)
result_stage_number = _result_stage_number(result_stage)
stage_skip = _stage_skip(rec_stage_number, result_stage)
# A skipped stage is never presented: the page keeps its "deriving…" state
# (waiting_agent) until the AI rewrites recommendations.json in order.
if result_stage == 'final':
expected_stage_number = None
status = 'done'
@@ -400,17 +446,20 @@ def _build_session_state(
current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 3 else 'stage2'
elif result_stage == 'stage1':
expected_stage_number = 2
status = 'ready_user' if rec_stage_number >= 2 else 'waiting_agent'
current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 2 else 'stage1'
ready = rec_stage_number >= 2 and not stage_skip
status = 'ready_user' if ready else 'waiting_agent'
current_stage = _stage_name(rec_stage_number) if ready else 'stage1'
else:
expected_stage_number = rec_stage_number or 1
status = 'ready_user' if rec_stage_number else 'waiting_agent'
current_stage = rec_stage or 'stage1'
expected_stage_number = 1 if stage_skip else (rec_stage_number or 1)
ready = bool(rec_stage_number) and not stage_skip
status = 'ready_user' if ready else 'waiting_agent'
current_stage = rec_stage if ready else 'stage1'
return {
'session_id': previous.get('session_id') or uuid.uuid4().hex,
'status': status,
'current_stage': current_stage,
'stage_skip': stage_skip,
'expected_stage': _stage_name(expected_stage_number),
'expected_stage_number': expected_stage_number,
'recommendation_stage': rec_stage,
@@ -541,6 +590,11 @@ def _wait_only_for_result(
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
return 0
skip_error = _stage_skip_error(result_file.parent)
if skip_error:
logger.error('%s', skip_error)
return 2
lock = _read_lock(lock_file)
pid = _lock_pid(lock)
if not pid or not _process_alive(pid):
@@ -81,6 +81,8 @@ The page runs as a **three-stage wizard in one browser session**. `recommendatio
The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-derives** the design-system candidates from the user's actual anchors, overwrites `recommendations.json` with `"stage": "stage2"`, and re-attaches with `--wait-only --wait-stage stage2`. After the Stage-2 result, it **re-derives** image and execution recommendations from the confirmed anchors + design system, overwrites `recommendations.json` with `"stage": "stage3"`, and re-attaches with `--wait-only` for the final result. The page preserves earlier selections across transitions (single JS session). `GET /api/session` is the browser's waiting-state endpoint: it is derived from `recommendations.json`, `result.json`, and the active server port, then persisted to `session.json` so a recovered server can resume the same stage state. Only after `/api/session` reports that the next recommendation stage is ready does the page fetch `GET /api/recommendations`. `GET /api/recommendations` is served `no-store` so polls see overwrites; on later stages the server folds already-confirmed choices from `result.json` back into the payload so a refresh / reopen re-initializes from the user's actual choices even though those sections are no longer rendered.
**Stage progression guard.** Stages confirm strictly in order — a staged `recommendations.json` may only run **one** stage past the last confirmed result. A file that skips ahead (e.g. `"stage3"` while only stage 1 is confirmed — typically an attempt to collapse Stage 2 because an active template already fixes color / typography) is never rendered: `/api/session` keeps reporting `waiting_agent` with `stage_skip: true`, and `--wait` / `--wait-only` exit `2` with a directive log line naming the expected stage to rewrite. An active deck/layout template — `strict` adherence included — does not exempt Stage 2: the template skin becomes the recommended color / typography candidate, not a reason to skip the confirmation. Legacy single-pass files (no `stage`) are not staged and bypass the guard.
### Input — `recommendations.json` (written by Strategist before launch)
```json
@@ -192,8 +192,9 @@ python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
Behavior:
- extracts slide text in reading order
- converts PowerPoint tables to Markdown tables
- transcribes native chart data (type + categories × series values) into a Markdown table, so chart numbers are not lost in conversion
- converts PowerPoint tables to Markdown tables; cell-internal line breaks become `<br>` so they cannot break the pipe-table row structure
- transcribes category charts as category × series tables and scatter/bubble charts as typed X/Y[/size] point tables
- preserves every readable chart dimension or series and emits `[Chart data warning: <reason>]` for missing caches/count mismatches; `[Chart data unavailable: <reason>]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table
- transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning
- exports embedded pictures to a sibling `_files/` directory
- appends speaker notes when present
@@ -245,11 +246,89 @@ python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode flat
| `layered` | Only the layered `svg/` view and inheritance metadata |
| `flat` | One self-contained slide SVG per page under `svg/` |
Supported unmerged tables and conservative classic-chart caches carry
`data-pptx-native` metadata beside their SVG fallback. Markers remain dormant
unless a later export uses `--native-objects`. Unsupported tables keep their
rendered SVG table; unsupported charts keep a baked preview or explicit
placeholder. Both carry `data-pptx-native-status`.
Supported text-grid tables and conservative classic-chart caches carry
`data-pptx-native` metadata beside their SVG fallback. Table import requires
exact physical row/grid topology and accepts canonical rectangular merges,
safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed
run-rich paragraph schema.
Each run requires `text` and may use only `bold`, `italic`, `underline`,
`strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`.
Presentation-only source run XML normalizes. Relationship-bearing text,
extensions, noncanonical/overlapping merges, nonblank merge slaves, unsafe
border XML, non-solid fills, structural line breaks/fields/tabs/bullets, and
broken text topology remain fallback-only.
Markers remain dormant
unless a later export uses `--native-objects`. That opt-in is editable-first:
it may normalize styling or omit marker-local details not represented by the
payload, and export reports that risk without disabling an otherwise supported
active marker. Unsupported tables keep their
rendered SVG table; unsupported charts keep a baked preview when one exists.
For the currently supported parsed classic families (column/bar/line/area,
pie/doughnut, scatter, and bubble), a chart without a baked preview receives a
deterministic readable fallback marked
`data-pptx-visual-status="normalized"`. Unknown style XML still fails closed;
common solid/no-fill/line/marker forms and scheme colors are normalized for the
SVG fallback and core payload colors, while native opt-in may still normalize
unmodeled alpha, line, marker, or no-fill details. Common General, decimal,
grouped, percent, and simple currency-prefix data-label formats render
deterministically; an unknown Excel format program keeps the active payload but
does not claim a normalized fallback. Active types outside the current renderer
continue to use an explicit placeholder marked
`data-pptx-visual-status="placeholder"` and
`data-pptx-route-status="reconstruction-only"`. Validation and export report
that route as a warning. Default export keeps the placeholder; when the same
group has a valid active native-chart payload, `--native-objects` may still
reconstruct the editable chart. Invalid or contradictory status declarations
remain errors.
Fallback-only native capability uses `data-pptx-native-status` and remains a
warning when the SVG fallback itself is complete.
Active imported markers also carry `data-pptx-fallback-sha256`, computed over
their canonical fallback plus reachable document-level SVG fragment definitions.
A later visible edit, reachable definition change, local reference-target
change, or marker transform makes the native metadata stale. The mandatory
quality checker reports the mismatch; default export keeps the edited fallback,
while `--native-objects` fails before replacement so it cannot discard that edit.
`visibility:hidden` content, marker-local unused definitions, and explicitly
referenced document-level target roots (even when hidden) are included
conservatively; marker-local `display:none` subtrees are excluded, and external
file bytes are not read.
A legacy marker without the hash remains native-compatible and warns in the
checker/native route that stale detection is unavailable.
For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the importer resolves
the normalized `wholeTbl`, `firstRow`, `band1H`/`band2H`, theme color/font, and
direct-format override subset. Other built-in/custom style families remain
outside this guarantee.
The chart importer also accepts the verified column/line/area combo subset,
canonical four-series OHLC stock charts with shared numeric date caches, area
charts with numeric date axes, and verified scatter/bubble charts with a closed
pair of `axes.x` / `axes.y` value axes. Combo primary/secondary plots may retain
independent category caches and workbook ranges. Both the category/value and XY
contracts read back the supported kind/position/visibility, label-position,
number-format, min/max/major-unit, reverse, and major-gridline fields; the native
writer emits every field in those closed contracts. Scatter style is derived
from uniform effective series line/marker/smooth state. The normalized XY
fallback newly consumes only the two major-gridline flags, not the remaining
axis fields. The importer also accepts radar, safe `of_pie` `serLines`, the
closed axis/title/legend normalization cases, and bar/column `gapWidth` /
`overlap`. `gapWidth` must be one integer in `0..500` and `overlap` one integer
in `-100..100`; both normalize in native output, while malformed, duplicate, or
out-of-range values fail closed. These additions do not expand the normalized
renderer.
Safe stock series style may pass the structural gate, while stock series,
`hiLowLines`, and up-down bar local styling can still normalize under the
editable-first contract.
ChartEx import accepts exactly the validated treemap, sunburst, histogram,
pareto, box-whisker, waterfall, and funnel data models. Their supported
hierarchy/category/value/series/subtotal topology round-trips to native output.
Numeric caches must be non-empty and finite, with canonical non-negative counts
and indexes and exact contiguous point topology. Source style, axes, labels,
and binning details may normalize. This is not full `AxisSpec`, arbitrary
ChartEx import or presentation fidelity, arbitrary stock variants, other
date-axis chart families, or unlisted axis semantics. ChartEx native output
still consumes valid payload colors in its color-style part.
Exporter-canonical charts recover canonical solid series/slice colors and exact
one- or two-paragraph title styling; two paragraphs retain their `title` /
@@ -6,6 +6,37 @@ These tools cover post-processing, SVG validation, speaker notes, recorded narra
The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported.
## `svg_authoring_view.py`
Create a lightweight inspection/authoring projection of one PPTX-imported SVG
or a directory of imported SVGs:
```bash
python3 scripts/svg_authoring_view.py <svg-file-or-directory> -o <output-dir>
```
The operation is non-destructive and refuses existing output files unless
`--force` is explicit. It never writes back to the source SVG. The JSON report
on stdout records original/projected byte counts and removals by category.
The projected copy:
- removes embedded `txbody` metadata;
- removes hidden native geometry carriers while retaining and unwrapping their
visible preview geometry;
- removes source-object identity/style/hash attributes that are only useful to
an exact import round trip;
- keeps visible paths, text, images, stable ids, Master/Layout root markers,
and supported compact `data-pptx-object` / `data-pptx-prst` /
`data-pptx-frame` intent; and
- rewrites relative local asset references for the projection's new location.
The complete imported SVG remains the evidence source for mirror restoration.
The exporter does not read the import workspace or the projection. The
projection is deliberately not a template generator, not a replacement for
the explicit Master/Layout restoration workflow, and not a supported release
input to `svg_to_pptx.py`.
## Recommended Pipeline
Run these steps in order:
@@ -35,8 +66,7 @@ Convert project SVGs into PPTX.
```bash
python3 scripts/svg_to_pptx.py <project_path>
python3 scripts/svg_to_pptx.py <project_path> --native-objects
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure template # explicit SVG template metadata
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure preserve # imported source package contract
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure structured # current release structure
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # structure diagnostic
# Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
@@ -58,7 +88,7 @@ Behavior:
- `finalize_svg.py` always creates `svg_final/` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX.
- Explicit `-o/--output` changes the native PPTX destination and skips `backup/`.
- Paragraph merging is enabled by default and trades some SVG line-layout fidelity for PowerPoint editability:
- Default: mergeable paragraph blocks (same x, dy clustered around one base line-height, optional larger gap for paragraph breaks) collapse into one editable text frame with multiple `<a:p>` and precise `<a:lnSpc>` / `<a:spcBef>`. Resizing the box reflows text inside it.
- Default: mergeable paragraph blocks (same x, dy clustered around one base line-height) collapse into one editable text frame. Equal effective font sizes may join as flowing prose; a font-size change, list marker, or accepted larger gap starts a new `<a:p>` with precise `<a:lnSpc>` / `<a:spcBef>`. Resizing the box reflows text inside it without erasing those paragraph boundaries.
- With `--no-merge`: every dy-stacked `<tspan>` becomes its own text frame — exact SVG line layout is preserved but a 12-line paragraph is 12 separate textboxes
- Side effect: PowerPoint may wrap merged paragraphs to a different line count than the SVG source. Long body text (abstracts, multi-paragraph sections, reference lists) usually benefits from the default; pages with tight typographic alignment (covers, charts, tables) usually want `--no-merge`
- Mergeable detection is conservative: only fires when the children form a clean paragraph block; mixed-layout `<text>` falls through to the default per-line path
@@ -71,16 +101,16 @@ Behavior:
inputs and package-level processing.
- For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide.
- Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content.
- Omitting `--pptx-structure` reads `spec_lock.md` and falls back to `baseline`. Baseline assigns Layout families from root `data-pptx-page-role`, keeps content Slide-local, and promotes only exact family-wide backgrounds plus exact leading structurally marked chrome; filenames and ids are compatibility fallbacks for marker-free legacy SVGs. It never infers placeholders or visual similarity. Template mode builds reusable PowerPoint structure only from explicit SVG metadata and validates every `pptx_layouts` row plus cross-slide equality. Both strict and adaptive template adherence use this mode; adaptive may introduce a new Layout key under the same Master.
- Template/preserve placeholder semantics distinguish title, subtitle, body, picture, chart, table, generic object, media, date, footer, and slide number. Reconstructed titles are normally type-matched without an index; explicit imported title indices and all other source indices are retained. Imported `subTitle`, `obj`, `media`, and `dt` identities remain distinct through `manifest.json`, `native_structure.json`, Layout XML, and Slide XML.
- Baseline/template native export reads `spec_lock.md` typography into the PowerPoint theme: `title_family` becomes the major font and `body_family` / `font_family` becomes the minor font. Matching SVG text emits `+mj-*` / `+mn-*` tokens, while unrelated emphasis/code/brand families stay concrete. Preserve mode keeps the imported source theme; flat mode keeps fixed-font diagnostic output.
- Template mode also requires numeric `typography.title` and `typography.body` rows. It writes the title size to every Master `p:titleStyle` level and the body size to every `p:bodyStyle` / `p:otherStyle` level. Each generated Layout text placeholder also stores the prototype's first direct run size in `a:lstStyle/a:lvl1pPr/a:defRPr@sz` while retaining its prompt run size. Slide direct-run sizes remain unchanged. Missing title/body rows fail template export; baseline, preserve, and flat modes do not apply these updates.
- Baseline/template native export also maps canonical `spec_lock.md` color roles into the PowerPoint color scheme and emits context-safe `schemeClr` tokens for exact matches in SVG fills/text/strokes, gradients/patterns/bullets, native tables, and native-chart accent series. Local colors, inverse white/black, and effects stay concrete. Preserve mode keeps the imported source color scheme; flat mode keeps fixed-color diagnostic output.
- Preserve mode is legacy strict-only compatibility for existing projects that already carry `native_structure.json` + `source_template.pptx`. Current template creation does not emit the pair.
- Omitting `--pptx-structure` reads `spec_lock.md`; release export requires `mode: structured`. The lock must include a complete `pptx_masters` roster and one `pptx_layouts` row per page. `flat` is diagnostic-only.
- Every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct atomic children; layer `<g>` elements are invalid.
- Each normal slot is a direct root `<g id>` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid.
- Structured export maps locked typography/colors into PowerPoint Master/Layout/theme defaults, creates one reusable Layout per declared key, and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts.
- Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive retains its Master and may use a new Layout identity only when fixed Layout atoms or slot topology/bounds change.
- Legacy `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, and missing Master identity are rejected with a pointer to [`restore-pptx-structure`](../../workflows/restore-pptx-structure.md). Export never migrates or infers them.
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
- Before publishing template-mode native output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication; other structure modes skip this gate.
- Before publishing structured-baseline or template-mode native output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication; legacy baseline, preserve, and flat skip this gate.
- SVG clip paths are still restricted for authored SVGs, but nested crop wrappers generated by PPTX import are mapped back to native picture crop / geometry when possible.
- Speaker notes are embedded automatically unless `--no-notes` is used
- Recorded narration is opt-in:
@@ -95,7 +125,7 @@ Behavior:
- 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`
- Page transitions are controlled by `-t/--transition`; per-element entrance animations are controlled by `-a/--animation`
- Per-element animation applies to top-level SVG `<g id="...">` groups in z-order; aim for 38 content groups per slide. Existing layer/slide-number placeholder semantics are read before minimal structural roles; exact id tokens remain a fallback only when all explicit markers are absent
- Per-element animation applies to ordinary top-level SVG `<g id="...">` groups in z-order; aim for 38 Slide-local content groups per slide. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent
- 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
- Start mode is set by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), `with-previous` (all together on slide entry)
- `on-click` is for live presentations only; recorded narration rejects it because the tool does not generate object-level click timings
@@ -146,12 +176,19 @@ python3 scripts/svg_quality_checker.py examples/project
python3 scripts/svg_quality_checker.py examples/project --format ppt169
python3 scripts/svg_quality_checker.py --all examples
python3 scripts/svg_quality_checker.py examples/project --export
python3 scripts/svg_quality_checker.py path/to/template/templates --template-mode
```
Checks include:
- `viewBox`
- banned elements
- line-break structure
- explicit Master/Layout/slot structure for reusable templates
- duplicate empty Layout contracts under different keys
Template mode accepts compact canonical preset shapes marked with
`data-pptx-authoring="preset"`. It validates the explicit structured SVG
contract; it does not implement a separate source-payload opt-in marker.
## `svg_position_calculator.py`
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
PPT Master - DrawingML Preset Shapes
Shared full-catalog loader, registry, and formula evaluator for PPTX shapes.
Usage:
Import get_preset_registry from pptx_shapes.
Examples:
from pptx_shapes import get_preset_registry
arrow = get_preset_registry().evaluate("rightArrow", 320, 160)
Dependencies:
None (only uses standard library)
"""
from .errors import (
FormulaEvaluationError,
FormulaSyntaxError,
PresetShapeDataError,
PresetShapeError,
UnknownGuideError,
UnknownPresetShapeError,
)
from .formula import (
FULL_CIRCLE,
OOXML_COORDINATE_MAX,
OOXML_COORDINATE_MIN,
OOXML_DEGREE,
OOXML_LINE_WIDTH_MAX,
SUPPORTED_OPERATORS,
FormulaEvaluator,
build_builtin_guides,
evaluate_formula,
validate_ooxml_line_width,
validate_ooxml_xfrm,
)
from .loader import (
BUNDLED_DEFINITIONS_PATH,
BUNDLED_DEFINITIONS_SHA256,
BUNDLED_SHAPE_TYPES_PATH,
BUNDLED_SHAPE_TYPES_SHA256,
DRAWINGML_NS,
EXPECTED_SHAPE_COUNT,
load_preset_shape_definitions,
load_shape_type_values,
)
from .models import (
EvaluatedPresetGeometry,
PresetShapeDefinition,
)
from .registry import (
CONNECTOR_PRESET_TYPES,
PresetShapeRegistry,
get_preset_registry,
)
from .semantic_hash import (
NATIVE_FALLBACK_SHA256_ATTR,
resolve_preset_preview_hash,
svg_native_fallback_fingerprint,
svg_native_fallback_markup_fingerprint,
svg_preset_preview_fingerprint,
svg_text_fingerprint,
)
from .xml_safety import RELATIONSHIPS_NS, has_relationship_attributes
__all__ = [
"BUNDLED_DEFINITIONS_PATH",
"BUNDLED_DEFINITIONS_SHA256",
"BUNDLED_SHAPE_TYPES_PATH",
"BUNDLED_SHAPE_TYPES_SHA256",
"CONNECTOR_PRESET_TYPES",
"DRAWINGML_NS",
"EXPECTED_SHAPE_COUNT",
"FULL_CIRCLE",
"OOXML_COORDINATE_MAX",
"OOXML_COORDINATE_MIN",
"OOXML_DEGREE",
"OOXML_LINE_WIDTH_MAX",
"NATIVE_FALLBACK_SHA256_ATTR",
"SUPPORTED_OPERATORS",
"EvaluatedPresetGeometry",
"FormulaEvaluationError",
"FormulaEvaluator",
"FormulaSyntaxError",
"PresetShapeDataError",
"PresetShapeDefinition",
"PresetShapeError",
"PresetShapeRegistry",
"RELATIONSHIPS_NS",
"UnknownGuideError",
"UnknownPresetShapeError",
"build_builtin_guides",
"evaluate_formula",
"get_preset_registry",
"has_relationship_attributes",
"load_preset_shape_definitions",
"load_shape_type_values",
"resolve_preset_preview_hash",
"svg_native_fallback_fingerprint",
"svg_native_fallback_markup_fingerprint",
"svg_text_fingerprint",
"svg_preset_preview_fingerprint",
"validate_ooxml_line_width",
"validate_ooxml_xfrm",
]
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,54 @@
# DrawingML preset geometry data provenance
## Apache POI notice
Apache POI
Copyright 2003-2025 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (<https://www.apache.org/>).
## Vendored data
`presetShapeDefinitions.xml` is vendored from Apache POI 5.4.1:
- Release tag: `REL_5_4_1`
- Source commit: `4554f204cbbf00ecbcaed134fe57e43a1779a612`
- Source path: `poi/src/main/resources/org/apache/poi/sl/draw/geom/presetShapeDefinitions.xml`
- Source revision: <https://github.com/apache/poi/tree/4554f204cbbf00ecbcaed134fe57e43a1779a612>
- Original release-file SHA-256:
`a7dad593d27bd70536b41da9b761fa16409536cc0c25ef2b6c7a61c5d9b3e738`
- Vendored SHA-256: `4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780`
- Modification: line endings normalized from the release JAR's mixed CRLF/LF to LF; XML content is otherwise unchanged.
- License: Apache License 2.0; see `LICENSE-APACHE-2.0.txt`.
The independent completeness list in `shape_type_values.txt` is derived from
the Open XML SDK schema metadata for `a:ST_ShapeType` / `ShapeTypeValues`:
- Repository: `dotnet/Open-XML-SDK`
- Source commit: `00967dc871f06776ae969762c6703d062308a6c9`
- Source path: `data/schemas/schemas_openxmlformats_org_drawingml_2006_main.json`
- Source revision:
<https://github.com/dotnet/Open-XML-SDK/tree/00967dc871f06776ae969762c6703d062308a6c9>
- Source-file SHA-256:
`e760b534c96ee02745d2a8084f1be362826da4470a85d0b0102a67c3b1678ab7`
- Vendored enum-list SHA-256:
`f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587`
- License: MIT (`dotnet/Open-XML-SDK`); see
`LICENSE-OPEN-XML-SDK-MIT.txt`.
At the locked revisions, both sources contain exactly 187 unique preset names,
and their sets are identical.
## Evaluator compatibility notes
- The circular-arrow, left-circular-arrow, and left-right-circular-arrow
definitions each contain `+- xH 0 dxB 0`. Apache POI evaluates the first
three operands and ignores the inert final zero. The PPT Master evaluator
accepts only this trailing-zero compatibility form; other arity mismatches
remain errors.
- A small number of definitions rebind an intermediate guide name later in
the ordered `gdLst`. Evaluation therefore preserves document order and the
later value replaces the earlier value, matching Apache POI behavior.
@@ -0,0 +1,187 @@
line
lineInv
triangle
rtTriangle
rect
diamond
parallelogram
trapezoid
nonIsoscelesTrapezoid
pentagon
hexagon
heptagon
octagon
decagon
dodecagon
star4
star5
star6
star7
star8
star10
star12
star16
star24
star32
roundRect
round1Rect
round2SameRect
round2DiagRect
snipRoundRect
snip1Rect
snip2SameRect
snip2DiagRect
plaque
ellipse
teardrop
homePlate
chevron
pieWedge
pie
blockArc
donut
noSmoking
rightArrow
leftArrow
upArrow
downArrow
stripedRightArrow
notchedRightArrow
bentUpArrow
leftRightArrow
upDownArrow
leftUpArrow
leftRightUpArrow
quadArrow
leftArrowCallout
rightArrowCallout
upArrowCallout
downArrowCallout
leftRightArrowCallout
upDownArrowCallout
quadArrowCallout
bentArrow
uturnArrow
circularArrow
leftCircularArrow
leftRightCircularArrow
curvedRightArrow
curvedLeftArrow
curvedUpArrow
curvedDownArrow
swooshArrow
cube
can
lightningBolt
heart
sun
moon
smileyFace
irregularSeal1
irregularSeal2
foldedCorner
bevel
frame
halfFrame
corner
diagStripe
chord
arc
leftBracket
rightBracket
leftBrace
rightBrace
bracketPair
bracePair
straightConnector1
bentConnector2
bentConnector3
bentConnector4
bentConnector5
curvedConnector2
curvedConnector3
curvedConnector4
curvedConnector5
callout1
callout2
callout3
accentCallout1
accentCallout2
accentCallout3
borderCallout1
borderCallout2
borderCallout3
accentBorderCallout1
accentBorderCallout2
accentBorderCallout3
wedgeRectCallout
wedgeRoundRectCallout
wedgeEllipseCallout
cloudCallout
cloud
ribbon
ribbon2
ellipseRibbon
ellipseRibbon2
leftRightRibbon
verticalScroll
horizontalScroll
wave
doubleWave
plus
flowChartProcess
flowChartDecision
flowChartInputOutput
flowChartPredefinedProcess
flowChartInternalStorage
flowChartDocument
flowChartMultidocument
flowChartTerminator
flowChartPreparation
flowChartManualInput
flowChartManualOperation
flowChartConnector
flowChartPunchedCard
flowChartPunchedTape
flowChartSummingJunction
flowChartOr
flowChartCollate
flowChartSort
flowChartExtract
flowChartMerge
flowChartOfflineStorage
flowChartOnlineStorage
flowChartMagneticTape
flowChartMagneticDisk
flowChartMagneticDrum
flowChartDisplay
flowChartDelay
flowChartAlternateProcess
flowChartOffpageConnector
actionButtonBlank
actionButtonHome
actionButtonHelp
actionButtonInformation
actionButtonForwardNext
actionButtonBackPrevious
actionButtonEnd
actionButtonBeginning
actionButtonReturn
actionButtonDocument
actionButtonSound
actionButtonMovie
gear6
gear9
funnel
mathPlus
mathMinus
mathMultiply
mathDivide
mathEqual
mathNotEqual
cornerTabs
squareTabs
plaqueTabs
chartX
chartStar
chartPlus
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
PPT Master - PPTX Shape Errors
Shared exception types for the DrawingML preset-geometry package.
Usage:
Import exception classes from pptx_shapes.errors.
Examples:
from pptx_shapes.errors import FormulaEvaluationError
Dependencies:
None (only uses standard library)
"""
class PresetShapeError(ValueError):
"""Base error for invalid preset-shape data or evaluation input."""
class PresetShapeDataError(PresetShapeError):
"""Raised when the bundled preset catalog is incomplete or malformed."""
class FormulaSyntaxError(PresetShapeError):
"""Raised when a DrawingML guide formula has invalid syntax."""
class FormulaEvaluationError(PresetShapeError):
"""Raised when a syntactically valid formula cannot be evaluated."""
class UnknownGuideError(PresetShapeError):
"""Raised when a formula references an unknown guide token."""
class UnknownPresetShapeError(KeyError):
"""Raised when a preset name is absent from the locked shape catalog."""
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""
PPT Master - DrawingML Formula Evaluator
Evaluate DrawingML geometry guide formulas and standard built-in guides.
Usage:
Import FormulaEvaluator or evaluate_formula from pptx_shapes.formula.
Examples:
evaluator = FormulaEvaluator(width=200, height=100)
evaluator.evaluate("*/ w 1 2")
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
import math
import re
from types import MappingProxyType
from typing import Mapping
from .errors import (
FormulaEvaluationError,
FormulaSyntaxError,
UnknownGuideError,
)
OOXML_DEGREE = 60000.0
FULL_CIRCLE = 360.0 * OOXML_DEGREE
OOXML_COORDINATE_MIN = -27273042329600
OOXML_COORDINATE_MAX = 27273042316900
OOXML_LINE_WIDTH_MAX = 20116800
_OPERATOR_ARITY = {
"val": 1,
"*/": 3,
"+-": 3,
"+/": 3,
"?:": 3,
"abs": 1,
"at2": 2,
"cat2": 3,
"cos": 2,
"max": 2,
"min": 2,
"mod": 3,
"pin": 3,
"sat2": 3,
"sin": 2,
"sqrt": 1,
"tan": 2,
}
SUPPORTED_OPERATORS = frozenset(_OPERATOR_ARITY)
_DIMENSION_GUIDE_RE = re.compile(r"^(wd|hd|ssd)([1-9][0-9]*)$")
_ANGLE_GUIDE_RE = re.compile(r"^(?:(\d+))?cd([1-9][0-9]*)$")
def validate_ooxml_xfrm(
off_x: int,
off_y: int,
ext_cx: int,
ext_cy: int,
) -> None:
"""Validate DrawingML shape offsets and extents in EMU."""
for name, value in (("x", off_x), ("y", off_y)):
if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
raise ValueError(
f"DrawingML xfrm offset {name}={value} is outside the "
"OOXML coordinate range"
)
for name, value in (("cx", ext_cx), ("cy", ext_cy)):
if value < 0 or value > OOXML_COORDINATE_MAX:
raise ValueError(
f"DrawingML xfrm extent {name}={value} is outside the "
"OOXML positive-coordinate range"
)
def validate_ooxml_line_width(width: int) -> None:
"""Validate one DrawingML ``ST_LineWidth`` value in EMU."""
if width < 0 or width > OOXML_LINE_WIDTH_MAX:
raise ValueError(
f"DrawingML line width {width} is outside the OOXML line-width range"
)
def build_builtin_guides(
width: float,
height: float,
*,
left: float = 0.0,
top: float = 0.0,
) -> Mapping[str, float]:
"""Build the standard DrawingML geometry guide context for one frame."""
width = _finite_number(width, "width")
height = _finite_number(height, "height")
left = _finite_number(left, "left")
top = _finite_number(top, "top")
if width < 0 or height < 0:
raise FormulaEvaluationError("Shape width and height must be non-negative")
right = left + width
bottom = top + height
short_side = min(width, height)
long_side = max(width, height)
values = {
"l": left,
"t": top,
"r": right,
"b": bottom,
"w": width,
"h": height,
"hc": left + width / 2.0,
"vc": top + height / 2.0,
"ss": short_side,
"ls": long_side,
"cd2": FULL_CIRCLE / 2.0,
"cd3": FULL_CIRCLE / 3.0,
"cd4": FULL_CIRCLE / 4.0,
"cd8": FULL_CIRCLE / 8.0,
"3cd4": FULL_CIRCLE * 3.0 / 4.0,
"3cd8": FULL_CIRCLE * 3.0 / 8.0,
"5cd8": FULL_CIRCLE * 5.0 / 8.0,
"7cd8": FULL_CIRCLE * 7.0 / 8.0,
}
for divisor in (2, 3, 4, 5, 6, 8, 10, 12, 16, 32):
values[f"wd{divisor}"] = width / divisor
values[f"hd{divisor}"] = height / divisor
values[f"ssd{divisor}"] = short_side / divisor
return MappingProxyType(values)
class FormulaEvaluator:
"""Evaluate guide formulas against a shape-local DrawingML context."""
def __init__(
self,
width: float,
height: float,
*,
left: float = 0.0,
top: float = 0.0,
values: Mapping[str, float] | None = None,
) -> None:
self._builtins = dict(
build_builtin_guides(width, height, left=left, top=top)
)
self._values: dict[str, float] = {}
if values:
for name, value in values.items():
self.bind(name, value)
@property
def values(self) -> Mapping[str, float]:
"""Return a detached, read-only view of explicitly bound guides."""
return MappingProxyType(dict(self._values))
@property
def builtins(self) -> Mapping[str, float]:
"""Return the resolved built-in guide values."""
return MappingProxyType(dict(self._builtins))
def bind(self, name: str, value: float) -> float:
"""Bind one evaluated adjustment or guide and return its numeric value."""
if not name or name.isspace():
raise FormulaEvaluationError("Guide name must not be empty")
number = _finite_number(value, f"guide {name!r}")
self._values[name] = number
return number
def resolve(self, token: str) -> float:
"""Resolve a literal, bound guide, or DrawingML built-in guide token."""
token = token.strip()
if not token:
raise UnknownGuideError("Guide token must not be empty")
try:
return _finite_number(float(token), f"literal {token!r}")
except ValueError:
pass
if token in self._values:
return self._values[token]
if token in self._builtins:
return self._builtins[token]
dynamic = self._resolve_dynamic_builtin(token)
if dynamic is not None:
self._builtins[token] = dynamic
return dynamic
raise UnknownGuideError(f"Unknown DrawingML guide token: {token!r}")
def evaluate(self, formula: str) -> float:
"""Evaluate one complete DrawingML geometry formula."""
parts = formula.split()
if not parts:
raise FormulaSyntaxError("DrawingML formula must not be empty")
operator = parts[0]
expected = _OPERATOR_ARITY.get(operator)
if expected is None:
raise FormulaSyntaxError(
f"Unsupported DrawingML formula operator: {operator!r}"
)
if len(parts) < expected + 1:
raise FormulaSyntaxError(
f"Operator {operator!r} expects {expected} operands, "
f"received {len(parts) - 1}: {formula!r}"
)
trailing = parts[expected + 1 :]
if trailing and not (
operator == "+-" and all(token == "0" for token in trailing)
):
raise FormulaSyntaxError(
f"Operator {operator!r} expects {expected} operands, "
f"received {len(parts) - 1}: {formula!r}"
)
# POI 5.4.1 carries three circular-arrow formulas with one inert
# trailing zero ("+- xH 0 dxB 0"). Apache POI ignores surplus tokens;
# accept only this harmless form while keeping all other arity errors.
operands = tuple(self.resolve(token) for token in parts[1 : expected + 1])
try:
result = _apply_operator(operator, operands)
except (OverflowError, ValueError) as exc:
raise FormulaEvaluationError(
f"Cannot evaluate DrawingML formula {formula!r}: {exc}"
) from exc
return _finite_number(result, f"result of {formula!r}")
def evaluate_value(self, value: str | int | float) -> float:
"""Evaluate a formula string, guide token, or numeric adjustment value."""
if isinstance(value, str):
parts = value.split()
if not parts:
raise FormulaSyntaxError("DrawingML value must not be empty")
if parts[0] in SUPPORTED_OPERATORS:
return self.evaluate(value)
if len(parts) == 1:
return self.resolve(parts[0])
raise FormulaSyntaxError(f"Invalid DrawingML value: {value!r}")
return _finite_number(value, "guide value")
def _resolve_dynamic_builtin(self, token: str) -> float | None:
dimension_match = _DIMENSION_GUIDE_RE.fullmatch(token)
if dimension_match:
family, divisor_text = dimension_match.groups()
divisor = int(divisor_text)
base_name = {"wd": "w", "hd": "h", "ssd": "ss"}[family]
return self._builtins[base_name] / divisor
angle_match = _ANGLE_GUIDE_RE.fullmatch(token)
if angle_match:
numerator_text, divisor_text = angle_match.groups()
numerator = int(numerator_text or "1")
divisor = int(divisor_text)
return FULL_CIRCLE * numerator / divisor
return None
def evaluate_formula(
formula: str,
*,
width: float,
height: float,
left: float = 0.0,
top: float = 0.0,
values: Mapping[str, float] | None = None,
) -> float:
"""Evaluate one formula without manually constructing an evaluator."""
evaluator = FormulaEvaluator(
width,
height,
left=left,
top=top,
values=values,
)
return evaluator.evaluate(formula)
def _apply_operator(operator: str, values: tuple[float, ...]) -> float:
if operator == "val":
return values[0]
if operator == "*/":
return 0.0 if values[2] == 0 else values[0] * values[1] / values[2]
if operator == "+-":
return values[0] + values[1] - values[2]
if operator == "+/":
return 0.0 if values[2] == 0 else (values[0] + values[1]) / values[2]
if operator == "?:":
return values[1] if values[0] > 0 else values[2]
if operator == "abs":
return abs(values[0])
if operator == "at2":
return math.degrees(math.atan2(values[1], values[0])) * OOXML_DEGREE
if operator == "cat2":
return values[0] * math.cos(math.atan2(values[2], values[1]))
if operator == "cos":
return values[0] * math.cos(math.radians(values[1] / OOXML_DEGREE))
if operator == "max":
return max(values[0], values[1])
if operator == "min":
return min(values[0], values[1])
if operator == "mod":
return math.sqrt(sum(value * value for value in values))
if operator == "pin":
return max(values[0], min(values[1], values[2]))
if operator == "sat2":
return values[0] * math.sin(math.atan2(values[2], values[1]))
if operator == "sin":
return values[0] * math.sin(math.radians(values[1] / OOXML_DEGREE))
if operator == "sqrt":
return math.sqrt(values[0])
if operator == "tan":
return values[0] * math.tan(math.radians(values[1] / OOXML_DEGREE))
raise FormulaSyntaxError(f"Unsupported DrawingML formula operator: {operator!r}")
def _finite_number(value: float, label: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise FormulaEvaluationError(f"{label} must be numeric") from exc
if not math.isfinite(number):
raise FormulaEvaluationError(f"{label} must be finite")
return number
@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Shape Data Loader
Load and validate the bundled DrawingML preset geometry catalog.
Usage:
Import load_preset_shape_definitions from pptx_shapes.loader.
Examples:
definitions = load_preset_shape_definitions()
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from xml.etree import ElementTree as ET
from .errors import PresetShapeDataError
from .models import (
AdjustHandleDefinition,
ConnectionSiteDefinition,
GuideDefinition,
PathCommandDefinition,
PointExpression,
PresetShapeDefinition,
ShapePathDefinition,
TextRectangleDefinition,
)
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
EXPECTED_SHAPE_COUNT = 187
BUNDLED_DEFINITIONS_SHA256 = (
"4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780"
)
BUNDLED_SHAPE_TYPES_SHA256 = (
"f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587"
)
_PACKAGE_DIR = Path(__file__).resolve().parent
BUNDLED_DEFINITIONS_PATH = _PACKAGE_DIR / "data" / "presetShapeDefinitions.xml"
BUNDLED_SHAPE_TYPES_PATH = _PACKAGE_DIR / "data" / "shape_type_values.txt"
_EXPECTED_ROOT_TAG = "presetShapeDefinitons"
_PATH_COMMAND_ARITY = {
"moveTo": 1,
"lnTo": 1,
"quadBezTo": 2,
"cubicBezTo": 3,
"arcTo": 0,
"close": 0,
}
def load_shape_type_values(path: Path | None = None) -> tuple[str, ...]:
"""Load the independent Open XML ``ShapeTypeValues`` coverage list."""
source = path or BUNDLED_SHAPE_TYPES_PATH
try:
raw = source.read_bytes()
names = tuple(
line.strip()
for line in raw.decode("utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
except (OSError, UnicodeDecodeError) as exc:
raise PresetShapeDataError(
f"Cannot read preset shape type catalog {source}: {exc}"
) from exc
if path is None:
actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
if actual != BUNDLED_SHAPE_TYPES_SHA256:
raise PresetShapeDataError(
f"ShapeTypeValues checksum mismatch for {source}: "
f"expected {BUNDLED_SHAPE_TYPES_SHA256}, found {actual}"
)
if len(names) != len(set(names)):
raise PresetShapeDataError(f"Duplicate names in shape type catalog: {source}")
if len(names) != EXPECTED_SHAPE_COUNT:
raise PresetShapeDataError(
f"Expected {EXPECTED_SHAPE_COUNT} ShapeTypeValues, found {len(names)}"
)
return names
def load_preset_shape_definitions(
path: Path | None = None,
*,
expected_sha256: str | None = None,
expected_names: tuple[str, ...] | None = None,
) -> tuple[PresetShapeDefinition, ...]:
"""Load one preset XML catalog and enforce uniqueness and coverage.
The bundled catalog is hash-locked automatically. External catalogs are
validated structurally and can opt into a caller-provided hash.
"""
source = path or BUNDLED_DEFINITIONS_PATH
locked_hash = (
BUNDLED_DEFINITIONS_SHA256
if path is None and expected_sha256 is None
else expected_sha256
)
raw = _read_verified_bytes(source, locked_hash)
try:
root = ET.fromstring(raw)
except ET.ParseError as exc:
raise PresetShapeDataError(f"Invalid preset geometry XML {source}: {exc}") from exc
if _local_name(root.tag) != _EXPECTED_ROOT_TAG:
raise PresetShapeDataError(
f"Unexpected preset geometry root {_local_name(root.tag)!r} in {source}"
)
definitions = tuple(_parse_shape(element) for element in root)
names = tuple(definition.name for definition in definitions)
if len(names) != len(set(names)):
raise PresetShapeDataError(f"Duplicate preset geometry names in {source}")
catalog_names = expected_names
if catalog_names is None and path is None:
catalog_names = load_shape_type_values()
if catalog_names is not None:
missing = sorted(set(catalog_names) - set(names))
extra = sorted(set(names) - set(catalog_names))
if missing or extra:
raise PresetShapeDataError(
"Preset geometry coverage differs from ShapeTypeValues: "
f"missing={missing}, extra={extra}"
)
return definitions
def _read_verified_bytes(path: Path, expected_sha256: str | None) -> bytes:
try:
raw = path.read_bytes()
except OSError as exc:
raise PresetShapeDataError(f"Cannot read preset geometry data {path}: {exc}") from exc
if expected_sha256:
actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
if actual != expected_sha256:
raise PresetShapeDataError(
f"Preset geometry checksum mismatch for {path}: "
f"expected {expected_sha256}, found {actual}"
)
return raw
def _normalized_lf_bytes(raw: bytes) -> bytes:
"""Normalize checkout line endings before verifying text-resource hashes."""
return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
def _parse_shape(element: ET.Element) -> PresetShapeDefinition:
name = _local_name(element.tag)
if not name:
raise PresetShapeDataError("Preset shape name must not be empty")
adjustments = _parse_guides(_find_child(element, "avLst"))
guides = _parse_guides(_find_child(element, "gdLst"))
handles = _parse_handles(_find_child(element, "ahLst"))
connections = _parse_connections(_find_child(element, "cxnLst"))
text_rectangle = _parse_text_rectangle(_find_child(element, "rect"))
paths = _parse_paths(_find_child(element, "pathLst"), name)
return PresetShapeDefinition(
name=name,
adjustments=adjustments,
guides=guides,
handles=handles,
connections=connections,
text_rectangle=text_rectangle,
paths=paths,
)
def _parse_guides(container: ET.Element | None) -> tuple[GuideDefinition, ...]:
if container is None:
return ()
guides = []
for element in container:
if _local_name(element.tag) != "gd":
raise PresetShapeDataError(
f"Unexpected {_local_name(element.tag)!r} in guide list"
)
name = _required_attribute(element, "name")
formula = _required_attribute(element, "fmla")
guides.append(GuideDefinition(name=name, formula=formula))
# Some normative preset definitions intentionally rebind an intermediate
# name later in the ordered guide list. Preserve that sequential behavior.
return tuple(guides)
def _parse_handles(
container: ET.Element | None,
) -> tuple[AdjustHandleDefinition, ...]:
if container is None:
return ()
handles = []
for element in container:
kind = _local_name(element.tag)
if kind not in {"ahXY", "ahPolar"}:
raise PresetShapeDataError(f"Unexpected adjustment handle: {kind!r}")
position = _parse_position(element)
handles.append(
AdjustHandleDefinition(
kind="xy" if kind == "ahXY" else "polar",
position=position,
x_reference=element.attrib.get("gdRefX"),
minimum_x=element.attrib.get("minX"),
maximum_x=element.attrib.get("maxX"),
y_reference=element.attrib.get("gdRefY"),
minimum_y=element.attrib.get("minY"),
maximum_y=element.attrib.get("maxY"),
angle_reference=element.attrib.get("gdRefAng"),
minimum_angle=element.attrib.get("minAng"),
maximum_angle=element.attrib.get("maxAng"),
radius_reference=element.attrib.get("gdRefR"),
minimum_radius=element.attrib.get("minR"),
maximum_radius=element.attrib.get("maxR"),
)
)
return tuple(handles)
def _parse_connections(
container: ET.Element | None,
) -> tuple[ConnectionSiteDefinition, ...]:
if container is None:
return ()
connections = []
for element in container:
if _local_name(element.tag) != "cxn":
raise PresetShapeDataError(
f"Unexpected connection-site element: {_local_name(element.tag)!r}"
)
connections.append(
ConnectionSiteDefinition(
angle=_required_attribute(element, "ang"),
position=_parse_position(element),
)
)
return tuple(connections)
def _parse_text_rectangle(
element: ET.Element | None,
) -> TextRectangleDefinition | None:
if element is None:
return None
return TextRectangleDefinition(
left=_required_attribute(element, "l"),
top=_required_attribute(element, "t"),
right=_required_attribute(element, "r"),
bottom=_required_attribute(element, "b"),
)
def _parse_paths(
container: ET.Element | None,
shape_name: str,
) -> tuple[ShapePathDefinition, ...]:
if container is None:
raise PresetShapeDataError(f"Preset {shape_name!r} has no path list")
paths = []
for element in container:
if _local_name(element.tag) != "path":
raise PresetShapeDataError(
f"Unexpected path-list element: {_local_name(element.tag)!r}"
)
paths.append(
ShapePathDefinition(
coordinate_width=element.attrib.get("w"),
coordinate_height=element.attrib.get("h"),
fill=element.attrib.get("fill", "norm"),
stroke=_parse_boolean(element.attrib.get("stroke"), default=True),
extrusion_ok=_parse_boolean(
element.attrib.get("extrusionOk"),
default=True,
),
commands=tuple(_parse_path_command(command) for command in element),
)
)
if not paths:
raise PresetShapeDataError(f"Preset {shape_name!r} has an empty path list")
return tuple(paths)
def _parse_path_command(element: ET.Element) -> PathCommandDefinition:
name = _local_name(element.tag)
expected_points = _PATH_COMMAND_ARITY.get(name)
if expected_points is None:
raise PresetShapeDataError(f"Unsupported preset path command: {name!r}")
if name == "arcTo":
parameters = tuple(
_required_attribute(element, attribute)
for attribute in ("wR", "hR", "stAng", "swAng")
)
return PathCommandDefinition(name=name, parameters=parameters)
points = tuple(
child for child in element if _local_name(child.tag) == "pt"
)
if len(points) != expected_points:
raise PresetShapeDataError(
f"Path command {name!r} expects {expected_points} points, "
f"found {len(points)}"
)
parameters = tuple(
coordinate
for point in points
for coordinate in (
_required_attribute(point, "x"),
_required_attribute(point, "y"),
)
)
return PathCommandDefinition(name=name, parameters=parameters)
def _parse_position(parent: ET.Element) -> PointExpression:
positions = [
child for child in parent if _local_name(child.tag) == "pos"
]
if len(positions) != 1:
raise PresetShapeDataError(
f"{_local_name(parent.tag)!r} must contain exactly one position"
)
return PointExpression(
x=_required_attribute(positions[0], "x"),
y=_required_attribute(positions[0], "y"),
)
def _find_child(parent: ET.Element, local_name: str) -> ET.Element | None:
matches = [child for child in parent if _local_name(child.tag) == local_name]
if len(matches) > 1:
raise PresetShapeDataError(
f"Preset contains duplicate {local_name!r} elements"
)
return matches[0] if matches else None
def _required_attribute(element: ET.Element, name: str) -> str:
value = element.attrib.get(name)
if value is None or not value.strip():
raise PresetShapeDataError(
f"Element {_local_name(element.tag)!r} requires attribute {name!r}"
)
return value.strip()
def _parse_boolean(value: str | None, *, default: bool) -> bool:
if value is None:
return default
if value in {"true", "1"}:
return True
if value in {"false", "0"}:
return False
raise PresetShapeDataError(f"Invalid DrawingML boolean value: {value!r}")
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
PPT Master - PPTX Shape Models
Immutable value objects shared by the preset catalog loader and evaluator.
Usage:
Import model classes from pptx_shapes.models.
Examples:
from pptx_shapes.models import GuideDefinition
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
@dataclass(frozen=True)
class GuideDefinition:
"""One named DrawingML guide formula."""
name: str
formula: str
@dataclass(frozen=True)
class PointExpression:
"""A point whose coordinates are numeric literals or guide names."""
x: str
y: str
@dataclass(frozen=True)
class AdjustHandleDefinition:
"""An XY or polar adjustment handle from ``a:ahLst``."""
kind: str
position: PointExpression
x_reference: str | None = None
minimum_x: str | None = None
maximum_x: str | None = None
y_reference: str | None = None
minimum_y: str | None = None
maximum_y: str | None = None
angle_reference: str | None = None
minimum_angle: str | None = None
maximum_angle: str | None = None
radius_reference: str | None = None
minimum_radius: str | None = None
maximum_radius: str | None = None
@dataclass(frozen=True)
class ConnectionSiteDefinition:
"""One preset connection site before guide evaluation."""
angle: str
position: PointExpression
@dataclass(frozen=True)
class TextRectangleDefinition:
"""The preset's internal text rectangle expressions."""
left: str
top: str
right: str
bottom: str
@dataclass(frozen=True)
class PathCommandDefinition:
"""A DrawingML path command with parameters in document order.
Parameter order is ``x,y`` for move/line, point order for Bezier commands,
and ``wR,hR,stAng,swAng`` for arcs. ``close`` has no parameters.
"""
name: str
parameters: tuple[str, ...]
@dataclass(frozen=True)
class ShapePathDefinition:
"""One path in a preset geometry definition."""
coordinate_width: str | None
coordinate_height: str | None
fill: str
stroke: bool
extrusion_ok: bool
commands: tuple[PathCommandDefinition, ...]
@dataclass(frozen=True)
class PresetShapeDefinition:
"""Complete immutable source definition for one preset shape."""
name: str
adjustments: tuple[GuideDefinition, ...]
guides: tuple[GuideDefinition, ...]
handles: tuple[AdjustHandleDefinition, ...]
connections: tuple[ConnectionSiteDefinition, ...]
text_rectangle: TextRectangleDefinition | None
paths: tuple[ShapePathDefinition, ...]
@dataclass(frozen=True)
class EvaluatedPoint:
"""A point in the evaluated shape-local coordinate system."""
x: float
y: float
@dataclass(frozen=True)
class EvaluatedAdjustHandle:
"""An adjustment handle with resolved position and constraint bounds."""
kind: str
position: EvaluatedPoint
x_reference: str | None = None
minimum_x: float | None = None
maximum_x: float | None = None
y_reference: str | None = None
minimum_y: float | None = None
maximum_y: float | None = None
angle_reference: str | None = None
minimum_angle: float | None = None
maximum_angle: float | None = None
radius_reference: str | None = None
minimum_radius: float | None = None
maximum_radius: float | None = None
@dataclass(frozen=True)
class EvaluatedConnectionSite:
"""A connection site with resolved angle and position."""
angle: float
position: EvaluatedPoint
@dataclass(frozen=True)
class EvaluatedTextRectangle:
"""A resolved internal text rectangle."""
left: float
top: float
right: float
bottom: float
@dataclass(frozen=True)
class EvaluatedPathCommand:
"""A path command whose parameters have all been resolved to numbers."""
name: str
parameters: tuple[float, ...]
@dataclass(frozen=True)
class EvaluatedShapePath:
"""One resolved path and its DrawingML paint behavior."""
coordinate_width: float
coordinate_height: float
fill: str
stroke: bool
extrusion_ok: bool
commands: tuple[EvaluatedPathCommand, ...]
@dataclass(frozen=True)
class EvaluatedPresetGeometry:
"""All evaluated semantic geometry for one preset shape instance."""
name: str
width: float
height: float
left: float
top: float
adjustments: Mapping[str, float]
guides: Mapping[str, float]
handles: tuple[EvaluatedAdjustHandle, ...]
connections: tuple[EvaluatedConnectionSite, ...]
text_rectangle: EvaluatedTextRectangle | None
paths: tuple[EvaluatedShapePath, ...]
def immutable_mapping(values: Mapping[str, float]) -> Mapping[str, float]:
"""Return a detached, read-only copy of a numeric mapping."""
return MappingProxyType(dict(values))
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Shape Registry
Expose the complete preset catalog and evaluate shape geometry instances.
Usage:
Import get_preset_registry from pptx_shapes.registry.
Examples:
geometry = get_preset_registry().evaluate("rightArrow", 320, 160)
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
from functools import lru_cache
from typing import Mapping
from .errors import (
FormulaEvaluationError,
PresetShapeDataError,
UnknownPresetShapeError,
)
from .formula import FormulaEvaluator
from .loader import load_preset_shape_definitions, load_shape_type_values
from .models import (
AdjustHandleDefinition,
EvaluatedAdjustHandle,
EvaluatedConnectionSite,
EvaluatedPathCommand,
EvaluatedPoint,
EvaluatedPresetGeometry,
EvaluatedShapePath,
EvaluatedTextRectangle,
PointExpression,
PresetShapeDefinition,
immutable_mapping,
)
AdjustmentValue = str | int | float
CONNECTOR_PRESET_TYPES = frozenset(
{
"line",
"lineInv",
"straightConnector1",
"bentConnector2",
"bentConnector3",
"bentConnector4",
"bentConnector5",
"curvedConnector2",
"curvedConnector3",
"curvedConnector4",
"curvedConnector5",
}
)
class PresetShapeRegistry:
"""Read-only registry for all standard DrawingML preset geometries."""
def __init__(
self,
definitions: tuple[PresetShapeDefinition, ...],
expected_names: tuple[str, ...],
) -> None:
self._definitions = {
definition.name: definition for definition in definitions
}
self._names = tuple(expected_names)
if len(self._definitions) != len(definitions):
raise PresetShapeDataError("Preset registry contains duplicate names")
missing = sorted(set(expected_names) - set(self._definitions))
extra = sorted(set(self._definitions) - set(expected_names))
if missing or extra:
raise PresetShapeDataError(
"Preset registry differs from ShapeTypeValues: "
f"missing={missing}, extra={extra}"
)
@classmethod
def bundled(cls) -> PresetShapeRegistry:
"""Load the hash-locked catalog shipped with PPT Master."""
names = load_shape_type_values()
definitions = load_preset_shape_definitions(expected_names=names)
return cls(definitions, names)
@property
def names(self) -> tuple[str, ...]:
"""Return all names in official ``ShapeTypeValues`` order."""
return self._names
def __len__(self) -> int:
return len(self._definitions)
def __contains__(self, name: object) -> bool:
return isinstance(name, str) and name in self._definitions
def contains(self, name: str) -> bool:
"""Return whether ``name`` is a standard preset in this catalog."""
return name in self._definitions
def get(self, name: str) -> PresetShapeDefinition:
"""Return one immutable source definition or raise a precise error."""
try:
return self._definitions[name]
except KeyError as exc:
raise UnknownPresetShapeError(
f"Unknown DrawingML preset shape: {name!r}"
) from exc
def evaluate(
self,
name: str,
width: float,
height: float,
*,
adjustments: Mapping[str, AdjustmentValue] | None = None,
) -> EvaluatedPresetGeometry:
"""Evaluate every guide, handle, connection, text rect, and path."""
definition = self.get(name)
evaluator = FormulaEvaluator(width, height)
supplied = dict(adjustments or {})
adjustment_names = {guide.name for guide in definition.adjustments}
unknown = sorted(set(supplied) - adjustment_names)
if unknown:
raise FormulaEvaluationError(
f"Preset {name!r} has no adjustments named {unknown}"
)
evaluated_adjustments: dict[str, float] = {}
for guide in definition.adjustments:
source = supplied.get(guide.name, guide.formula)
value = evaluator.evaluate_value(source)
evaluated_adjustments[guide.name] = evaluator.bind(guide.name, value)
evaluated_guides: dict[str, float] = {}
for guide in definition.guides:
value = evaluator.evaluate(guide.formula)
evaluated_guides[guide.name] = evaluator.bind(guide.name, value)
return EvaluatedPresetGeometry(
name=name,
width=evaluator.resolve("w"),
height=evaluator.resolve("h"),
left=evaluator.resolve("l"),
top=evaluator.resolve("t"),
adjustments=immutable_mapping(evaluated_adjustments),
guides=immutable_mapping(evaluated_guides),
handles=tuple(
_evaluate_handle(handle, evaluator)
for handle in definition.handles
),
connections=tuple(
EvaluatedConnectionSite(
angle=evaluator.evaluate_value(connection.angle),
position=_evaluate_point(connection.position, evaluator),
)
for connection in definition.connections
),
text_rectangle=_evaluate_text_rectangle(definition, evaluator),
paths=tuple(
EvaluatedShapePath(
coordinate_width=_path_extent(
path.coordinate_width,
evaluator.resolve("w"),
evaluator,
),
coordinate_height=_path_extent(
path.coordinate_height,
evaluator.resolve("h"),
evaluator,
),
fill=path.fill,
stroke=path.stroke,
extrusion_ok=path.extrusion_ok,
commands=tuple(
EvaluatedPathCommand(
name=command.name,
parameters=tuple(
evaluator.evaluate_value(parameter)
for parameter in command.parameters
),
)
for command in path.commands
),
)
for path in definition.paths
),
)
@lru_cache(maxsize=1)
def get_preset_registry() -> PresetShapeRegistry:
"""Return the process-wide, lazily loaded bundled registry."""
return PresetShapeRegistry.bundled()
def _evaluate_point(
point: PointExpression,
evaluator: FormulaEvaluator,
) -> EvaluatedPoint:
return EvaluatedPoint(
x=evaluator.evaluate_value(point.x),
y=evaluator.evaluate_value(point.y),
)
def _evaluate_handle(
handle: AdjustHandleDefinition,
evaluator: FormulaEvaluator,
) -> EvaluatedAdjustHandle:
return EvaluatedAdjustHandle(
kind=handle.kind,
position=_evaluate_point(handle.position, evaluator),
x_reference=handle.x_reference,
minimum_x=_optional_value(handle.minimum_x, evaluator),
maximum_x=_optional_value(handle.maximum_x, evaluator),
y_reference=handle.y_reference,
minimum_y=_optional_value(handle.minimum_y, evaluator),
maximum_y=_optional_value(handle.maximum_y, evaluator),
angle_reference=handle.angle_reference,
minimum_angle=_optional_value(handle.minimum_angle, evaluator),
maximum_angle=_optional_value(handle.maximum_angle, evaluator),
radius_reference=handle.radius_reference,
minimum_radius=_optional_value(handle.minimum_radius, evaluator),
maximum_radius=_optional_value(handle.maximum_radius, evaluator),
)
def _evaluate_text_rectangle(
definition: PresetShapeDefinition,
evaluator: FormulaEvaluator,
) -> EvaluatedTextRectangle | None:
rectangle = definition.text_rectangle
if rectangle is None:
return None
return EvaluatedTextRectangle(
left=evaluator.evaluate_value(rectangle.left),
top=evaluator.evaluate_value(rectangle.top),
right=evaluator.evaluate_value(rectangle.right),
bottom=evaluator.evaluate_value(rectangle.bottom),
)
def _optional_value(
value: str | None,
evaluator: FormulaEvaluator,
) -> float | None:
return None if value is None else evaluator.evaluate_value(value)
def _path_extent(
value: str | None,
shape_extent: float,
evaluator: FormulaEvaluator,
) -> float:
if value is None:
return shape_extent
resolved = evaluator.evaluate_value(value)
return shape_extent if resolved == 0 else resolved
@@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""
PPT Master - Native Shape Semantic Fingerprints
Build stable hashes for visible SVG text, generated preset previews, and
native chart/table fallback subtrees.
Usage:
Import the fingerprint helper for the relevant semantic carrier.
Examples:
digest = svg_text_fingerprint(group_element)
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
import hashlib
import json
import re
from xml.etree import ElementTree as ET
_ROOT_TEXT_STYLE_ATTRS = frozenset({
"class",
"fill",
"fill-opacity",
"font-family",
"font-size",
"font-style",
"font-weight",
"letter-spacing",
"opacity",
"style",
"text-anchor",
"text-decoration",
"word-spacing",
})
NATIVE_FALLBACK_SHA256_ATTR = "data-pptx-fallback-sha256"
_NATIVE_FALLBACK_IGNORED_TAGS = frozenset({"metadata", "title", "desc"})
_NATIVE_FALLBACK_IGNORED_ATTRS = frozenset({
"id",
"data-name",
"data-ph-type",
})
_URL_ID_RE = re.compile(
r"url\(\s*(?P<quote>['\"]?)#(?P<id>[^)'\"\s]+)(?P=quote)\s*\)",
re.IGNORECASE,
)
def svg_text_fingerprint(root: ET.Element) -> str:
"""Hash text content, structure, positioning, and visible typography.
Shape-level movement is intentionally excluded: the native ``a:xfrm``
owns that change and the original ``p:txBody`` remains valid. Text/tspan
transforms and all their non-semantic attributes remain part of the hash.
"""
payload = {
"root_style": sorted(
(name, value)
for name, value in root.attrib.items()
if name in _ROOT_TEXT_STYLE_ATTRS
),
"text": [
_element_payload(element)
for element in root.iter()
if _local_name(element.tag) == "text"
],
}
canonical = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def svg_preset_preview_fingerprint(root: ET.Element) -> str:
"""Hash the complete visible preview subtree and intermediate wrappers."""
payload = _preview_subtree(root, is_root=True, active=False)
canonical = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def svg_native_fallback_fingerprint(
root: ET.Element,
*,
document_root: ET.Element | None = None,
) -> str:
"""Hash one native chart/table marker's rendering-relevant SVG subtree.
Native metadata, editor/runtime attributes, and stable element IDs are not
fallback artwork. Reachable document-level fragment definitions are hashed
when ``document_root`` is available. Transforms remain part of the digest
because complete explicit native bounds are absolute and therefore do not
consume marker transforms; changing one must make the replacement stale.
"""
id_tokens = _native_fallback_id_tokens(root)
dependencies = _native_fallback_external_dependencies(
root,
document_root,
id_tokens,
)
payload = _native_fallback_subtree(
root,
id_tokens=id_tokens,
)
if dependencies:
payload = {
"marker": payload,
"external_dependencies": [
{
"token": id_tokens[element_id],
"node": _native_fallback_subtree(
target,
id_tokens=id_tokens,
force_include=True,
),
}
for element_id, target in dependencies
],
}
canonical = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def svg_native_fallback_markup_fingerprint(
markup: str,
*,
root_transform: str | None = None,
external_markup: str | None = None,
) -> str:
"""Hash an SVG fallback fragment through the canonical marker function."""
if external_markup:
document_root = ET.fromstring(
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
f"<defs>{external_markup}</defs><g>{markup}</g>"
"</svg>"
)
wrapper = document_root[-1]
else:
document_root = None
wrapper = ET.fromstring(
'<g xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
f"{markup}"
"</g>"
)
if root_transform:
wrapper.set("transform", root_transform)
return svg_native_fallback_fingerprint(
wrapper,
document_root=document_root,
)
def resolve_preset_preview_hash(root: ET.Element) -> str | None:
"""Resolve and cross-check a logical preset group's fingerprint contract.
The hash is duplicated on the logical group and hidden native carrier so
stripping either copy cannot disable stale-preview detection. A visible
generated preview without either hash is invalid rather than legacy SVG.
"""
has_preview = any(
element.get("data-pptx-part")
in {"geometry-preview", "geometry-detail"}
for element in root.iter()
)
carrier_hashes = {
value
for element in root.iter()
if element.get("data-pptx-part") == "geometry"
and (value := element.get("data-pptx-preview-sha256")) is not None
}
group_hash = root.get("data-pptx-preview-sha256")
if not has_preview and not carrier_hashes and group_hash is None:
return None
if len(carrier_hashes) > 1:
raise ValueError("Native geometry carriers have inconsistent preview hashes")
carrier_hash = next(iter(carrier_hashes), None)
if (
group_hash is not None
and carrier_hash is not None
and group_hash != carrier_hash
):
raise ValueError("Logical group and native carrier preview hashes differ")
expected = group_hash or carrier_hash
if expected is None:
raise ValueError("Generated preset preview is missing its fingerprint")
return expected
def _preview_subtree(
element: ET.Element,
*,
is_root: bool,
active: bool,
) -> dict | None:
part = element.get("data-pptx-part")
contains_preview = part in {"geometry-preview", "geometry-detail"}
child_active = active or contains_preview
children = [
payload
for child in element
if (
payload := _preview_subtree(
child,
is_root=False,
active=child_active,
)
) is not None
]
if is_root:
return {"children": children}
if not child_active and not children:
return None
return {
"tag": _local_name(element.tag),
"attrs": sorted(
(name, value)
for name, value in element.attrib.items()
if name != "id"
and name != "data-pptx-preview-sha256"
and not name.startswith("data-pptx-runtime-")
),
"children": children,
}
def _native_fallback_subtree(
element: ET.Element,
*,
id_tokens: dict[str, str],
force_include: bool = False,
) -> dict | None:
tag = _local_name(element.tag)
if not force_include and _native_fallback_element_hidden(element):
return None
attrs = []
for raw_name, raw_value in element.attrib.items():
name = _local_name(raw_name)
if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
continue
if name.startswith("data-pptx-"):
continue
attrs.append((
raw_name,
_normalize_native_fallback_id_refs(name, raw_value, id_tokens),
))
children = []
for child in element:
child_payload = _native_fallback_subtree(
child,
id_tokens=id_tokens,
)
if child_payload is None:
continue
entry = {"node": child_payload}
if child.tail and (
child.tail.strip() or tag in {"text", "tspan", "textPath"}
):
entry["tail"] = child.tail
children.append(entry)
text = element.text or ""
payload = {
"tag": tag,
"attrs": sorted(attrs),
"children": children,
}
if text and (text.strip() or tag in {"text", "tspan", "textPath", "style"}):
payload["text"] = text
return payload
def _native_fallback_id_tokens(
root: ET.Element,
) -> dict[str, str]:
tokens: dict[str, str] = {}
def visit(
element: ET.Element,
*,
is_root: bool,
path: tuple[int, ...],
) -> None:
element_id = None if is_root else element.get("id")
if element_id and element_id not in tokens:
tokens[element_id] = "native-node-" + "-".join(map(str, path))
canonical_index = 0
for child in element:
if _native_fallback_element_hidden(child):
continue
visit(
child,
is_root=False,
path=(*path, canonical_index),
)
canonical_index += 1
visit(root, is_root=True, path=())
return tokens
def _native_fallback_external_dependencies(
marker: ET.Element,
document_root: ET.Element | None,
id_tokens: dict[str, str],
) -> list[tuple[str, ET.Element]]:
"""Resolve the marker's reachable document-level fragment references."""
if document_root is None or document_root is marker:
return []
marker_nodes = set(marker.iter())
targets: dict[str, ET.Element] = {}
for element in document_root.iter():
element_id = element.get("id")
if element_id and element_id not in targets:
targets[element_id] = element
dependencies: list[tuple[str, ET.Element]] = []
def add_reference(element_id: str) -> None:
if not element_id or element_id in id_tokens:
return
target = targets.get(element_id)
if target is None or target in marker_nodes:
return
id_tokens[element_id] = f"native-external-{len(dependencies) + 1}"
dependencies.append((element_id, target))
for nested_id in _native_fallback_fragment_references(
target,
force_include_root=True,
):
add_reference(nested_id)
for element_id in _native_fallback_fragment_references(marker):
add_reference(element_id)
return dependencies
def _native_fallback_fragment_references(
root: ET.Element,
*,
force_include_root: bool = False,
) -> list[str]:
references: list[str] = []
seen: set[str] = set()
def add(element_id: str) -> None:
if element_id and element_id not in seen:
seen.add(element_id)
references.append(element_id)
def visit(element: ET.Element, *, force_include: bool) -> None:
if not force_include and _native_fallback_element_hidden(element):
return
for raw_name, raw_value in sorted(element.attrib.items()):
name = _local_name(raw_name)
if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
continue
if name.startswith("data-pptx-"):
continue
for match in _URL_ID_RE.finditer(raw_value):
add(match.group("id"))
if name == "href" and raw_value.startswith("#"):
add(raw_value[1:])
for child in element:
visit(child, force_include=False)
visit(root, force_include=force_include_root)
return references
def _native_fallback_element_hidden(element: ET.Element) -> bool:
if _local_name(element.tag) in _NATIVE_FALLBACK_IGNORED_TAGS:
return True
# ``display:none`` suppresses the entire descendant subtree. SVG
# ``visibility`` is different: a descendant may explicitly restore
# ``visibility:visible``. Keep visibility-hidden content in the digest
# conservatively so such visible descendants cannot evade stale detection.
return _native_fallback_style_value(element, "display") == "none"
def _normalize_native_fallback_id_refs(
name: str,
value: str,
id_tokens: dict[str, str],
) -> str:
def replace_url(match: re.Match[str]) -> str:
token = id_tokens.get(match.group("id"))
return f"url(#{token})" if token is not None else match.group(0)
normalized = _URL_ID_RE.sub(replace_url, value)
if name in {"href", "xlink:href"} and normalized.startswith("#"):
token = id_tokens.get(normalized[1:])
if token is not None:
return f"#{token}"
return normalized
def _native_fallback_style_value(element: ET.Element, name: str) -> str | None:
raw = element.get(name)
if raw is not None:
return raw.strip().lower()
style = element.get("style") or ""
for declaration in style.split(";"):
if ":" not in declaration:
continue
key, value = declaration.split(":", 1)
if key.strip().lower() == name:
return value.strip().lower()
return None
def _element_payload(element: ET.Element) -> dict:
return {
"tag": _local_name(element.tag),
"attrs": sorted(
(name, value)
for name, value in element.attrib.items()
if not name.startswith("data-pptx-") and name != "id"
),
"text": element.text or "",
"children": [
{
"node": _element_payload(child),
"tail": child.tail or "",
}
for child in element
if _local_name(child.tag) in {"text", "tspan"}
],
}
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Safety checks for opaque DrawingML fragments copied between PPTX parts."""
from __future__ import annotations
from xml.etree import ElementTree as ET
RELATIONSHIPS_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
_RELATIONSHIP_ATTRIBUTE_PREFIX = f"{{{RELATIONSHIPS_NS}}}"
def has_relationship_attributes(root: ET.Element) -> bool:
"""Return whether a subtree contains any part-local relationship QName."""
return any(
attribute.startswith(_RELATIONSHIP_ATTRIBUTE_PREFIX)
for element in root.iter()
for attribute in element.attrib
)
@@ -26,6 +26,7 @@ from __future__ import annotations
import argparse
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
# Allow running this script from anywhere
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -37,6 +38,23 @@ from pptx_to_svg.converter import ConvertOptions
configure_utf8_stdio()
def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]:
"""Return slide/object labels for generated placeholders."""
artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", [])
diagnostics: list[tuple[int, str]] = []
for artifact in artifacts:
try:
root = ET.fromstring(artifact.svg)
except ET.ParseError:
continue
for elem in root.iter():
if elem.get("data-pptx-route-status") != "reconstruction-only":
continue
marker_id = elem.get("id") or elem.get("data-name") or "<unnamed>"
diagnostics.append((artifact.index, marker_id))
return diagnostics
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert .pptx to per-slide SVG by reading OOXML directly.",
@@ -110,6 +128,21 @@ def main() -> int:
fonts = ", ".join(f"{k}={v}" for k, v in result.theme_fonts.items())
print(f"Theme fonts: {fonts}")
print(f"Slides converted: {len(result.slides)}")
reconstruction_only = _reconstruction_only_graphics(result)
if reconstruction_only:
print(
"Warning: chart placeholder(s) without a baked preview are "
"reconstruction-only. Default export keeps the placeholder; "
"--native-objects may reconstruct entries with a valid active marker:",
file=sys.stderr,
)
for slide_index, marker_id in reconstruction_only[:20]:
print(f" slide {slide_index}: {marker_id}", file=sys.stderr)
if len(reconstruction_only) > 20:
print(
f" ... and {len(reconstruction_only) - 20} more",
file=sys.stderr,
)
print(f"Output: {output_dir}")
return 0
@@ -8,6 +8,13 @@ Public entry: convert_pptx_to_svg().
from __future__ import annotations
from .converter import convert_pptx_to_svg
__all__ = ["convert_pptx_to_svg"]
def __getattr__(name: str):
"""Load the public converter lazily so shared submodules stay lightweight."""
if name != "convert_pptx_to_svg":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from .converter import convert_pptx_to_svg
return convert_pptx_to_svg
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
"""Extract native ChartEx payloads from PPTX chart parts.
The parser is deliberately closed around the seven ChartEx data models that
the native writer already emits. Data topology and caches must be complete;
unmodeled chart chrome, labels, axes, binning options, and style details are
allowed to normalize during editable native reconstruction.
"""
from __future__ import annotations
import math
import re
from typing import Any
from xml.etree import ElementTree as ET
from svg_to_pptx.native_objects.chart_data import validate_chart_payload
from svg_to_pptx.native_objects.marker_common import CHART_COLOR_STYLE_REL_TYPE
from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color
from .emu_units import NS, Xfrm
from .ooxml_loader import OoxmlPackage, PartRef
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
CX_NS = {
**NS,
"cx": CHARTEX_URI,
}
_INT_TOKEN_RE = re.compile(r"[0-9]+")
_HIERARCHY_LAYOUTS = {"sunburst", "treemap"}
_FLAT_LAYOUTS = {"funnel", "waterfall"}
_LEGEND_POSITIONS = {"b", "l", "r", "t"}
class UnsupportedChartEx(RuntimeError):
"""A ChartEx part cannot map to the current native authoring schema."""
def __init__(self, status: str) -> None:
super().__init__(status)
self.status = status
def extract_native_chartex_payload(
graphic_data: ET.Element | None,
xfrm: Xfrm,
slide_part: PartRef,
pkg: OoxmlPackage,
palette: ColorPalette | None = None,
) -> dict[str, Any]:
"""Return a writer-valid payload for one supported ChartEx reference.
``UnsupportedChartEx.status`` is suitable for ``data-pptx-native-status``
when the reference, relationship, data topology, or cache is unusable.
Style-part failures never reject an otherwise valid data payload.
"""
if graphic_data is None:
raise UnsupportedChartEx("unsupported-chart-reference")
if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
raise UnsupportedChartEx("unsupported-native-transform")
chart_ref = graphic_data.find("cx:chart", CX_NS)
if chart_ref is None:
raise UnsupportedChartEx("unsupported-chart-reference")
rid = chart_ref.attrib.get(f"{{{NS['r']}}}id")
if not rid:
raise UnsupportedChartEx("unsupported-chart-reference")
chart_path = slide_part.resolve_rel(rid)
if not chart_path:
raise UnsupportedChartEx("unsupported-chart-relationship")
chart_part = pkg.load_part(chart_path)
if chart_part is None:
raise UnsupportedChartEx("unsupported-chart-part")
try:
payload = _payload_from_chartex_xml(chart_part.xml, xfrm)
colors = _resolved_chart_colors(chart_part, pkg, palette)
if colors:
payload["style"] = {"colors": colors}
validate_chart_payload(payload)
except UnsupportedChartEx:
raise
except RuntimeError as exc:
raise UnsupportedChartEx("unsupported-chartex-schema") from exc
except (AttributeError, OverflowError, TypeError, ValueError) as exc:
raise UnsupportedChartEx("unsupported-chartex-parse") from exc
return payload
def _payload_from_chartex_xml(
chart_root: ET.Element,
xfrm: Xfrm,
) -> dict[str, Any]:
if chart_root.tag != f"{{{CHARTEX_URI}}}chartSpace":
raise UnsupportedChartEx("unsupported-chartex-part")
chart_data = _one_child(chart_root, "chartData", "unsupported-chartex-structure")
chart = _one_child(chart_root, "chart", "unsupported-chartex-structure")
plot_area = _one_child(chart, "plotArea", "unsupported-chartex-structure")
region = _one_child(plot_area, "plotAreaRegion", "unsupported-chartex-structure")
data_by_id = _data_parts(chart_data)
series_nodes = _children(region, "series")
if not series_nodes:
raise UnsupportedChartEx("unsupported-chartex-series")
chart_type = _chart_type(series_nodes)
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"type": chart_type,
}
referenced_data_ids: list[int]
if chart_type in _HIERARCHY_LAYOUTS:
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
raw_levels = _string_dimension(data, expected_levels=None)
values = _numeric_dimension(data, "size")
if not raw_levels or any(len(level) != len(values) for level in raw_levels):
raise UnsupportedChartEx("unsupported-chartex-cache")
# ChartEx serializes the innermost level first; the authoring schema
# and workbook writer use the natural outermost-to-innermost order.
payload["levels"] = list(reversed(raw_levels))
payload["values"] = values
if chart_type == "treemap":
parent_labels = _treemap_parent_labels(series_nodes[0])
if parent_labels is not None:
payload["parent_label_layout"] = parent_labels
referenced_data_ids = [data_id]
elif chart_type == "histogram":
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
_reject_dimensions(data, allowed={"numDim"})
payload["values"] = _numeric_dimension(data, "val")
referenced_data_ids = [data_id]
elif chart_type in {"funnel", "pareto", "waterfall"}:
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
categories = _string_dimension(data, expected_levels=1)[0]
values = _numeric_dimension(data, "val")
if len(categories) != len(values):
raise UnsupportedChartEx("unsupported-chartex-cache")
payload["categories"] = categories
payload["values"] = values
if chart_type == "waterfall":
payload["subtotals"] = _waterfall_subtotals(series_nodes[0])
referenced_data_ids = [data_id]
elif chart_type == "box_whisker":
items: list[dict[str, Any]] = []
referenced_data_ids = []
for index, series in enumerate(series_nodes, start=1):
data_id = _series_data_id(series)
if data_id in referenced_data_ids:
raise UnsupportedChartEx("unsupported-chartex-data-id")
data = _require_data(data_by_id, data_id)
categories = _string_dimension(data, expected_levels=1)[0]
values = _numeric_dimension(data, "val")
if len(categories) != len(values):
raise UnsupportedChartEx("unsupported-chartex-cache")
items.append({
"categories": categories,
"name": _series_name(series, index),
"values": values,
})
referenced_data_ids.append(data_id)
payload["series"] = items
else: # pragma: no cover - _chart_type is closed, keep the invariant explicit.
raise UnsupportedChartEx("unsupported-chartex-type")
if set(referenced_data_ids) != set(data_by_id):
raise UnsupportedChartEx("unsupported-chartex-data-id")
legend = chart.find("cx:legend", CX_NS)
if legend is not None:
payload["show_legend"] = True
position = legend.attrib.get("pos")
if position in _LEGEND_POSITIONS:
payload["legend_position"] = position
return payload
def _chart_type(series_nodes: list[ET.Element]) -> str:
layouts = [series.attrib.get("layoutId", "") for series in series_nodes]
if len(series_nodes) == 1 and layouts[0] in _HIERARCHY_LAYOUTS | _FLAT_LAYOUTS:
return layouts[0]
if layouts and all(layout == "boxWhisker" for layout in layouts):
return "box_whisker"
if len(series_nodes) == 1 and layouts == ["clusteredColumn"]:
if series_nodes[0].find("cx:layoutPr/cx:binning", CX_NS) is None:
raise UnsupportedChartEx("unsupported-chartex-series")
return "histogram"
if len(series_nodes) == 2 and layouts == ["clusteredColumn", "paretoLine"]:
primary, line = series_nodes
if (
primary.find("cx:layoutPr/cx:aggregation", CX_NS) is None
or line.attrib.get("ownerIdx") != "0"
or line.find("cx:dataId", CX_NS) is not None
):
raise UnsupportedChartEx("unsupported-chartex-series")
return "pareto"
raise UnsupportedChartEx("unsupported-chartex-type")
def _data_parts(chart_data: ET.Element) -> dict[int, ET.Element]:
result: dict[int, ET.Element] = {}
for data in _children(chart_data, "data"):
data_id = _nonnegative_int(data.attrib.get("id"), "unsupported-chartex-data-id")
if data_id in result:
raise UnsupportedChartEx("unsupported-chartex-data-id")
result[data_id] = data
if not result:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return result
def _series_data_id(series: ET.Element) -> int:
data_ids = _children(series, "dataId")
if len(data_ids) != 1:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return _nonnegative_int(
data_ids[0].attrib.get("val"),
"unsupported-chartex-data-id",
)
def _require_data(data_by_id: dict[int, ET.Element], data_id: int) -> ET.Element:
data = data_by_id.get(data_id)
if data is None:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return data
def _string_dimension(
data: ET.Element,
*,
expected_levels: int | None,
) -> list[list[str]]:
_reject_dimensions(data, allowed={"strDim", "numDim"})
dimensions = [
child
for child in _children(data, "strDim")
if child.attrib.get("type") == "cat"
]
if len(dimensions) != 1 or len(_children(data, "strDim")) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
levels = _children(dimensions[0], "lvl")
if not levels or (expected_levels is not None and len(levels) != expected_levels):
raise UnsupportedChartEx("unsupported-chartex-dimension")
return [_level_values(level, numeric=False) for level in levels]
def _numeric_dimension(data: ET.Element, dim_type: str) -> list[int | float]:
dimensions = [
child
for child in _children(data, "numDim")
if child.attrib.get("type") == dim_type
]
if len(dimensions) != 1 or len(_children(data, "numDim")) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
levels = _children(dimensions[0], "lvl")
if len(levels) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
return _level_values(levels[0], numeric=True)
def _reject_dimensions(data: ET.Element, *, allowed: set[str]) -> None:
for child in data:
name = _local_name(child.tag)
if name.endswith("Dim") and name not in allowed:
raise UnsupportedChartEx("unsupported-chartex-dimension")
def _level_values(
level: ET.Element,
*,
numeric: bool,
) -> list[Any]:
point_count = _nonnegative_int(
level.attrib.get("ptCount"),
"unsupported-chartex-cache",
)
points: dict[int, Any] = {}
for point in _children(level, "pt"):
point_index = _nonnegative_int(
point.attrib.get("idx"),
"unsupported-chartex-cache",
)
if point_index >= point_count or point_index in points or list(point):
raise UnsupportedChartEx("unsupported-chartex-cache")
raw_value = point.text or ""
points[point_index] = (
_numeric_value(raw_value)
if numeric
else raw_value
)
if (
len(points) != point_count
or any(index not in points for index in range(point_count))
):
raise UnsupportedChartEx("unsupported-chartex-cache")
return [points[index] for index in range(point_count)]
def _numeric_value(raw_value: str) -> int | float:
if not raw_value.strip():
raise UnsupportedChartEx("unsupported-chartex-cache")
try:
number = float(raw_value)
except (OverflowError, ValueError):
raise UnsupportedChartEx("unsupported-chartex-cache") from None
if not math.isfinite(number):
raise UnsupportedChartEx("unsupported-chartex-cache")
return int(number) if number.is_integer() else number
def _waterfall_subtotals(series: ET.Element) -> list[int]:
subtotals: list[int] = []
for item in series.findall("cx:layoutPr/cx:subtotals/cx:idx", CX_NS):
subtotals.append(
_nonnegative_int(item.attrib.get("val"), "unsupported-chartex-cache")
)
return subtotals
def _treemap_parent_labels(series: ET.Element) -> str | None:
parent = series.find("cx:layoutPr/cx:parentLabelLayout", CX_NS)
if parent is None:
return None
value = parent.attrib.get("val")
return value if value in {"banner", "none", "overlapping"} else None
def _series_name(series: ET.Element, index: int) -> str:
value = series.findtext("cx:tx/cx:txData/cx:v", default="", namespaces=CX_NS)
return value or f"Series {index}"
def _resolved_chart_colors(
chart_part: PartRef,
pkg: OoxmlPackage,
palette: ColorPalette | None,
) -> list[str]:
"""Resolve the base color cycle; any style failure normalizes silently."""
try:
targets = [
info.get("target")
for info in chart_part.rels.values()
if info.get("type") == CHART_COLOR_STYLE_REL_TYPE
and not info.get("external")
and info.get("target")
]
if len(targets) != 1:
return []
colors_part = pkg.load_part(str(targets[0]))
if colors_part is None:
return []
color_nodes = [
child
for child in colors_part.xml
if _namespace(child.tag) == NS["a"]
and _local_name(child.tag) in COLOR_TAGS
]
if not color_nodes:
return []
colors: list[str] = []
for color_node in color_nodes:
color, _alpha = resolve_color(color_node, palette)
if color is None:
return []
colors.append(color)
return colors
except (AttributeError, OverflowError, RuntimeError, TypeError, ValueError):
return []
def _one_child(parent: ET.Element, name: str, status: str) -> ET.Element:
children = _children(parent, name)
if len(children) != 1:
raise UnsupportedChartEx(status)
return children[0]
def _children(parent: ET.Element, name: str) -> list[ET.Element]:
return [
child
for child in parent
if child.tag == f"{{{CHARTEX_URI}}}{name}"
]
def _nonnegative_int(raw_value: str | None, status: str) -> int:
if raw_value is None or _INT_TOKEN_RE.fullmatch(raw_value) is None:
raise UnsupportedChartEx(status)
value = int(raw_value)
if value < 0:
raise UnsupportedChartEx(status)
return value
def _bounds_payload(xfrm: Xfrm) -> dict[str, int | float]:
return {
"height": _round_payload_number(xfrm.h),
"width": _round_payload_number(xfrm.w),
"x": _round_payload_number(xfrm.x),
"y": _round_payload_number(xfrm.y),
}
def _round_payload_number(value: float) -> int | float:
rounded = round(float(value), 3)
return int(rounded) if rounded.is_integer() else rounded
def _namespace(tag: str) -> str:
return tag[1:].split("}", 1)[0] if tag.startswith("{") else ""
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
@@ -11,182 +11,155 @@ Path command mapping:
to SVG endpoint parameterization)
<a:close/> -> Z
DrawingML <a:path w="..." h="..."> defines a local EMU coordinate system. We
remap path coordinates from path-local to slide-absolute pixels using the
shape's xfrm.
DrawingML <a:path w="..." h="..."> defines a shape-local coordinate system.
Each path resolves guide formulas against that local coordinate system, then
its coordinates are projected into the shape's absolute SVG frame.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from .emu_units import NS, Xfrm, emu_to_px, fmt_num
from pptx_shapes import FormulaEvaluationError, FormulaEvaluator
from .emu_units import NS, Xfrm
from .preset_registry_to_svg import render_evaluated_path
@dataclass(frozen=True)
class _EvaluatedCommand:
name: str
parameters: tuple[float, ...]
def convert_custom_geom(
cust_geom: ET.Element,
xfrm: Xfrm,
) -> str | None:
"""Return an SVG path d="..." string in slide-absolute coordinates, or None.
"""
"""Evaluate a complete custom geometry and return absolute SVG path data."""
path_lst = cust_geom.find("a:pathLst", NS)
if path_lst is None:
return None
paths = path_lst.findall("a:path", NS)
if not paths:
return None
d_segments: list[str] = []
for path_elem in paths:
d = _convert_one_path(path_elem, xfrm)
if d:
d_segments.append(d)
if not d_segments:
return None
return " ".join(d_segments)
shape_evaluator = _custom_geometry_evaluator(cust_geom, xfrm.w, xfrm.h)
paths = [
_convert_one_path(path_elem, cust_geom, xfrm, shape_evaluator)
for path_elem in path_lst.findall("a:path", NS)
]
rendered = [path for path in paths if path]
return " ".join(rendered) if rendered else None
def _convert_one_path(path_elem: ET.Element, xfrm: Xfrm) -> str:
"""Convert a single <a:path> to SVG path commands (slide-absolute coords)."""
try:
path_w_emu = int(path_elem.attrib.get("w", "0"))
path_h_emu = int(path_elem.attrib.get("h", "0"))
except ValueError:
return ""
path_w_px = emu_to_px(path_w_emu) if path_w_emu else xfrm.w
path_h_px = emu_to_px(path_h_emu) if path_h_emu else xfrm.h
if path_w_px <= 0 or path_h_px <= 0:
return ""
sx = xfrm.w / path_w_px if path_w_px else 1.0
sy = xfrm.h / path_h_px if path_h_px else 1.0
def map_pt(x_emu: float, y_emu: float) -> tuple[float, float]:
x = emu_to_px(x_emu) * sx + xfrm.x
y = emu_to_px(y_emu) * sy + xfrm.y
return x, y
# Track current point so a:arcTo (center-based) can compute its endpoint
cx, cy = 0.0, 0.0 # slide-absolute pixels
parts: list[str] = []
for child in list(path_elem):
if not isinstance(child.tag, str):
def _custom_geometry_evaluator(
cust_geom: ET.Element,
width: float,
height: float,
) -> FormulaEvaluator:
evaluator = FormulaEvaluator(width, height)
for list_name in ("avLst", "gdLst"):
guide_list = cust_geom.find(f"a:{list_name}", NS)
if guide_list is None:
continue
local = child.tag.split("}", 1)[-1]
if local == "moveTo":
pt = child.find("a:pt", NS)
if pt is None:
continue
x, y = _read_pt(pt, map_pt)
parts.append(f"M {fmt_num(x)} {fmt_num(y)}")
cx, cy = x, y
elif local == "lnTo":
pt = child.find("a:pt", NS)
if pt is None:
continue
x, y = _read_pt(pt, map_pt)
parts.append(f"L {fmt_num(x)} {fmt_num(y)}")
cx, cy = x, y
elif local == "cubicBezTo":
pts = child.findall("a:pt", NS)
if len(pts) < 3:
continue
p1 = _read_pt(pts[0], map_pt)
p2 = _read_pt(pts[1], map_pt)
p3 = _read_pt(pts[2], map_pt)
parts.append(
f"C {fmt_num(p1[0])} {fmt_num(p1[1])} "
f"{fmt_num(p2[0])} {fmt_num(p2[1])} "
f"{fmt_num(p3[0])} {fmt_num(p3[1])}"
)
cx, cy = p3
elif local == "quadBezTo":
pts = child.findall("a:pt", NS)
if len(pts) < 2:
continue
p1 = _read_pt(pts[0], map_pt)
p2 = _read_pt(pts[1], map_pt)
parts.append(
f"Q {fmt_num(p1[0])} {fmt_num(p1[1])} "
f"{fmt_num(p2[0])} {fmt_num(p2[1])}"
)
cx, cy = p2
elif local == "arcTo":
arc_d, end_x, end_y = _arc_to_svg(child, cx, cy, sx, sy)
if arc_d:
parts.append(arc_d)
cx, cy = end_x, end_y
elif local == "close":
parts.append("Z")
# SVG semantics: Z returns to subpath start; we don't track that
# explicitly here. cx/cy stays as-is — subsequent moveTo will reset.
return " ".join(parts)
for guide in guide_list.findall("a:gd", NS):
name = guide.attrib.get("name", "").strip()
formula = guide.attrib.get("fmla", "").strip()
if not name or not formula:
raise FormulaEvaluationError(
f"Custom geometry {list_name} contains an incomplete guide"
)
evaluator.bind(name, evaluator.evaluate(formula))
return evaluator
def _read_pt(pt_elem: ET.Element, mapper) -> tuple[float, float]:
try:
x = float(pt_elem.attrib.get("x", "0"))
y = float(pt_elem.attrib.get("y", "0"))
except ValueError:
x = 0.0
y = 0.0
return mapper(x, y)
def _arc_to_svg(
arc_elem: ET.Element,
cx: float, cy: float,
sx: float, sy: float,
) -> tuple[str, float, float]:
"""Convert <a:arcTo wR hR stAng swAng/> to an SVG A command.
DrawingML semantics: starting at the current point, draw an elliptical arc
where the ellipse has radii (wR, hR) in path-local EMU. stAng/swAng are
1/60000 degrees, with 0° = +x axis, increasing clockwise.
The center of the ellipse is at:
center.x = cur.x - wR * cos(stAng)
center.y = cur.y - hR * sin(stAng)
The end point is on the same ellipse at angle (stAng + swAng).
We emit a single SVG A command. SVG's sweep_flag = 1 means clockwise; the
DrawingML convention is also clockwise so we pass sweep_flag = 1 when
swAng > 0.
"""
try:
wR_emu = float(arc_elem.attrib.get("wR", "0"))
hR_emu = float(arc_elem.attrib.get("hR", "0"))
st_ang = float(arc_elem.attrib.get("stAng", "0"))
sw_ang = float(arc_elem.attrib.get("swAng", "0"))
except ValueError:
return "", cx, cy
if wR_emu <= 0 or hR_emu <= 0:
return "", cx, cy
rx = emu_to_px(wR_emu) * sx
ry = emu_to_px(hR_emu) * sy
st_rad = math.radians(st_ang / 60000.0)
sw_rad = math.radians(sw_ang / 60000.0)
end_rad = st_rad + sw_rad
# Center of the ellipse in slide-absolute coords
arc_cx = cx - rx * math.cos(st_rad)
arc_cy = cy - ry * math.sin(st_rad)
end_x = arc_cx + rx * math.cos(end_rad)
end_y = arc_cy + ry * math.sin(end_rad)
abs_sw = abs(sw_ang) / 60000.0
large_arc = 1 if abs_sw > 180.0 else 0
sweep = 1 if sw_ang >= 0 else 0
return (
f"A {fmt_num(rx)} {fmt_num(ry)} 0 {large_arc} {sweep} "
f"{fmt_num(end_x)} {fmt_num(end_y)}",
end_x,
end_y,
def _convert_one_path(
path_elem: ET.Element,
cust_geom: ET.Element,
xfrm: Xfrm,
shape_evaluator: FormulaEvaluator,
) -> str:
coordinate_width = _path_extent(
path_elem.get("w"),
xfrm.w,
shape_evaluator,
)
coordinate_height = _path_extent(
path_elem.get("h"),
xfrm.h,
shape_evaluator,
)
path_evaluator = _custom_geometry_evaluator(
cust_geom,
coordinate_width,
coordinate_height,
)
commands = tuple(
_evaluate_path_command(command, path_evaluator)
for command in path_elem
if isinstance(command.tag, str)
)
return render_evaluated_path(
commands,
x=xfrm.x,
y=xfrm.y,
width=xfrm.w,
height=xfrm.h,
coordinate_width=coordinate_width,
coordinate_height=coordinate_height,
)
def _evaluate_path_command(
element: ET.Element,
evaluator: FormulaEvaluator,
) -> _EvaluatedCommand:
name = element.tag.rsplit("}", 1)[-1]
if name == "arcTo":
values = tuple(
evaluator.evaluate_value(_required_attr(element, attr))
for attr in ("wR", "hR", "stAng", "swAng")
)
return _EvaluatedCommand(name=name, parameters=values)
expected_points = {
"moveTo": 1,
"lnTo": 1,
"quadBezTo": 2,
"cubicBezTo": 3,
"close": 0,
}.get(name)
if expected_points is None:
raise FormulaEvaluationError(
f"Unsupported custom geometry path command: {name!r}"
)
points = element.findall("a:pt", NS)
if len(points) != expected_points:
raise FormulaEvaluationError(
f"Custom path command {name!r} expects {expected_points} point(s), "
f"found {len(points)}"
)
values = tuple(
evaluator.evaluate_value(_required_attr(point, coordinate))
for point in points
for coordinate in ("x", "y")
)
return _EvaluatedCommand(name=name, parameters=values)
def _path_extent(
raw: str | None,
shape_extent: float,
evaluator: FormulaEvaluator,
) -> float:
if raw is None:
return shape_extent
value = evaluator.evaluate_value(raw)
return shape_extent if value == 0 else value
def _required_attr(element: ET.Element, name: str) -> str:
value = element.attrib.get(name)
if value is None or not value.strip():
raise FormulaEvaluationError(
f"Custom geometry element {element.tag.rsplit('}', 1)[-1]!r} "
f"requires attribute {name!r}"
)
return value.strip()
@@ -174,14 +174,16 @@ class Xfrm:
cy = self.y + self.h / 2.0
parts: list[str] = []
if self.rot:
parts.append(f"rotate({_fmt(self.rot)} {_fmt(cx)} {_fmt(cy)})")
parts.append(
f"rotate({_fmt(self.rot, 8)} {_fmt(cx, 8)} {_fmt(cy, 8)})"
)
if self.flip_h or self.flip_v:
sx = -1 if self.flip_h else 1
sy = -1 if self.flip_v else 1
# scale around shape center
parts.append(f"translate({_fmt(cx)} {_fmt(cy)})")
parts.append(f"translate({_fmt(cx, 8)} {_fmt(cy, 8)})")
parts.append(f"scale({sx} {sy})")
parts.append(f"translate({_fmt(-cx)} {_fmt(-cy)})")
parts.append(f"translate({_fmt(-cx, 8)} {_fmt(-cy, 8)})")
return " ".join(parts) if parts else None
@@ -130,6 +130,7 @@ REL_TYPES = {
"slideLayout": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"slideMaster": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
"theme": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
"tableStyles": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
"image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"media": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/media",
}
@@ -156,6 +157,8 @@ class OoxmlPackage:
self._layouts: dict[str, PartRef] = {}
self._masters: dict[str, PartRef] = {}
self._themes: dict[str, PartRef] = {}
self._table_styles: PartRef | None = None
self._table_styles_loaded = False
# ------------------- context manager -------------------
@@ -311,6 +314,32 @@ class OoxmlPackage:
return cached
return None
def resolve_table_styles(self) -> PartRef | None:
"""Return the presentation-level table style list, when usable.
Table style definitions are optional and some producers emit only the
built-in style id. A missing or malformed style part must therefore
not prevent otherwise valid slides from being converted.
"""
if self._table_styles_loaded:
return self._table_styles
self._table_styles_loaded = True
target: str | None = None
if self.presentation is not None:
for info in self.presentation.rels.values():
if info.get("type") == REL_TYPES["tableStyles"]:
target = info.get("target")
break
if target is None:
target = "ppt/tableStyles.xml"
try:
self._table_styles = self._load_part(target)
except RuntimeError:
self._table_styles = None
return self._table_styles
# ------------------- public iteration -------------------
def iter_slides(self) -> Iterator[SlideRef]:
@@ -0,0 +1,581 @@
#!/usr/bin/env python3
"""
PPT Master - Authored Preset Shape Contract
Build and validate canonical SVG fragments for newly authored PowerPoint
preset shapes.
Usage:
Import render_preset_shape_fragment or validate_authored_preset_group.
Examples:
fragment = render_preset_shape_fragment("rightArrow", (80, 120, 240, 96))
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import math
import re
from typing import Mapping
from xml.etree import ElementTree as ET
from pptx_shapes import (
CONNECTOR_PRESET_TYPES,
OOXML_COORDINATE_MAX,
OOXML_COORDINATE_MIN,
SUPPORTED_OPERATORS,
get_preset_registry,
resolve_preset_preview_hash,
svg_preset_preview_fingerprint,
validate_ooxml_line_width,
validate_ooxml_xfrm,
)
from .emu_units import EMU_PER_PX, Xfrm, fmt_num
from .preset_registry_to_svg import render_preset_geometry
from .preset_svg_markup import attrs_to_xml, serialize_preset_layers
AUTHORING_ATTR = "data-pptx-authoring"
AUTHORING_VALUE = "preset"
_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*")
_PAINT_RE = re.compile(r"(?:none|#[0-9A-Fa-f]{6})")
_INTEGER_RE = re.compile(r"[+-]?\d+")
_ADJUSTMENT_PREFIX = "data-pptx-av-"
_STYLE_ATTRS = (
"fill",
"fill-opacity",
"stroke",
"stroke-linecap",
"stroke-linejoin",
"stroke-opacity",
"stroke-width",
)
_SEMANTIC_ATTRS = (
AUTHORING_ATTR,
"data-pptx-object",
"data-pptx-prst",
"data-pptx-frame",
)
def render_preset_shape_fragment(
preset: str,
frame: tuple[float, float, float, float],
*,
adjustments: Mapping[str, str | int | float] | None = None,
object_kind: str = "shape",
element_id: str,
name: str | None = None,
style: Mapping[str, str] | None = None,
) -> str:
"""Render one complete authored preset fragment for manual SVG insertion."""
registry = get_preset_registry()
if preset not in registry:
raise ValueError(f"Unknown DrawingML preset shape: {preset!r}")
if _ID_RE.fullmatch(element_id) is None:
raise ValueError(f"Invalid SVG element id: {element_id!r}")
if object_kind not in {"shape", "connector"}:
raise ValueError("object_kind must be 'shape' or 'connector'")
if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector":
raise ValueError(
f"Connector preset {preset!r} requires object_kind='connector'"
)
if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES:
raise ValueError(
f"Authored connector requires a connector preset, got {preset!r}"
)
x, y, width, height = _validate_frame(frame, object_kind)
adjustment_values = _normalize_adjustments(adjustments or {})
_validate_adjustments(preset, adjustment_values)
registry.evaluate(
preset,
width,
height,
adjustments=adjustment_values,
)
rendered = render_preset_geometry(
preset,
Xfrm(x=x, y=y, w=width, h=height),
adjustment_values,
)
if not rendered.paths:
raise ValueError(f"Preset {preset!r} produced no visible SVG paths")
frame_text = " ".join(
fmt_num(value, 8) for value in (x, y, width, height)
)
semantic_attrs = {
AUTHORING_ATTR: AUTHORING_VALUE,
"data-pptx-object": object_kind,
"data-pptx-prst": preset,
"data-pptx-frame": frame_text,
}
if name:
semantic_attrs["data-pptx-shape-name"] = name
for guide_name, formula in adjustment_values.items():
semantic_attrs[f"{_ADJUSTMENT_PREFIX}{guide_name}"] = str(formula)
style_attrs = _validate_style(style or {})
if object_kind == "connector":
if style_attrs.get("fill", "none") != "none":
raise ValueError("Authored connector fill must be none")
if not _has_visible_stroke(style_attrs):
raise ValueError("Authored connector requires a visible stroke")
markup = serialize_preset_layers(
rendered.paths,
semantic_attrs,
style_attrs,
)
group_attrs = {
"id": element_id,
**semantic_attrs,
"data-pptx-preview-sha256": markup.preview_hash,
}
return (
f'<g{attrs_to_xml(group_attrs)}>\n'
f"{markup.markup}\n"
"</g>"
)
def validate_authored_preset_group(group: ET.Element) -> list[str]:
"""Return canonical authored-preset contract errors for one logical group."""
if group.get(AUTHORING_ATTR) != AUTHORING_VALUE:
return []
errors: list[str] = []
if _local_name(group.tag) != "g":
return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG <g>']
element_id = group.get("id")
if element_id is None:
errors.append("Authored preset logical group requires a stable id")
elif _ID_RE.fullmatch(element_id) is None:
errors.append(f"Authored preset logical group has invalid id {element_id!r}")
unexpected_group_attrs = sorted(
name for name in group.attrib
if _is_unexpected_group_attr(name)
)
if unexpected_group_attrs:
errors.append(
"Authored preset logical group has unsupported attributes: "
+ ", ".join(unexpected_group_attrs)
)
direct_children = list(group)
carriers = [
child
for child in direct_children
if child.get("data-pptx-part") == "geometry"
]
previews = [
child
for child in direct_children
if child.get("data-pptx-part") == "geometry-preview"
]
if len(carriers) != 1:
errors.append(
f"Authored preset requires exactly one direct geometry carrier; "
f"found {len(carriers)}"
)
if len(previews) != 1:
errors.append(
f"Authored preset requires exactly one direct geometry preview; "
f"found {len(previews)}"
)
allowed_children = set(carriers + previews)
foreign_children = [
child for child in direct_children
if child not in allowed_children
]
if foreign_children:
errors.append(
"Authored preset groups are atomic; place labels or decorations "
"in a parent group"
)
if len(carriers) != 1 or len(previews) != 1:
return errors
carrier = carriers[0]
preview = previews[0]
if _local_name(carrier.tag) != "path":
errors.append("Authored preset geometry carrier must be an SVG <path>")
if _local_name(preview.tag) != "g":
errors.append("Authored preset geometry preview must be an SVG <g>")
if carrier.get("visibility") != "hidden":
errors.append('Authored preset carrier requires visibility="hidden"')
if carrier.get("pointer-events") != "none":
errors.append('Authored preset carrier requires pointer-events="none"')
for attr_name in _SEMANTIC_ATTRS:
if group.get(attr_name) != carrier.get(attr_name):
errors.append(
f"Authored preset group/carrier {attr_name} values differ"
)
adjustment_names = {
name
for element in (group, carrier)
for name in element.attrib
if name.startswith(_ADJUSTMENT_PREFIX)
}
for attr_name in sorted(adjustment_names):
if group.get(attr_name) != carrier.get(attr_name):
errors.append(
f"Authored preset group/carrier {attr_name} values differ"
)
unexpected_carrier_attrs = [
name
for name in carrier.attrib
if _is_unexpected_carrier_attr(name)
]
if unexpected_carrier_attrs:
errors.append(
"Authored preset carrier has unsupported presentation attributes: "
+ ", ".join(sorted(unexpected_carrier_attrs))
)
preset = carrier.get("data-pptx-prst") or ""
object_kind = carrier.get("data-pptx-object") or ""
if object_kind not in {"shape", "connector"}:
errors.append(
"Authored preset data-pptx-object must be 'shape' or 'connector'"
)
if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector":
errors.append(
f"Connector preset {preset!r} requires data-pptx-object='connector'"
)
if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES:
errors.append(
f"Authored connector requires a connector preset, got {preset!r}"
)
try:
frame = _parse_frame(carrier.get("data-pptx-frame"), object_kind)
adjustments = {
name[len(_ADJUSTMENT_PREFIX):]: value
for name, value in carrier.attrib.items()
if name.startswith(_ADJUSTMENT_PREFIX)
}
_validate_adjustments(preset, adjustments)
rendered = render_preset_geometry(
preset,
Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]),
adjustments,
)
style_attrs = _validate_style({
name: carrier.attrib[name]
for name in _STYLE_ATTRS
if name in carrier.attrib
})
if object_kind == "connector":
if style_attrs.get("fill", "none") != "none":
raise ValueError("Authored connector fill must be none")
if not _has_visible_stroke(style_attrs):
raise ValueError("Authored connector requires a visible stroke")
expected = serialize_preset_layers(
rendered.paths,
{
name: value
for name, value in carrier.attrib.items()
if name in _SEMANTIC_ATTRS
or name.startswith(_ADJUSTMENT_PREFIX)
or name == "data-pptx-shape-name"
},
style_attrs,
)
except ValueError as exc:
errors.append(f"Cannot regenerate authored preset preview: {exc}")
return errors
if (carrier.get("d") or "").strip() != _carrier_path(rendered.paths):
errors.append("Authored preset carrier path differs from registry output")
actual_preview_hash = svg_preset_preview_fingerprint(group)
if actual_preview_hash != expected.preview_hash:
errors.append("Authored preset visible preview differs from registry output")
try:
stored_hash = resolve_preset_preview_hash(group)
except ValueError as exc:
errors.append(f"Invalid authored preset preview fingerprint: {exc}")
else:
if stored_hash != expected.preview_hash:
errors.append(
"Authored preset fingerprint does not match regenerated metadata"
)
return errors
def validate_authored_preset_tree(root: ET.Element) -> list[str]:
"""Return structural errors for every authored preset marker in one SVG."""
errors: list[str] = []
id_counts: dict[str, int] = {}
for element in root.iter():
element_id = element.get("id")
if element_id:
id_counts[element_id] = id_counts.get(element_id, 0) + 1
parents = {
child: parent
for parent in root.iter()
for child in parent
}
for element in root.iter():
authoring = element.get(AUTHORING_ATTR)
if authoring is None:
continue
tag = _local_name(element.tag)
label = _element_label(element)
if authoring != AUTHORING_VALUE:
errors.append(
f"{label}: unsupported {AUTHORING_ATTR} value {authoring!r}"
)
continue
if tag == "g":
errors.extend(
f"{label}: {error}"
for error in validate_authored_preset_group(element)
)
element_id = element.get("id")
if element_id and id_counts.get(element_id, 0) > 1:
errors.append(
f"{label}: authored preset logical group id must be "
"globally unique"
)
continue
if element.get("data-pptx-part") != "geometry":
errors.append(
f"{label}: authored preset metadata is allowed only on the "
"logical group and its direct geometry carrier"
)
continue
parent = parents.get(element)
if (
parent is None
or _local_name(parent.tag) != "g"
or parent.get(AUTHORING_ATTR) != AUTHORING_VALUE
):
errors.append(
f"{label}: authored preset geometry carrier must be a direct "
"child of its authored logical group"
)
return errors
def _validate_frame(
frame: tuple[float, float, float, float],
object_kind: str,
) -> tuple[float, float, float, float]:
if len(frame) != 4:
raise ValueError("frame must contain x, y, width, and height")
values = tuple(float(value) for value in frame)
if not all(math.isfinite(value) for value in values):
raise ValueError("frame values must be finite")
width, height = values[2], values[3]
if object_kind == "connector":
if width < 0 or height < 0 or (width == 0 and height == 0):
raise ValueError(
"connector frame dimensions must be non-negative and not both zero"
)
elif width <= 0 or height <= 0:
raise ValueError("shape frame width and height must be positive")
validate_ooxml_xfrm(
round(values[0] * EMU_PER_PX),
round(values[1] * EMU_PER_PX),
round(width * EMU_PER_PX),
round(height * EMU_PER_PX),
)
return values
def _parse_frame(
raw: str | None,
object_kind: str,
) -> tuple[float, float, float, float]:
if raw is None:
raise ValueError("authored preset requires data-pptx-frame")
parts = re.split(r"[\s,]+", raw.strip())
if len(parts) != 4:
raise ValueError("data-pptx-frame must contain four numbers")
return _validate_frame(tuple(float(part) for part in parts), object_kind)
def _validate_style(style: Mapping[str, str]) -> dict[str, str]:
unknown = sorted(set(style) - set(_STYLE_ATTRS))
if unknown:
raise ValueError(f"Unsupported authored preset style attributes: {unknown}")
normalized = {name: str(value).strip() for name, value in style.items()}
if not normalized:
raise ValueError("Authored preset requires explicit fill and/or stroke")
if normalized.get("fill", "none") == "none" and normalized.get(
"stroke", "none"
) == "none":
raise ValueError("Authored preset cannot have both fill and stroke set to none")
for name in ("fill", "stroke"):
value = normalized.get(name, "none")
if _PAINT_RE.fullmatch(value) is None:
raise ValueError(f"{name} must be none or a six-digit HEX color")
normalized[name] = value.upper() if value != "none" else value
if normalized.get("stroke", "none") == "none":
unused_stroke_attrs = sorted(
name for name in normalized
if name.startswith("stroke-")
)
if unused_stroke_attrs:
raise ValueError(
"Stroke presentation attributes require a visible stroke: "
+ ", ".join(unused_stroke_attrs)
)
if normalized.get("stroke-linecap") not in {None, "butt", "round", "square"}:
raise ValueError("stroke-linecap must be butt, round, or square")
if normalized.get("stroke-linejoin") not in {None, "miter", "round", "bevel"}:
raise ValueError("stroke-linejoin must be miter, round, or bevel")
for name in ("fill-opacity", "stroke-opacity"):
if name not in normalized:
continue
value = float(normalized[name])
if not math.isfinite(value) or value < 0 or value > 1:
raise ValueError(f"{name} must be between 0 and 1")
normalized[name] = fmt_num(value, 6)
if "stroke-width" in normalized:
width = float(normalized["stroke-width"])
if not math.isfinite(width) or width < 0:
raise ValueError("stroke-width must be finite and non-negative")
validate_ooxml_line_width(round(width * EMU_PER_PX))
normalized["stroke-width"] = fmt_num(width, 6)
if normalized.get("fill", "none") == "none" and "fill-opacity" in normalized:
raise ValueError("fill-opacity requires a visible fill paint")
if not _has_visible_fill(normalized) and not _has_visible_stroke(normalized):
raise ValueError(
"Authored preset requires at least one non-transparent visible paint"
)
return normalized
def _normalize_adjustments(
adjustments: Mapping[str, str | int | float],
) -> dict[str, str]:
normalized: dict[str, str] = {}
for name, value in adjustments.items():
if isinstance(value, bool):
raise ValueError(f"Adjustment {name!r} must not be boolean")
if isinstance(value, int):
formula = f"val {value}"
elif isinstance(value, float):
if not math.isfinite(value) or not value.is_integer():
raise ValueError(
f"Numeric adjustment {name!r} must be a finite integer"
)
formula = f"val {int(value)}"
else:
formula = str(value).strip()
if len(formula.split()) == 1:
formula = f"val {formula}"
normalized[str(name)] = formula
return normalized
def _validate_adjustments(
preset: str,
adjustments: Mapping[str, str | int | float],
) -> None:
registry = get_preset_registry()
if preset not in registry:
raise ValueError(f"Unknown DrawingML preset shape: {preset!r}")
for name, formula in adjustments.items():
if not isinstance(formula, str) or not formula.strip():
raise ValueError(f"Adjustment {name!r} requires a formula")
parts = formula.split()
if parts[0] not in SUPPORTED_OPERATORS:
raise ValueError(
f"Adjustment {name!r} must use a DrawingML formula operator"
)
if parts[0] == "val" and len(parts) == 2:
try:
float(parts[1])
except ValueError:
pass
else:
if _INTEGER_RE.fullmatch(parts[1]) is None:
raise ValueError(
f"Adjustment {name!r} val operand must be an integer "
"coordinate"
)
if not adjustments:
return
evaluated = registry.evaluate(
preset,
100000,
100000,
adjustments=adjustments,
)
for name, value in evaluated.adjustments.items():
if name not in adjustments:
continue
if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
raise ValueError(
f"Adjustment {name!r} evaluates outside OOXML coordinate range"
)
def _has_visible_fill(style: Mapping[str, str]) -> bool:
return (
style.get("fill", "none") != "none"
and float(style.get("fill-opacity", "1")) > 0
)
def _has_visible_stroke(style: Mapping[str, str]) -> bool:
return (
style.get("stroke", "none") != "none"
and float(style.get("stroke-opacity", "1")) > 0
and float(style.get("stroke-width", "1")) > 0
)
def _is_unexpected_carrier_attr(name: str) -> bool:
if name in {
"d",
"data-pptx-preview-sha256",
"data-pptx-part",
"data-pptx-shape-name",
"visibility",
"pointer-events",
*_SEMANTIC_ATTRS,
*_STYLE_ATTRS,
}:
return False
return not name.startswith(_ADJUSTMENT_PREFIX)
def _is_unexpected_group_attr(name: str) -> bool:
if name in {
"id",
"transform",
"data-pptx-preview-sha256",
"data-pptx-shape-name",
*_SEMANTIC_ATTRS,
}:
return False
if name.startswith(_ADJUSTMENT_PREFIX):
return False
if name.startswith("data-pptx-runtime-") or name.startswith("aria-"):
return False
return name not in {"role", "tabindex"}
def _carrier_path(paths) -> str:
return " ".join(path.d for path in paths).strip()
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _element_label(element: ET.Element) -> str:
tag = _local_name(element.tag)
element_id = element.get("id")
if element_id:
return f'<{tag} id="{element_id}">'
return f"<{tag}>"
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Geometry SVG Adapter
Render evaluated DrawingML preset geometry as absolute SVG path layers.
Usage:
Import render_preset_geometry from pptx_to_svg.preset_registry_to_svg.
Examples:
geometry = render_preset_geometry("rightArrow", xfrm)
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Mapping
from pptx_shapes import get_preset_registry
from .emu_units import Xfrm, fmt_num
@dataclass(frozen=True)
class SvgPresetPath:
"""One visible layer from a DrawingML preset's ``a:pathLst``."""
d: str
fill: str
stroke: bool
@dataclass(frozen=True)
class SvgPresetGeometry:
"""A fully evaluated preset preview in slide-absolute SVG coordinates."""
paths: tuple[SvgPresetPath, ...]
def render_preset_geometry(
preset: str,
xfrm: Xfrm,
adjustments: Mapping[str, str | int | float] | None = None,
) -> SvgPresetGeometry:
"""Evaluate ``preset`` and project every DrawingML path into SVG space."""
evaluated = get_preset_registry().evaluate(
preset,
xfrm.w,
xfrm.h,
adjustments=adjustments,
)
layers = tuple(
SvgPresetPath(
d=render_evaluated_path(
path.commands,
x=xfrm.x,
y=xfrm.y,
width=xfrm.w,
height=xfrm.h,
coordinate_width=path.coordinate_width,
coordinate_height=path.coordinate_height,
),
fill=path.fill,
stroke=path.stroke,
)
for path in evaluated.paths
)
return SvgPresetGeometry(paths=tuple(layer for layer in layers if layer.d))
def render_evaluated_path(
commands,
*,
x: float,
y: float,
width: float,
height: float,
coordinate_width: float,
coordinate_height: float,
) -> str:
scale_x = width / coordinate_width if coordinate_width else 1.0
scale_y = height / coordinate_height if coordinate_height else 1.0
def point(px: float, py: float) -> tuple[float, float]:
return x + px * scale_x, y + py * scale_y
parts: list[str] = []
current = (x, y)
subpath_start = current
for command in commands:
values = command.parameters
if command.name == "moveTo":
current = point(values[0], values[1])
subpath_start = current
parts.append(f"M {fmt_num(current[0])} {fmt_num(current[1])}")
elif command.name == "lnTo":
current = point(values[0], values[1])
parts.append(f"L {fmt_num(current[0])} {fmt_num(current[1])}")
elif command.name == "quadBezTo":
control = point(values[0], values[1])
current = point(values[2], values[3])
parts.append(
"Q "
f"{fmt_num(control[0])} {fmt_num(control[1])} "
f"{fmt_num(current[0])} {fmt_num(current[1])}"
)
elif command.name == "cubicBezTo":
control_1 = point(values[0], values[1])
control_2 = point(values[2], values[3])
current = point(values[4], values[5])
parts.append(
"C "
f"{fmt_num(control_1[0])} {fmt_num(control_1[1])} "
f"{fmt_num(control_2[0])} {fmt_num(control_2[1])} "
f"{fmt_num(current[0])} {fmt_num(current[1])}"
)
elif command.name == "arcTo":
arc_parts, current = _render_arc(
current,
radius_x=values[0],
radius_y=values[1],
scale_x=scale_x,
scale_y=scale_y,
start_angle=values[2],
sweep_angle=values[3],
)
parts.extend(arc_parts)
elif command.name == "close":
parts.append("Z")
current = subpath_start
return " ".join(parts)
def _render_arc(
current: tuple[float, float],
*,
radius_x: float,
radius_y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
start_angle: float,
sweep_angle: float,
) -> tuple[list[str], tuple[float, float]]:
"""Render one DrawingML arc, splitting full circles for SVG validity.
DrawingML resolves the polar angle in the path-local ellipse before the
path coordinate system is scaled into the shape frame. Applying the
angle correction to already-scaled radii bends explicit-extent paths such
as ``cloud`` when the containing shape has a non-square aspect ratio.
"""
radius_x = abs(radius_x)
radius_y = abs(radius_y)
scaled_radius_x = abs(radius_x * scale_x)
scaled_radius_y = abs(radius_y * scale_y)
if (
radius_x <= 1e-12
or radius_y <= 1e-12
or scaled_radius_x <= 1e-12
or scaled_radius_y <= 1e-12
or abs(sweep_angle) <= 1e-12
):
return [], current
start_radians = _ellipse_parameter_angle(
start_angle,
radius_x,
radius_y,
)
center_x = current[0] - scaled_radius_x * math.cos(start_radians)
center_y = current[1] - scaled_radius_y * math.sin(start_radians)
# SVG cannot represent a 360-degree arc with one A command because its
# start and end points coincide. Chunks of at most 180 degrees also keep
# the large-arc flag deterministic for every preset definition.
remaining = sweep_angle
angle = start_angle
parts: list[str] = []
endpoint = current
half_circle = 180.0 * 60000.0
while abs(remaining) > 1e-9:
step = math.copysign(min(abs(remaining), half_circle), remaining)
angle += step
end_radians = _ellipse_parameter_angle(angle, radius_x, radius_y)
endpoint = (
center_x + scaled_radius_x * math.cos(end_radians),
center_y + scaled_radius_y * math.sin(end_radians),
)
large_arc = 1 if abs(step) > half_circle else 0
sweep = 1 if step >= 0 else 0
parts.append(
"A "
f"{fmt_num(scaled_radius_x)} {fmt_num(scaled_radius_y)} "
f"0 {large_arc} {sweep} "
f"{fmt_num(endpoint[0])} {fmt_num(endpoint[1])}"
)
remaining -= step
return parts, endpoint
def _ellipse_parameter_angle(
ooxml_angle: float,
radius_x: float,
radius_y: float,
) -> float:
"""Unskew an OOXML polar angle into an ellipse parameter angle."""
radians = math.radians(ooxml_angle / 60000.0)
return math.atan2(
radius_x * math.sin(radians),
radius_y * math.cos(radians),
)
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Shape SVG Markup
Serialize evaluated DrawingML preset layers into one native carrier and one
browser-visible SVG preview.
Usage:
Import serialize_preset_layers from pptx_to_svg.preset_svg_markup.
Examples:
markup = serialize_preset_layers(layers, semantic_attrs, style_attrs)
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
from xml.etree import ElementTree as ET
from pptx_shapes import svg_preset_preview_fingerprint
from .preset_registry_to_svg import SvgPresetPath
@dataclass(frozen=True)
class PresetSvgMarkup:
"""Canonical hidden-carrier and visible-preview markup for one preset."""
carrier: str
preview: str
preview_hash: str
@property
def markup(self) -> str:
"""Return the carrier and preview in canonical document order."""
return f"{self.carrier}\n{self.preview}"
def serialize_preset_layers(
layers: Sequence[SvgPresetPath],
semantic_attrs: Mapping[str, str],
style_attrs: Mapping[str, str],
) -> PresetSvgMarkup:
"""Serialize one preset without duplicating its native PowerPoint object."""
detail_style_attrs = dict(style_attrs)
preview_group_attrs = {"data-pptx-part": "geometry-preview"}
for name in ("filter", "opacity"):
value = detail_style_attrs.pop(name, None)
if value is not None:
preview_group_attrs[name] = value
detail_layers: list[str] = []
for layer in layers:
attrs = dict(detail_style_attrs)
apply_preset_path_fill(attrs, layer.fill)
if not layer.stroke:
remove_stroke_attrs(attrs)
attrs["stroke"] = "none"
attrs["data-pptx-part"] = "geometry-detail"
detail_layers.append(
f'<path d="{_xml_escape(layer.d)}"{attrs_to_xml(attrs)}/>'
)
preview = (
f'<g{attrs_to_xml(preview_group_attrs)}>\n'
+ "\n".join(detail_layers)
+ "\n</g>"
)
preview_root = ET.fromstring(
f'<svg xmlns="http://www.w3.org/2000/svg">{preview}</svg>'
)
preview_hash = svg_preset_preview_fingerprint(preview_root)
carrier_attrs = {
**style_attrs,
**semantic_attrs,
"data-pptx-preview-sha256": preview_hash,
"data-pptx-part": "geometry",
"visibility": "hidden",
"pointer-events": "none",
}
combined_path = " ".join(layer.d for layer in layers)
carrier = (
f'<path d="{_xml_escape(combined_path)}"'
f'{attrs_to_xml(carrier_attrs)}/>'
)
return PresetSvgMarkup(
carrier=carrier,
preview=preview,
preview_hash=preview_hash,
)
def apply_preset_path_fill(attrs: dict[str, str], mode: str) -> None:
"""Apply one DrawingML path fill mode to SVG presentation attributes."""
if mode == "none":
attrs["fill"] = "none"
attrs.pop("fill-opacity", None)
return
if mode == "norm":
return
color = attrs.get("fill", "")
if not color.startswith("#") or len(color) != 7:
return
try:
channels = tuple(
int(color[offset:offset + 2], 16)
for offset in (1, 3, 5)
)
except ValueError:
return
if mode in {"darken", "darkenLess"}:
factor = 0.65 if mode == "darken" else 0.82
adjusted = tuple(round(channel * factor) for channel in channels)
elif mode in {"lighten", "lightenLess"}:
amount = 0.4 if mode == "lighten" else 0.2
adjusted = tuple(
round(channel + (255 - channel) * amount)
for channel in channels
)
else:
return
attrs["fill"] = "#" + "".join(
f"{channel:02X}" for channel in adjusted
)
def remove_stroke_attrs(attrs: dict[str, str]) -> None:
"""Remove inherited stroke and marker attributes from one path layer."""
for name in tuple(attrs):
if name.startswith("stroke") or name in {"marker-start", "marker-end"}:
attrs.pop(name, None)
def attrs_to_xml(attrs: Mapping[str, str]) -> str:
"""Serialize SVG attributes in deterministic insertion order."""
return "".join(
f' {name}="{_xml_escape(value)}"'
for name, value in attrs.items()
)
def _xml_escape(value: str) -> str:
text = str(value)
if any(not _is_xml_10_character(character) for character in text):
raise ValueError("SVG markup contains an XML 1.0-incompatible character")
return (
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _is_xml_10_character(character: str) -> bool:
codepoint = ord(character)
return (
codepoint in {0x09, 0x0A, 0x0D}
or 0x20 <= codepoint <= 0xD7FF
or 0xE000 <= codepoint <= 0xFFFD
or 0x10000 <= codepoint <= 0x10FFFF
)
@@ -1,23 +1,27 @@
"""DrawingML <a:prstGeom> -> SVG geometry conversion.
svg_to_pptx only emits 4 prstGeom presets (rect / roundRect / ellipse / line);
everything else goes through custGeom. So the reverse pipeline only needs
strong support for those four to handle round-tripped decks. We additionally
include an extended preset map covering the most common PowerPoint-authored
shapes (triangle, diamond, hexagon, parallelogram, arrow, star, etc.) so
hand-built decks like muban.pptx don't fall through to a placeholder.
The visible SVG geometry is accompanied by the source preset name and every
explicit ``a:avLst`` guide formula. This metadata is rendering-neutral and
lets the reverse converter distinguish a native PowerPoint shape from an
arbitrary SVG path without guessing from its appearance.
Each handler returns a SHAPE_TAG + attribute dict that the slide assembler
wraps with fill/stroke/effect attributes plus the absolute (x, y) translation.
The standard preset catalog is evaluated by the shared data-driven geometry
engine. Presets outside that locked catalog retain their source semantics on
an explicitly marked bounding-box fallback; they never masquerade as ``rect``.
The evaluated result carries one or more painted SVG path layers plus the
rendering-neutral metadata used by the reverse converter.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from pptx_shapes import get_preset_registry
from .emu_units import NS, Xfrm, fmt_num
from .preset_registry_to_svg import SvgPresetPath, render_preset_geometry
# ---------------------------------------------------------------------------
@@ -40,52 +44,24 @@ class GeomResult:
path_d: str | None = None
# When tag == 'polygon' / 'polyline' use points for the points attribute.
points: str | None = None
# Standard presets can contain multiple independently painted path layers.
layers: tuple[SvgPresetPath, ...] = ()
# ---------------------------------------------------------------------------
# Adjustment helper
# Explicit fallback for presets outside the locked standard catalog
# ---------------------------------------------------------------------------
def _adj_value(sp_pr: ET.Element | None, adj_name: str = "adj",
default_pct: float = 0.0) -> float:
"""Read an adjustment value from <a:avLst><a:gd name="..." fmla="val N"/>.
Returns the value as a fraction in [0, 1] of the relevant dimension. If
the gd is absent or the formula is unparseable, returns default_pct.
"""
if sp_pr is None:
return default_pct
av_lst = sp_pr.find(".//a:avLst", NS)
if av_lst is None:
return default_pct
for gd in av_lst.findall("a:gd", NS):
if gd.attrib.get("name") == adj_name:
fmla = gd.attrib.get("fmla", "")
if fmla.startswith("val "):
try:
return float(fmla[4:]) / 100000.0
except ValueError:
return default_pct
return default_pct
def _adj_int_value(sp_pr: ET.Element | None, adj_name: str,
default: int) -> int:
"""Read an adjustment value as the original DrawingML integer."""
if sp_pr is None:
return default
av_lst = sp_pr.find(".//a:avLst", NS)
if av_lst is None:
return default
for gd in av_lst.findall("a:gd", NS):
if gd.attrib.get("name") == adj_name:
fmla = gd.attrib.get("fmla", "")
if fmla.startswith("val "):
try:
return int(float(fmla[4:]))
except ValueError:
return default
return default
def _rect(xfrm: Xfrm) -> GeomResult:
return GeomResult(
tag="rect",
attrs={
"x": fmt_num(xfrm.x),
"y": fmt_num(xfrm.y),
"width": fmt_num(xfrm.w),
"height": fmt_num(xfrm.h),
},
)
# ---------------------------------------------------------------------------
@@ -99,572 +75,88 @@ def convert_prst_geom(
) -> GeomResult | None:
"""Convert <a:prstGeom prst="..."> to a GeomResult.
Returns None if the preset has no v1 mapping; the caller can then choose
to render a fallback rect.
Every emitted result carries ``data-pptx-prst`` plus one
``data-pptx-av-<name>`` attribute per explicit adjustment guide. Unknown
presets use a visibly neutral bounding-box fallback with diagnostic
metadata instead of silently changing their semantic type to ``rect``.
Returns ``None`` only when the logical frame cannot produce geometry.
"""
# Line-style presets accept zero width OR zero height (axis-aligned lines).
if prst in ("line", "straightConnector1"):
if xfrm.w == 0 and xfrm.h == 0:
return None
return _line(xfrm, sp_pr)
if xfrm.w <= 0 or xfrm.h <= 0:
metadata = _preset_metadata(prst, sp_pr)
registry = get_preset_registry()
if prst not in registry:
result = _rect(xfrm)
result.attrs.update(metadata)
result.attrs.update({
"data-pptx-geometry-status": "unsupported",
"data-pptx-geometry-reason": f"unsupported-preset:{prst}",
})
return result
if xfrm.w < 0 or xfrm.h < 0 or (xfrm.w == 0 and xfrm.h == 0):
return None
handler = _PRESET_HANDLERS.get(prst)
if handler is None:
try:
rendered = render_preset_geometry(
prst,
xfrm,
_preset_adjustments(sp_pr),
)
except ValueError as exc:
result = _rect(xfrm)
result.attrs.update(metadata)
result.attrs.update({
"data-pptx-geometry-status": "unsupported",
"data-pptx-geometry-reason": (
f"preset-evaluation-error:{type(exc).__name__}"
),
})
return result
if not rendered.paths:
return None
return handler(xfrm, sp_pr)
# ---------------------------------------------------------------------------
# Per-preset handlers
# ---------------------------------------------------------------------------
def _rect(xfrm: Xfrm, _sp_pr) -> GeomResult:
return GeomResult(
tag="rect",
attrs={
"x": fmt_num(xfrm.x),
"y": fmt_num(xfrm.y),
"width": fmt_num(xfrm.w),
"height": fmt_num(xfrm.h),
},
)
def _round_rect(xfrm: Xfrm, sp_pr) -> GeomResult:
"""roundRect adj = ratio of corner radius to half of shorter side.
DrawingML default adj = 16667 (16.667%) when avLst is absent.
"""
adj = _adj_value(sp_pr, "adj", default_pct=0.16667)
short = min(xfrm.w, xfrm.h)
radius = adj * short / 2.0 # adj is fraction of "half shorter side"
# Actually DrawingML "adj" for roundRect is fraction of the shorter side
# itself (i.e. up to 50000 = half side = capsule). svg_to_pptx writes
# adj = radius / shorterSide * 100000, so we invert: radius = adj * shorter.
radius = adj * short
radius = min(radius, short / 2.0)
return GeomResult(
tag="rect",
attrs={
"x": fmt_num(xfrm.x),
"y": fmt_num(xfrm.y),
"width": fmt_num(xfrm.w),
"height": fmt_num(xfrm.h),
"rx": fmt_num(radius),
"ry": fmt_num(radius),
},
)
def _round_2_diag_rect(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Rectangle with two diagonal rounded corners.
DrawingML's round2DiagRect rounds the top-left and bottom-right corners.
SVG has no direct primitive for per-corner radii, so emit a native-friendly
path with quadratic curves.
"""
adj = _adj_value(sp_pr, "adj", default_pct=0.16667)
r = min(adj * min(xfrm.w, xfrm.h), min(xfrm.w, xfrm.h) / 2.0)
x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h
d = (
f"M {fmt_num(x + r)} {fmt_num(y)} "
f"L {fmt_num(x + w)} {fmt_num(y)} "
f"L {fmt_num(x + w)} {fmt_num(y + h - r)} "
f"Q {fmt_num(x + w)} {fmt_num(y + h)} {fmt_num(x + w - r)} {fmt_num(y + h)} "
f"L {fmt_num(x)} {fmt_num(y + h)} "
f"L {fmt_num(x)} {fmt_num(y + r)} "
f"Q {fmt_num(x)} {fmt_num(y)} {fmt_num(x + r)} {fmt_num(y)} Z"
)
return GeomResult(tag="path", path_d=d)
def _round_2_same_rect(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Rectangle with top and bottom same-side corner adjustments.
Mirrors the OOXML preset definition: adj1 controls the top pair of corners,
adj2 controls the bottom pair. A common title-tab setting is adj1=0 and
adj2=50000, yielding square top corners and a capsule-like bottom edge.
"""
x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h
ss = min(w, h)
adj1 = _adj_int_value(sp_pr, "adj1", 16667)
adj2 = _adj_int_value(sp_pr, "adj2", 0)
tx = min(adj1 / 100000.0, 0.5) * ss
bx = min(adj2 / 100000.0, 0.5) * ss
parts = [
f"M {fmt_num(x + tx)} {fmt_num(y)}",
f"L {fmt_num(x + w - tx)} {fmt_num(y)}",
]
if tx > 0:
parts.append(
f"A {fmt_num(tx)} {fmt_num(tx)} 0 0 1 "
f"{fmt_num(x + w)} {fmt_num(y + tx)}"
)
parts.append(f"L {fmt_num(x + w)} {fmt_num(y + h - bx)}")
if bx > 0:
parts.append(
f"A {fmt_num(bx)} {fmt_num(bx)} 0 0 1 "
f"{fmt_num(x + w - bx)} {fmt_num(y + h)}"
)
else:
parts.append(f"L {fmt_num(x + w)} {fmt_num(y + h)}")
parts.append(f"L {fmt_num(x + bx)} {fmt_num(y + h)}")
if bx > 0:
parts.append(
f"A {fmt_num(bx)} {fmt_num(bx)} 0 0 1 "
f"{fmt_num(x)} {fmt_num(y + h - bx)}"
)
else:
parts.append(f"L {fmt_num(x)} {fmt_num(y + h)}")
parts.append(f"L {fmt_num(x)} {fmt_num(y + tx)}")
if tx > 0:
parts.append(
f"A {fmt_num(tx)} {fmt_num(tx)} 0 0 1 "
f"{fmt_num(x + tx)} {fmt_num(y)}"
)
d = " ".join(parts) + " Z"
return GeomResult(
tag="path",
attrs={
"data-pptx-prst": "round2SameRect",
"data-pptx-adj1": str(adj1),
"data-pptx-adj2": str(adj2),
},
path_d=d,
attrs=metadata,
path_d=" ".join(path.d for path in rendered.paths),
layers=rendered.paths,
)
def _ellipse(xfrm: Xfrm, _sp_pr) -> GeomResult:
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
rx = xfrm.w / 2.0
ry = xfrm.h / 2.0
return GeomResult(
tag="ellipse",
attrs={
"cx": fmt_num(cx), "cy": fmt_num(cy),
"rx": fmt_num(rx), "ry": fmt_num(ry),
},
)
def _line(xfrm: Xfrm, _sp_pr) -> GeomResult:
# cxnSp line endpoints: x1,y1 = (x, y); x2,y2 = (x+w, y+h). flipH/flipV
# already baked into the xfrm via to_svg_transform.
return GeomResult(
tag="line",
attrs={
"x1": fmt_num(xfrm.x), "y1": fmt_num(xfrm.y),
"x2": fmt_num(xfrm.x + xfrm.w), "y2": fmt_num(xfrm.y + xfrm.h),
},
)
# ---------- Polygon-based shapes ----------
def _polygon(points: list[tuple[float, float]]) -> GeomResult:
pts = " ".join(f"{fmt_num(x)},{fmt_num(y)}" for x, y in points)
return GeomResult(tag="polygon", points=pts)
def _triangle(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Isoceles triangle. adj controls apex x position (default 50%)."""
adj = _adj_value(sp_pr, "adj", default_pct=0.5)
apex_x = xfrm.x + adj * xfrm.w
return _polygon([
(apex_x, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y + xfrm.h),
(xfrm.x, xfrm.y + xfrm.h),
])
def _rt_triangle(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _polygon([
(xfrm.x, xfrm.y),
(xfrm.x, xfrm.y + xfrm.h),
(xfrm.x + xfrm.w, xfrm.y + xfrm.h),
])
def _diamond(xfrm: Xfrm, _sp_pr) -> GeomResult:
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
return _polygon([
(cx, xfrm.y),
(xfrm.x + xfrm.w, cy),
(cx, xfrm.y + xfrm.h),
(xfrm.x, cy),
])
def _parallelogram(xfrm: Xfrm, sp_pr) -> GeomResult:
"""adj = horizontal skew offset as fraction of width (default 25%)."""
adj = _adj_value(sp_pr, "adj", default_pct=0.25)
skew = adj * xfrm.w
return _polygon([
(xfrm.x + skew, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y),
(xfrm.x + xfrm.w - skew, xfrm.y + xfrm.h),
(xfrm.x, xfrm.y + xfrm.h),
])
def _trapezoid(xfrm: Xfrm, sp_pr) -> GeomResult:
"""OOXML trapezoid: x1 = ss * adj / 200000 (ss = min(w, h)).
adj default 25000; maxAdj caps at 50000 * w / ss so the top can't invert.
"""
adj = _adj_int_value(sp_pr, "adj", 25000)
ss = min(xfrm.w, xfrm.h)
if ss <= 0:
return _rect(xfrm, sp_pr)
max_adj = 50000.0 * xfrm.w / ss
a = max(0.0, min(float(adj), max_adj))
inset = ss * a / 200000.0
return _polygon([
(xfrm.x + inset, xfrm.y),
(xfrm.x + xfrm.w - inset, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y + xfrm.h),
(xfrm.x, xfrm.y + xfrm.h),
])
def _chevron(xfrm: Xfrm, sp_pr) -> GeomResult:
"""OOXML chevron: dx = ss * adj / 100000 (ss = min(w, h)), default adj=50000.
Both the right-pointing tip length and the left back-notch depth equal dx,
which is what lets a series of chevrons tile flush.
"""
adj = _adj_int_value(sp_pr, "adj", 50000)
x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h
ss = min(w, h)
if ss <= 0:
return _rect(xfrm, sp_pr)
max_adj = 100000.0 * w / ss
a = max(0.0, min(float(adj), max_adj))
dx = ss * a / 100000.0
cy = y + h / 2.0
return _polygon([
(x, y),
(x + w - dx, y),
(x + w, cy),
(x + w - dx, y + h),
(x, y + h),
(x + dx, cy),
])
def _home_plate(xfrm: Xfrm, sp_pr) -> GeomResult:
"""OOXML homePlate: tip length = ss * adj / 100000; body fills 0..(w - tip).
Same dx as chevron so a homePlatechevron series tiles seamlessly.
"""
adj = _adj_int_value(sp_pr, "adj", 50000)
x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h
ss = min(w, h)
if ss <= 0:
return _rect(xfrm, sp_pr)
max_adj = 100000.0 * w / ss
a = max(0.0, min(float(adj), max_adj))
dx = ss * a / 100000.0
split = w - dx
cy = y + h / 2.0
return _polygon([
(x, y),
(x + split, y),
(x + w, cy),
(x + split, y + h),
(x, y + h),
])
def _flow_chart_extract(xfrm: Xfrm, _sp_pr) -> GeomResult:
"""Flowchart "Extract": upward-pointing isoceles triangle."""
return _polygon([
(xfrm.x + xfrm.w / 2.0, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y + xfrm.h),
(xfrm.x, xfrm.y + xfrm.h),
])
def _flow_chart_merge(xfrm: Xfrm, _sp_pr) -> GeomResult:
"""Flowchart "Merge": downward-pointing isoceles triangle."""
return _polygon([
(xfrm.x, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y),
(xfrm.x + xfrm.w / 2.0, xfrm.y + xfrm.h),
])
def _regular_polygon(xfrm: Xfrm, n_sides: int, *, rot_deg: float = -90.0) -> GeomResult:
"""Regular polygon inscribed in the bounding box."""
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
rx = xfrm.w / 2.0
ry = xfrm.h / 2.0
pts: list[tuple[float, float]] = []
for i in range(n_sides):
ang = math.radians(rot_deg + i * 360.0 / n_sides)
pts.append((cx + rx * math.cos(ang), cy + ry * math.sin(ang)))
return _polygon(pts)
def _pentagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 5)
def _hexagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 6, rot_deg=0)
def _heptagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 7)
def _octagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 8, rot_deg=22.5)
def _decagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 10)
def _dodecagon(xfrm: Xfrm, _sp_pr) -> GeomResult:
return _regular_polygon(xfrm, 12)
def _star(n_points: int):
def handler(xfrm: Xfrm, _sp_pr) -> GeomResult:
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
r_outer_x = xfrm.w / 2.0
r_outer_y = xfrm.h / 2.0
# Inner radius: classic 5-pointed star uses ~0.382 of outer.
inner_ratio = 0.382 if n_points == 5 else 0.5
r_inner_x = r_outer_x * inner_ratio
r_inner_y = r_outer_y * inner_ratio
pts: list[tuple[float, float]] = []
for i in range(n_points * 2):
angle = math.radians(-90 + i * (360.0 / (n_points * 2)))
rx = r_outer_x if i % 2 == 0 else r_inner_x
ry = r_outer_y if i % 2 == 0 else r_inner_y
pts.append((cx + rx * math.cos(angle), cy + ry * math.sin(angle)))
return _polygon(pts)
return handler
# ---------- Arrow shapes ----------
def _right_arrow(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Right-pointing block arrow. adj1 = head width / shape height (50%);
adj2 = head length / shape width (50%)."""
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5)
adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5)
head_h = xfrm.h * adj1
head_w = xfrm.w * adj2
body_y_top = xfrm.y + (xfrm.h - head_h) / 2.0
body_y_bot = xfrm.y + (xfrm.h + head_h) / 2.0
head_x = xfrm.x + xfrm.w - head_w
return _polygon([
(xfrm.x, body_y_top),
(head_x, body_y_top),
(head_x, xfrm.y),
(xfrm.x + xfrm.w, xfrm.y + xfrm.h / 2.0),
(head_x, xfrm.y + xfrm.h),
(head_x, body_y_bot),
(xfrm.x, body_y_bot),
])
def _left_arrow(xfrm: Xfrm, sp_pr) -> GeomResult:
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5)
adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5)
head_h = xfrm.h * adj1
head_w = xfrm.w * adj2
body_y_top = xfrm.y + (xfrm.h - head_h) / 2.0
body_y_bot = xfrm.y + (xfrm.h + head_h) / 2.0
head_x = xfrm.x + head_w
return _polygon([
(xfrm.x + xfrm.w, body_y_top),
(head_x, body_y_top),
(head_x, xfrm.y),
(xfrm.x, xfrm.y + xfrm.h / 2.0),
(head_x, xfrm.y + xfrm.h),
(head_x, body_y_bot),
(xfrm.x + xfrm.w, body_y_bot),
])
def _down_arrow(xfrm: Xfrm, sp_pr) -> GeomResult:
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5)
adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5)
head_w = xfrm.w * adj1
head_h = xfrm.h * adj2
body_x_l = xfrm.x + (xfrm.w - head_w) / 2.0
body_x_r = xfrm.x + (xfrm.w + head_w) / 2.0
head_y = xfrm.y + xfrm.h - head_h
return _polygon([
(body_x_l, xfrm.y),
(body_x_l, head_y),
(xfrm.x, head_y),
(xfrm.x + xfrm.w / 2.0, xfrm.y + xfrm.h),
(xfrm.x + xfrm.w, head_y),
(body_x_r, head_y),
(body_x_r, xfrm.y),
])
def _up_arrow(xfrm: Xfrm, sp_pr) -> GeomResult:
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5)
adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5)
head_w = xfrm.w * adj1
head_h = xfrm.h * adj2
body_x_l = xfrm.x + (xfrm.w - head_w) / 2.0
body_x_r = xfrm.x + (xfrm.w + head_w) / 2.0
head_y = xfrm.y + head_h
return _polygon([
(body_x_l, xfrm.y + xfrm.h),
(body_x_l, head_y),
(xfrm.x, head_y),
(xfrm.x + xfrm.w / 2.0, xfrm.y),
(xfrm.x + xfrm.w, head_y),
(body_x_r, head_y),
(body_x_r, xfrm.y + xfrm.h),
])
# ---------- Decorative shapes ----------
def _plaque(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Approximate DrawingML plaque with concave curved corner cuts."""
adj = _adj_value(sp_pr, "adj", default_pct=0.16667)
r = min(adj * min(xfrm.w, xfrm.h), min(xfrm.w, xfrm.h) / 2.0)
x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h
d = (
f"M {fmt_num(x + r)} {fmt_num(y)} "
f"L {fmt_num(x + w - r)} {fmt_num(y)} "
f"Q {fmt_num(x + w - r)} {fmt_num(y + r)} {fmt_num(x + w)} {fmt_num(y + r)} "
f"L {fmt_num(x + w)} {fmt_num(y + h - r)} "
f"Q {fmt_num(x + w - r)} {fmt_num(y + h - r)} {fmt_num(x + w - r)} {fmt_num(y + h)} "
f"L {fmt_num(x + r)} {fmt_num(y + h)} "
f"Q {fmt_num(x + r)} {fmt_num(y + h - r)} {fmt_num(x)} {fmt_num(y + h - r)} "
f"L {fmt_num(x)} {fmt_num(y + r)} "
f"Q {fmt_num(x + r)} {fmt_num(y + r)} {fmt_num(x + r)} {fmt_num(y)} Z"
)
return GeomResult(tag="path", path_d=d)
# ---------- Pie / chord / arc (path-based) ----------
def _pie(xfrm: Xfrm, sp_pr) -> GeomResult:
"""Pie slice. adj1 = start angle, adj2 = end angle (1/60000 deg)."""
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.0) # default 0°
adj2 = _adj_value(sp_pr, "adj2", default_pct=270.0 / 360.0) # default 270°
# adj is in 100000ths of percent → degrees by * 360
start_deg = adj1 * 360.0
end_deg = adj2 * 360.0
return _arc_path(xfrm, start_deg, end_deg, mode="pie")
def _chord(xfrm: Xfrm, sp_pr) -> GeomResult:
adj1 = _adj_value(sp_pr, "adj1", default_pct=0.0)
adj2 = _adj_value(sp_pr, "adj2", default_pct=270.0 / 360.0)
return _arc_path(xfrm, adj1 * 360.0, adj2 * 360.0, mode="chord")
def _arc(xfrm: Xfrm, sp_pr) -> GeomResult:
adj1 = _adj_value(sp_pr, "adj1", default_pct=270.0 / 360.0)
adj2 = _adj_value(sp_pr, "adj2", default_pct=0.0)
return _arc_path(xfrm, adj1 * 360.0, adj2 * 360.0, mode="arc")
def _arc_path(xfrm: Xfrm, start_deg: float, end_deg: float, *, mode: str) -> GeomResult:
cx = xfrm.x + xfrm.w / 2.0
cy = xfrm.y + xfrm.h / 2.0
rx = xfrm.w / 2.0
ry = xfrm.h / 2.0
sa = math.radians(start_deg)
ea = math.radians(end_deg)
sx = cx + rx * math.cos(sa)
sy = cy + ry * math.sin(sa)
ex = cx + rx * math.cos(ea)
ey = cy + ry * math.sin(ea)
# Sweep direction: PowerPoint draws clockwise; SVG arc sweep_flag = 1 = clockwise.
delta = (end_deg - start_deg) % 360.0
large_arc = 1 if delta > 180 else 0
sweep = 1
parts = [f"M {fmt_num(sx)} {fmt_num(sy)}",
f"A {fmt_num(rx)} {fmt_num(ry)} 0 {large_arc} {sweep} {fmt_num(ex)} {fmt_num(ey)}"]
if mode == "pie":
parts.append(f"L {fmt_num(cx)} {fmt_num(cy)}")
parts.append("Z")
elif mode == "chord":
parts.append("Z")
return GeomResult(tag="path", path_d=" ".join(parts))
# ---------------------------------------------------------------------------
# Preset table
# ---------------------------------------------------------------------------
_PRESET_HANDLERS = {
# Core 4 (svg_to_pptx round-trip)
"rect": _rect,
"roundRect": _round_rect,
"round2DiagRect": _round_2_diag_rect,
"round2SameRect": _round_2_same_rect,
"ellipse": _ellipse,
"line": _line,
# Straight connector: same geometry as a `line` preset; head/tail markers
# come from <a:ln>.
"straightConnector1": _line,
# Polygons
"triangle": _triangle,
"rtTriangle": _rt_triangle,
"diamond": _diamond,
"parallelogram": _parallelogram,
"trapezoid": _trapezoid,
"chevron": _chevron,
"homePlate": _home_plate,
"flowChartExtract": _flow_chart_extract,
"flowChartMerge": _flow_chart_merge,
"pentagon": _pentagon,
"hexagon": _hexagon,
"heptagon": _heptagon,
"octagon": _octagon,
"decagon": _decagon,
"dodecagon": _dodecagon,
# Stars
"star4": _star(4),
"star5": _star(5),
"star6": _star(6),
"star7": _star(7),
"star8": _star(8),
"star10": _star(10),
"star12": _star(12),
"star16": _star(16),
"star24": _star(24),
"star32": _star(32),
# Arrows
"rightArrow": _right_arrow,
"leftArrow": _left_arrow,
"downArrow": _down_arrow,
"upArrow": _up_arrow,
# Decorative
"plaque": _plaque,
# Pie / chord / arc
"pie": _pie,
"chord": _chord,
"arc": _arc,
}
def _preset_metadata(
prst: str,
prst_geom: ET.Element | None,
) -> dict[str, str]:
"""Return rendering-neutral SVG attributes for native preset semantics."""
attrs = {"data-pptx-prst": prst}
if prst_geom is None:
return attrs
av_lst = prst_geom.find("a:avLst", NS)
if av_lst is None:
return attrs
for guide in av_lst.findall("a:gd", NS):
name = guide.attrib.get("name", "")
if not name:
continue
attrs[f"data-pptx-av-{name}"] = guide.attrib.get("fmla", "")
return attrs
def _preset_adjustments(
prst_geom: ET.Element | None,
) -> dict[str, str]:
"""Return explicit instance adjustment formulas for registry evaluation."""
if prst_geom is None:
return {}
av_lst = prst_geom.find("a:avLst", NS)
if av_lst is None:
return {}
return {
guide.attrib["name"]: guide.attrib.get("fmla", "")
for guide in av_lst.findall("a:gd", NS)
if guide.attrib.get("name")
}
def supported_presets() -> set[str]:
"""Return the set of recognized prst values for diagnostics."""
return set(_PRESET_HANDLERS.keys())
return set(get_preset_registry().names)
@@ -22,10 +22,20 @@ animation anchor.
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from pptx_shapes import (
CONNECTOR_PRESET_TYPES,
NATIVE_FALLBACK_SHA256_ATTR,
has_relationship_attributes,
svg_native_fallback_markup_fingerprint,
svg_text_fingerprint,
)
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload
from .custgeom_to_svg import convert_custom_geom
@@ -36,6 +46,7 @@ from .ln_to_svg import resolve_stroke
from .ooxml_loader import OoxmlPackage, PartRef, SlideRef
from .pic_to_svg import convert_blip_fill, convert_picture
from .prstgeom_to_svg import GeomResult, convert_prst_geom
from .preset_svg_markup import serialize_preset_layers
from .shape_walker import (
CONNECTOR, GRAPHIC, GROUP, PICTURE, SHAPE,
ShapeNode, get_background, walk_sp_tree,
@@ -349,7 +360,13 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
if is_vertical:
# Vertical text: geometry + image in one group, text in separate group
geom_inner = (blip_image + "\n" + geom_xml) if blip_image else geom_xml
shape_xml = _wrap_shape_group(geom_inner, node, ctx, top_level=top_level)
shape_xml = _wrap_shape_group(
geom_inner,
node,
ctx,
top_level=top_level,
extra_attrs=_geometry_group_attrs(geom),
)
if not text_result.svg:
return shape_xml
text_group = (
@@ -365,35 +382,85 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
inner_parts.append(blip_image)
if geom_xml:
inner_parts.append(geom_xml)
if tx_body is not None and geom is not None:
inner_parts.append(
_txbody_metadata(
tx_body,
text_result.svg,
)
)
if text_result.svg:
inner_parts.append(text_result.svg)
inner = "\n".join(inner_parts) if inner_parts else ""
return _wrap_shape_group(inner, node, ctx, top_level=top_level)
return _wrap_shape_group(
inner,
node,
ctx,
top_level=top_level,
extra_attrs=_geometry_group_attrs(geom),
)
def _txbody_metadata(
tx_body: ET.Element,
visible_text_svg: str,
) -> str:
"""Preserve the native text body while its visible SVG remains authoritative."""
if has_relationship_attributes(tx_body):
# Relationship ids are part-local and cannot be copied into a newly
# generated slide without rebuilding the relationship target.
return ""
raw = ET.tostring(tx_body, encoding="utf-8")
encoded = base64.b64encode(raw).decode("ascii")
wrapper = ET.fromstring(
f'<svg xmlns="http://www.w3.org/2000/svg">{visible_text_svg}</svg>'
)
digest = svg_text_fingerprint(wrapper)
return (
'<metadata data-pptx-part="txbody" data-pptx-encoding="base64" '
f'data-pptx-text-sha256="{digest}">{encoded}</metadata>'
)
def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult | None:
"""Resolve a DrawingML shape geometry into an absolute SVG geometry model."""
prst_geom = sp_pr.find("a:prstGeom", NS) if sp_pr is not None else None
cust_geom = sp_pr.find("a:custGeom", NS) if sp_pr is not None else None
prst = prst_geom.attrib.get("prst", "rect") if prst_geom is not None else None
geom: GeomResult | None = None
if prst_geom is not None:
prst = prst_geom.attrib.get("prst", "rect")
geom = convert_prst_geom(prst, node.xfrm, prst_geom)
if geom is None:
# Unknown prst — fall back to rect bounding box
geom = convert_prst_geom("rect", node.xfrm, None)
elif cust_geom is not None:
d = convert_custom_geom(cust_geom, node.xfrm)
if d:
geom = GeomResult(tag="path", path_d=d)
raw = ET.tostring(cust_geom, encoding="utf-8")
geom = GeomResult(
tag="path",
path_d=d,
attrs={
"data-pptx-part": "geometry",
"data-pptx-geometry-kind": "custom",
"data-pptx-custgeom": base64.b64encode(raw).decode("ascii"),
"data-pptx-geometry-sha256": hashlib.sha256(
d.strip().encode("utf-8")
).hexdigest(),
},
)
else:
# No geometry hint at all — render bounding rect
geom = convert_prst_geom("rect", node.xfrm, None)
if geom is None:
return None
if geom.tag != "line" and (node.xfrm.w <= 0 or node.xfrm.h <= 0):
permits_degenerate_axis = (
node.kind == CONNECTOR
or prst in CONNECTOR_PRESET_TYPES
)
if (
not permits_degenerate_axis
and (node.xfrm.w <= 0 or node.xfrm.h <= 0)
):
return None
return geom
@@ -443,8 +510,21 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
# Skip emitting stroke="none" to keep markup tight.
pass
geom_attrs_xml = _attrs_to_xml({**geom.attrs, **attrs})
return _geom_to_svg(geom, geom_attrs_xml)
semantic_attrs = {
**geom.attrs,
**_object_metadata(node, ctx),
}
shape_style = node.xml.find("p:style", NS)
if shape_style is not None:
semantic_attrs["data-pptx-shape-style"] = base64.b64encode(
ET.tostring(shape_style, encoding="utf-8")
).decode("ascii")
if geom.layers:
return _preset_layers_to_svg(geom, semantic_attrs, attrs)
return _geom_to_svg(
geom,
_attrs_to_xml({**semantic_attrs, **attrs}),
)
def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict[str, str]:
@@ -490,9 +570,9 @@ def _resolve_ref_color(ref_elem: ET.Element | None, ctx: AssemblyContext) -> str
return hex_
def _geom_to_svg(geom: GeomResult, attrs_xml: str = "") -> str:
def _geom_to_svg(geom: GeomResult, attrs_xml: str | None = None) -> str:
"""Serialize a resolved geometry with optional SVG attributes."""
if not attrs_xml:
if attrs_xml is None:
attrs_xml = _attrs_to_xml(geom.attrs)
if geom.tag == "path":
return f'<path d="{geom.path_d}"{attrs_xml}/>'
@@ -501,6 +581,29 @@ def _geom_to_svg(geom: GeomResult, attrs_xml: str = "") -> str:
return f"<{geom.tag}{attrs_xml}/>"
def _preset_layers_to_svg(
geom: GeomResult,
semantic_attrs: dict[str, str],
style_attrs: dict[str, str],
) -> str:
"""Serialize one semantic carrier plus every visible preset path layer.
DrawingML applies shape-level fill/line first, then each preset path can
override whether and how that paint is used. A hidden carrier retains the
unmodified shape-level style for native round-trip; visible detail paths
reproduce the preset's independent paint behavior without being exported
as duplicate PowerPoint shapes.
"""
markup = serialize_preset_layers(
geom.layers,
semantic_attrs,
style_attrs,
)
geom.attrs["data-pptx-preview-sha256"] = markup.preview_hash
semantic_attrs["data-pptx-preview-sha256"] = markup.preview_hash
return markup.markup
def _clip_blip_image(image_xml: str, geom: GeomResult | None,
ctx: AssemblyContext) -> str:
"""Clip image fills to the owning shape geometry when it is not a plain rect."""
@@ -511,7 +614,7 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None,
ctx.clip_seq[0] += 1
clip_id = f"{ctx.group_id_prefix}clip{ctx.clip_seq[0]}"
clip_shape = _geom_to_svg(geom)
clip_shape = _geom_to_svg(geom, "")
ctx.defs.append(
f'<clipPath id="{clip_id}" clipPathUnits="userSpaceOnUse">'
f'{clip_shape}</clipPath>'
@@ -542,7 +645,18 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
if not result.svg:
return ""
ctx.media.update(result.media)
return _wrap_shape_group(result.svg, node, ctx, top_level=top_level)
picture_svg = _inject_root_svg_attrs(result.svg, _object_metadata(node, ctx))
return _wrap_shape_group(picture_svg, node, ctx, top_level=top_level)
def _inject_root_svg_attrs(markup: str, attrs: dict[str, str]) -> str:
"""Attach source-object identity to a picture's root SVG element."""
attrs_xml = _attrs_to_xml(attrs)
for tag in ("image", "svg"):
prefix = f"<{tag}"
if markup.startswith(prefix):
return markup.replace(prefix, f"{prefix}{attrs_xml}", 1)
return markup
# ---------------------------------------------------------------------------
@@ -551,8 +665,15 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
sp_pr = node.xml.find("p:spPr", NS)
geom_xml = _build_geometry_xml(node, sp_pr, ctx)
return _wrap_shape_group(geom_xml, node, ctx, top_level=top_level)
geom = _resolve_geometry(node, sp_pr)
geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom)
return _wrap_shape_group(
geom_xml,
node,
ctx,
top_level=top_level,
extra_attrs=_geometry_group_attrs(geom),
)
# ---------------------------------------------------------------------------
@@ -663,8 +784,12 @@ def _render_graphic_table(
tbl = graphic_data.find("a:tbl", NS)
if tbl is None:
return "", []
table_styles_part = ctx.pkg.resolve_table_styles()
result = convert_tbl(
tbl, node.xfrm, ctx.palette,
table_styles=(
table_styles_part.xml if table_styles_part is not None else None
),
theme_fonts=ctx.theme_fonts,
slide_number=ctx.slide_number,
id_prefix=f"tbl{ctx.shape_seq[0]}",
@@ -705,6 +830,7 @@ def _render_graphic_chart(
node.xfrm,
ctx.slide_part,
ctx.pkg,
ctx.palette,
)
native_attrs: list[str] = ['data-pptx-native-source="pptx"']
if result.native_payload:
@@ -728,6 +854,16 @@ def _render_graphic_chart(
rendered = ""
if ctx.render_graphic_previews:
rendered = _render_graphic_preview(node, ctx)
if rendered:
native_attrs.append('data-pptx-visual-status="source-preview"')
elif result.normalized_svg:
rendered = result.normalized_svg
native_attrs.append('data-pptx-visual-status="normalized"')
else:
native_attrs.extend([
'data-pptx-visual-status="placeholder"',
'data-pptx-route-status="reconstruction-only"',
])
return rendered, native_attrs
@@ -994,12 +1130,32 @@ def _wrap_shape_group(
g_id = f"{ctx.group_id_prefix}shape-{sid}"
attrs: list[str] = [f'id="{g_id}"']
attrs.extend(
f'{key}="{_xml_escape(value)}"'
for key, value in _object_metadata(
node,
ctx,
fallback_shape_id=sid,
).items()
)
if node.name:
attrs.append(f'data-name="{_xml_escape(node.name)}"')
if node.placeholder is not None and node.placeholder.type:
attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"')
if extra_attrs:
attrs.extend(extra_attrs)
if any(
attribute.split("=", 1)[0] == "data-pptx-native"
for attribute in extra_attrs
):
fallback_hash = svg_native_fallback_markup_fingerprint(
inner,
root_transform=transform,
external_markup="".join(ctx.defs),
)
attrs.append(
f'{NATIVE_FALLBACK_SHA256_ATTR}="{fallback_hash}"'
)
if transform:
attrs.append(f'transform="{transform}"')
return f"<g {' '.join(attrs)}>\n{inner}\n</g>"
@@ -1008,7 +1164,89 @@ def _wrap_shape_group(
def _attrs_to_xml(attrs: dict[str, str]) -> str:
if not attrs:
return ""
return "".join(f' {k}="{v}"' for k, v in attrs.items())
return "".join(f' {key}="{_xml_escape(value)}"' for key, value in attrs.items())
def _geometry_group_attrs(geom: GeomResult | None) -> list[str]:
"""Mirror native geometry semantics onto the logical shape container."""
if geom is None:
return []
keys = (
"data-pptx-prst",
"data-pptx-geometry-kind",
"data-pptx-geometry-sha256",
"data-pptx-preview-sha256",
"data-pptx-geometry-status",
"data-pptx-geometry-reason",
)
attrs: list[str] = []
for key, value in geom.attrs.items():
if key in keys or key.startswith("data-pptx-av-"):
attrs.append(f'{key}="{_xml_escape(value)}"')
return attrs
def _object_metadata(
node: ShapeNode,
ctx: AssemblyContext,
*,
fallback_shape_id: str = "",
) -> dict[str, str]:
"""Describe the source object without coupling geometry to its SVG bounds."""
object_kind = {
SHAPE: "shape",
PICTURE: "picture",
CONNECTOR: "connector",
GROUP: "group",
GRAPHIC: "graphic-frame",
}.get(node.kind, node.kind)
shape_id = node.spid or fallback_shape_id
frame = " ".join((
fmt_num(node.xfrm.x, 8),
fmt_num(node.xfrm.y, 8),
fmt_num(node.xfrm.w, 8),
fmt_num(node.xfrm.h, 8),
))
attrs = {
"data-pptx-object": object_kind,
"data-pptx-shape-id": shape_id,
"data-pptx-shape-scope": _shape_scope(ctx),
"data-pptx-frame": frame,
}
if node.name:
attrs["data-pptx-shape-name"] = node.name
if node.kind == CONNECTOR:
attrs.update(_connector_metadata(node, _shape_scope(ctx)))
return attrs
def _shape_scope(ctx: AssemblyContext) -> str:
if ctx.group_id_prefix.startswith("master-"):
return "master"
if ctx.group_id_prefix.startswith("layout-"):
return "layout"
return "slide"
def _connector_metadata(node: ShapeNode, scope: str) -> dict[str, str]:
"""Preserve connector endpoint references when PowerPoint declares them."""
attrs: dict[str, str] = {}
cnv = node.xml.find("p:nvCxnSpPr/p:cNvCxnSpPr", NS)
if cnv is None:
return attrs
for endpoint, prefix in (("stCxn", "start"), ("endCxn", "end")):
connection = cnv.find(f"a:{endpoint}", NS)
if connection is None:
continue
shape_id = connection.attrib.get("id")
site = connection.attrib.get("idx")
if shape_id is not None:
attrs[f"data-pptx-{prefix}-shape-id"] = shape_id
attrs[f"data-pptx-{prefix}-shape-scope"] = scope
if site is not None:
attrs[f"data-pptx-{prefix}-site"] = site
return attrs
def _xml_escape(text: str) -> str:
File diff suppressed because it is too large Load Diff
@@ -107,6 +107,7 @@ def convert_txbody(
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextResult:
@@ -119,6 +120,7 @@ def convert_txbody(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
if not paragraphs or not _has_visible_text(paragraphs):
@@ -193,6 +195,7 @@ def convert_vertical_txbody(
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextResult:
@@ -210,6 +213,7 @@ def convert_vertical_txbody(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
runs = [
@@ -344,6 +348,7 @@ def _parse_paragraphs(
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
@@ -361,6 +366,7 @@ def _parse_paragraphs(
para = _parse_paragraph(
p_elem, palette, theme_fonts, autonum_state,
lst_styles=lst_styles,
fallback_run_props=fallback_run_props,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
slide_number=slide_number,
@@ -378,6 +384,7 @@ def _parse_paragraph(
autonum_state: dict[int, int],
*,
lst_styles: tuple[ET.Element, ...] = (),
fallback_run_props: tuple[ET.Element, ...] = (),
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
slide_number: int | None = None,
@@ -422,6 +429,7 @@ def _parse_paragraph(
text, rpr, end_rpr, palette, theme_fonts,
def_rpr=def_rpr,
list_def_rpr=list_def_rpr,
fallback_run_props=fallback_run_props,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
id_prefix=id_prefix, id_seq=id_seq,
@@ -449,6 +457,7 @@ def _parse_paragraph(
text, rpr, end_rpr, palette, theme_fonts,
def_rpr=def_rpr,
list_def_rpr=list_def_rpr,
fallback_run_props=fallback_run_props,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
id_prefix=id_prefix, id_seq=id_seq,
@@ -467,13 +476,16 @@ def _build_run(
*,
def_rpr: ET.Element | None = None,
list_def_rpr: ET.Element | None = None,
fallback_run_props: tuple[ET.Element, ...] = (),
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextRun:
"""Resolve a single <a:r> run from its rPr and fallback run properties."""
style_chain = (rpr, def_rpr, list_def_rpr, end_rpr)
style_chain = (
rpr, def_rpr, list_def_rpr, end_rpr,
) + fallback_run_props
# font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default
sz = _attr_chain(style_chain, "sz")
font_size_px = hundredths_pt_to_px(sz, default_font_size_px)
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Shape SVG Fragment Tool
List DrawingML presets or print one canonical native-preset SVG fragment to
stdout for manual insertion into a hand-authored slide.
Usage:
python3 scripts/preset_shape_svg.py list [--search QUERY]
python3 scripts/preset_shape_svg.py describe PRESET
python3 scripts/preset_shape_svg.py render PRESET --id ID --frame X Y W H
Examples:
python3 scripts/preset_shape_svg.py list --search arrow
python3 scripts/preset_shape_svg.py describe rightArrow
python3 scripts/preset_shape_svg.py render rightArrow --id next-step \
--frame 160 210 320 112 --fill "#2563EB" --stroke none
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Sequence
from console_encoding import configure_utf8_stdio
from pptx_shapes import CONNECTOR_PRESET_TYPES, get_preset_registry
from pptx_to_svg.preset_authoring import render_preset_shape_fragment
configure_utf8_stdio()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Print one canonical DrawingML preset SVG fragment. "
"This tool never writes SVG files or page layouts."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser(
"list",
help="List preset names, optionally filtered by substring.",
)
list_parser.add_argument(
"--search",
default="",
help="Case-insensitive preset-name substring.",
)
describe_parser = subparsers.add_parser(
"describe",
help="Print preset adjustment and path metadata as JSON.",
)
describe_parser.add_argument("preset", help="DrawingML preset name.")
render_parser = subparsers.add_parser(
"render",
help="Print one canonical authored-preset <g> fragment to stdout.",
)
render_parser.add_argument("preset", help="DrawingML preset name.")
render_parser.add_argument(
"--id",
required=True,
dest="element_id",
help="Stable unique SVG group id.",
)
render_parser.add_argument(
"--frame",
required=True,
nargs=4,
type=float,
metavar=("X", "Y", "WIDTH", "HEIGHT"),
help="Absolute SVG frame in page coordinates.",
)
render_parser.add_argument(
"--object-kind",
choices=("shape", "connector"),
default="shape",
help="Emit a normal shape or a native PowerPoint connector.",
)
render_parser.add_argument(
"--name",
help="Optional PowerPoint object name.",
)
render_parser.add_argument(
"--fill",
default="none",
help="Solid SVG fill from spec_lock, or none.",
)
render_parser.add_argument(
"--fill-opacity",
type=float,
help="Fill opacity from 0 to 1.",
)
render_parser.add_argument(
"--stroke",
default="none",
help="Solid SVG stroke from spec_lock, or none.",
)
render_parser.add_argument(
"--stroke-width",
type=float,
help="Stroke width in SVG page units; defaults to 1 when stroked.",
)
render_parser.add_argument(
"--stroke-opacity",
type=float,
help="Stroke opacity from 0 to 1.",
)
render_parser.add_argument(
"--stroke-linecap",
choices=("butt", "round", "square"),
)
render_parser.add_argument(
"--stroke-linejoin",
choices=("miter", "round", "bevel"),
)
render_parser.add_argument(
"--adjust",
action="append",
default=[],
metavar="NAME=FORMULA",
help=(
"DrawingML adjustment formula; repeat for multiple guides, "
"for example --adjust 'adj1=val 50000'."
),
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
registry = get_preset_registry()
if args.command == "list":
query = args.search.casefold().strip()
names = [
name for name in registry.names
if not query or query in name.casefold()
]
if not names:
print(f"No preset names match {args.search!r}", file=sys.stderr)
return 1
print("\n".join(names))
return 0
if args.command == "describe":
if args.preset not in registry:
print(f"Unknown DrawingML preset: {args.preset!r}", file=sys.stderr)
return 1
definition = registry.get(args.preset)
payload = {
"preset": definition.name,
"connector_preset": definition.name in CONNECTOR_PRESET_TYPES,
"adjustments": [
{"name": guide.name, "formula": guide.formula}
for guide in definition.adjustments
],
"path_count": len(definition.paths),
"connection_site_count": len(definition.connections),
"has_text_rectangle": definition.text_rectangle is not None,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
try:
adjustments = _parse_adjustments(args.adjust)
style = _style_from_args(args)
fragment = render_preset_shape_fragment(
args.preset,
tuple(args.frame),
adjustments=adjustments,
object_kind=args.object_kind,
element_id=args.element_id,
name=args.name,
style=style,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(fragment)
return 0
def _parse_adjustments(values: Sequence[str]) -> dict[str, str]:
adjustments: dict[str, str] = {}
for value in values:
name, separator, formula = value.partition("=")
name = name.strip()
formula = formula.strip()
if not separator or not name or not formula:
raise ValueError(
f"Invalid adjustment {value!r}; expected NAME=FORMULA"
)
if name in adjustments:
raise ValueError(f"Duplicate adjustment guide: {name!r}")
adjustments[name] = formula
return adjustments
def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
style = {
"fill": args.fill,
"stroke": args.stroke,
}
if args.fill_opacity is not None:
style["fill-opacity"] = str(args.fill_opacity)
if args.stroke != "none":
style["stroke-width"] = str(
1.0 if args.stroke_width is None else args.stroke_width
)
elif args.stroke_width is not None:
raise ValueError("--stroke-width requires a non-none --stroke")
if args.stroke_opacity is not None:
style["stroke-opacity"] = str(args.stroke_opacity)
if args.stroke_linecap is not None:
style["stroke-linecap"] = args.stroke_linecap
if args.stroke_linejoin is not None:
style["stroke-linejoin"] = args.stroke_linejoin
return style
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,15 +1,20 @@
#!/usr/bin/env python3
"""Register a brand / layout / deck template into the global template index.
Three kinds, three physical directories, three index files (see
Three kinds, three workspace roots, three index files (see
``docs/zh/templates-architecture.md`` for the data model):
| --kind | Source dir | Index file |
| --kind | Workspace roots | Index file |
|---------|-------------------------|-------------------------------|
| brand | ``templates/brands/`` | ``brands_index.json`` |
| layout | ``templates/layouts/`` | ``layouts_index.json`` |
| deck | ``templates/decks/`` | ``decks_index.json`` |
Current workspaces keep ``design_spec.md`` and any SVG roster under
``<workspace>/templates/``. Assets live in optional ``images/`` / ``icons/``
directories. Explicitly generated review artifacts go to the optional, ignored
``exports/`` directory. Legacy flat roots remain readable.
Index entry schemas (the JSON file is the single source of truth README
files describe the kind and usage in prose but do **not** enumerate templates;
discovery happens exclusively against the index file):
@@ -166,6 +171,18 @@ def _summary_from_use_cases(use_cases: str | None) -> str | None:
return f"{cleaned}."
def _template_content_dir(template_root: Path) -> Path:
"""Resolve the canonical source directory, with legacy-flat compatibility."""
nested = template_root / "templates"
if (nested / "design_spec.md").is_file():
return nested
if (template_root / "design_spec.md").is_file():
return template_root
raise SpecParseError(
f"missing templates/design_spec.md or legacy design_spec.md in {template_root}"
)
def _list_pages(template_dir: Path) -> list[str]:
return sorted(p.stem for p in template_dir.glob("*.svg"))
@@ -189,9 +206,9 @@ def _derive_page_types(pages: list[str]) -> list[str]:
def _extract_entry(kind: str, template_id: str, template_dir: Path) -> dict:
"""Build the index entry + extras for a single template."""
template_root = template_dir
template_dir = _template_content_dir(template_root)
spec_path = template_dir / "design_spec.md"
if not spec_path.exists():
raise SpecParseError(f"missing design_spec.md in {template_dir}")
frontmatter, body = _read_spec(spec_path)
fm = frontmatter or {}
@@ -243,6 +260,12 @@ def _extract_entry(kind: str, template_id: str, template_dir: Path) -> dict:
extras = OrderedDict(
pages=pages,
primary_color=str(primary_color),
page_prefix="templates/" if template_dir != template_root else "",
preview=(
f"exports/{template_id}_template_preview.pptx"
if (template_root / "exports" / f"{template_id}_template_preview.pptx").is_file()
else ""
),
)
return {"entry": entry, "extras": extras}
@@ -275,7 +298,11 @@ def _enumerate_ids(kind: str) -> list[str]:
return []
return sorted(
p.name for p in base.iterdir()
if p.is_dir() and (p / "design_spec.md").exists()
if p.is_dir()
and (
(p / "templates" / "design_spec.md").is_file()
or (p / "design_spec.md").is_file()
)
)
@@ -300,13 +327,20 @@ def _print_completion_card(kind: str, template_id: str, entry: dict, extras: dic
print()
if kind != "brand":
pages = extras.get("pages") or []
page_prefix = extras.get("page_prefix") or ""
preview = extras.get("preview") or ""
if preview:
print(f"**Review PPTX**: `{preview}`")
print()
if pages:
print("### Files Included")
print()
print("| File | Status |")
print("|------|--------|")
for page in pages:
print(f"| `{page}.svg` | Done |")
print(f"| `{page_prefix}{page}.svg` | Done |")
if preview:
print(f"| `{preview}` | Verified |")
print()
@@ -17,8 +17,10 @@ Dependency:
API stability note:
Detecting slide-internal jumps (``ppaction://hlinksldjump``) reads
``run._r`` (the CT_TextRun lxml element) because python-pptx exposes no
public API to distinguish an internal jump from an external URL. This
access pattern is stable across python-pptx 0.6.x; pin python-pptx<0.7.
public API to distinguish an internal jump from an external URL. XY chart
extraction likewise reads ``series._element`` for X values and bubble sizes,
which the public chart API does not expose. Keep these private accesses
localized here and covered by conversion smoke tests.
"""
from __future__ import annotations
@@ -57,6 +59,10 @@ configure_utf8_stdio()
EMU_PER_INCH = 914400
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
IMAGE_EXT_BY_CONTENT_TYPE = {
"image/bmp": "bmp",
@@ -133,7 +139,10 @@ def sanitize_filename(value: str) -> str:
def escape_table_cell(value: str) -> str:
"""Escape Markdown table syntax inside a cell."""
return normalize_text(value).replace("|", r"\|") or " "
normalized = value.replace("\r\n", "\n").replace("\r", "\n")
lines = [re.sub(r"\s+", " ", line).strip() for line in normalized.split("\n")]
with_breaks = "<br>".join(lines)
return with_breaks.replace("|", r"\|") or " "
def _safe_position(shape: object, attr: str) -> int:
@@ -384,55 +393,260 @@ def _format_chart_value(value: object) -> str:
return str(value)
def chart_to_markdown(chart: object, name: str) -> str:
"""Render a chart's data as a Markdown table so the numbers survive conversion.
A native PowerPoint chart stores its data in embedded XML, not in any text
frame emitting only a `[Chart]` placeholder drops every value. The markdown
is the content contract for downstream generation, so transcribe categories ×
series here. `scripts/pptx_intake.py` writes the same data in structured JSON
form for tooling; this is the human- and content-readable mirror.
"""
def _chart_header(chart: object, name: str) -> tuple[str, str]:
"""Return the Markdown chart header and its best-effort type label."""
try:
chart_type = str(chart.chart_type)
except (ValueError, AttributeError):
except (ValueError, AttributeError, KeyError):
chart_type = ""
raw_name = "" if name is None else str(name)
chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart"
header = f"> [Chart] {chart_name}" + (f"{chart_type}" if chart_type else "")
return header, chart_type
def _chart_warning_lines(warnings: list[str]) -> list[str]:
"""Return stable, de-duplicated Markdown warning blocks."""
lines: list[str] = []
seen: set[str] = set()
for warning in warnings:
normalized = normalize_text(warning).replace("\n", " ") or "unknown warning"
if normalized in seen:
continue
seen.add(normalized)
lines.append(f"> [Chart data warning: {normalized}]")
return lines
def _chart_data_unavailable(
header: str,
reason: str,
*,
warnings: list[str] | None = None,
) -> str:
"""Attach an explicit data-read failure to a chart heading."""
normalized_reason = normalize_text(reason).replace("\n", " ") or "unknown reason"
lines = [header]
lines.extend(_chart_warning_lines(warnings or []))
lines.append(f"> [Chart data unavailable: {normalized_reason}]")
return "\n".join(lines)
def _chart_value_cell(value: object) -> str:
"""Return one chart table cell while keeping missing values visibly empty."""
rendered = _format_chart_value(value)
return escape_table_cell(rendered) if rendered else ""
def _chart_series_element(series: object) -> object | None:
"""Return python-pptx's series XML carrier when its public API is insufficient."""
element = getattr(series, "_element", None)
if element is not None:
return element
return getattr(series, "_ser", None)
def _chart_series_name(series: object, index: int) -> str:
"""Return a stable, Markdown-safe series name."""
try:
raw_name = series.name
except (ValueError, AttributeError, KeyError):
raw_name = None
label = str(raw_name) if raw_name not in (None, "") else f"Series {index}"
return escape_table_cell(label)
def _chart_numeric_cache_values(
parent: object | None,
) -> tuple[list[object | None] | None, str | None]:
"""Read one XY display cache through python-pptx's OOXML value helpers."""
if parent is None:
return None, "missing numeric value container"
try:
point_count = int(parent.ptCount_val)
values = [parent.pt_v(index) for index in range(point_count)]
except (AttributeError, IndexError, TypeError, ValueError):
return None, "invalid or unavailable numeric display cache"
if point_count <= 0 or all(value is None for value in values):
return None, "numeric display cache contains no values"
return values, None
def _chart_family(chart_type: str, series: list[object]) -> str:
"""Classify category, scatter, and bubble charts without misreading XY as category."""
type_key = chart_type.upper()
if "BUBBLE" in type_key:
return "bubble"
if "SCATTER" in type_key:
return "scatter"
for item in series:
element = _chart_series_element(item)
if element is None:
continue
if element.find(qn("c:bubbleSize")) is not None:
return "bubble"
if element.find(qn("c:xVal")) is not None or element.find(qn("c:yVal")) is not None:
return "scatter"
return "category"
def _xy_chart_to_markdown(
series: list[object],
*,
family: str,
header: str,
) -> str:
"""Render scatter/bubble series as typed per-point X/Y[/size] rows."""
table_header = ["Series", "Point", "X", "Y"]
if family == "bubble":
table_header.append("Size")
rows: list[list[str]] = []
warnings: list[str] = []
for series_index, item in enumerate(series, start=1):
series_name = _chart_series_name(item, series_index)
element = _chart_series_element(item)
if element is None:
x_values = None
warnings.append(f"{series_name}: series XML is unavailable for X data")
else:
x_values, x_error = _chart_numeric_cache_values(
element.find(qn("c:xVal"))
)
if x_error:
warnings.append(f"{series_name}: X data {x_error}")
try:
y_values = list(item.values)
except (ValueError, TypeError, AttributeError, KeyError):
y_values = []
warnings.append(f"{series_name}: Y values are unavailable")
size_values: list[object | None] | None = None
if family == "bubble":
if element is None:
size_error = "missing series XML"
else:
size_values, size_error = _chart_numeric_cache_values(
element.find(qn("c:bubbleSize"))
)
if size_error:
warnings.append(f"{series_name}: bubble sizes {size_error}")
x_values = x_values or []
point_count = max(
len(x_values),
len(y_values),
len(size_values or []),
)
if point_count == 0:
warnings.append(f"{series_name}: no readable points")
continue
point_counts = {len(x_values), len(y_values)}
if family == "bubble":
point_counts.add(len(size_values or []))
if len(point_counts) > 1:
dimensions = "X/Y/size" if family == "bubble" else "X/Y"
warnings.append(
f"{series_name}: {dimensions} point counts differ; "
"missing cells are blank"
)
for point_index in range(point_count):
x_value = x_values[point_index] if point_index < len(x_values) else None
y_value = y_values[point_index] if point_index < len(y_values) else None
row = [
series_name,
str(point_index + 1),
_chart_value_cell(x_value),
_chart_value_cell(y_value),
]
if family == "bubble":
size_value = (
size_values[point_index]
if size_values is not None and point_index < len(size_values)
else None
)
row.append(_chart_value_cell(size_value))
rows.append(row)
if not rows:
return _chart_data_unavailable(
header,
"chart has no readable XY points",
warnings=warnings,
)
lines = [header]
lines.extend(_chart_warning_lines(warnings))
lines.extend([
"",
"| " + " | ".join(table_header) + " |",
"| " + " | ".join(["---"] * len(table_header)) + " |",
])
lines.extend("| " + " | ".join(row) + " |" for row in rows)
return "\n".join(lines)
def _category_chart_to_markdown(chart: object, series: list[object], header: str) -> str:
"""Render a conventional category chart through python-pptx's public API."""
categories: list[str] = []
warnings: list[str] = []
has_category_xml = any(
(element := _chart_series_element(item)) is not None
and element.find(qn("c:cat")) is not None
for item in series
)
try:
plots = list(chart.plots)
if plots:
categories = [
escape_table_cell(str(cat)) if cat is not None else ""
for cat in plots[0].categories
escape_table_cell(str(category)) if category is not None else ""
for category in plots[0].categories
]
except (ValueError, IndexError, AttributeError):
categories = []
except (ValueError, TypeError, IndexError, AttributeError, KeyError):
if has_category_xml:
warnings.append("chart categories are unavailable; using point numbers")
if not has_category_xml and not categories:
warnings.append("chart categories are missing; using point numbers")
elif has_category_xml and not categories:
warnings.append("chart categories are empty; using point numbers")
series_data: list[tuple[str, list[object]]] = []
try:
for index, series in enumerate(chart.series, start=1):
try:
values = list(series.values)
except (ValueError, TypeError, AttributeError):
values = []
label = str(series.name) if getattr(series, "name", None) else f"Series {index}"
series_data.append((escape_table_cell(label), values))
except (ValueError, AttributeError):
series_data = []
for index, item in enumerate(series, start=1):
series_name = _chart_series_name(item, index)
try:
values = list(item.values)
except (ValueError, TypeError, AttributeError, KeyError):
warnings.append(f"{series_name}: series values are unavailable")
continue
series_data.append((series_name, values))
header = f"> [Chart] {name}" + (f"{chart_type}" if chart_type else "")
row_count = len(categories) if categories else max((len(v) for _, v in series_data), default=0)
row_count = max(
len(categories),
max((len(values) for _, values in series_data), default=0),
)
if not series_data or row_count == 0:
return header
return _chart_data_unavailable(
header,
"chart has no readable category-series data",
warnings=warnings,
)
point_counts = {len(values) for _, values in series_data}
if categories:
point_counts.add(len(categories))
if len(point_counts) > 1:
warnings.append("category/series point counts differ; missing cells are blank")
table_header = (["Category"] if categories else ["#"]) + [sname for sname, _ in series_data]
lines = [
header,
table_header = (["Category"] if categories else ["#"]) + [
series_name for series_name, _ in series_data
]
lines = [header]
lines.extend(_chart_warning_lines(warnings))
lines.extend([
"",
"| " + " | ".join(table_header) + " |",
"| " + " | ".join(["---"] * len(table_header)) + " |",
]
])
for row_index in range(row_count):
if categories:
label = categories[row_index] if row_index < len(categories) else ""
@@ -440,11 +654,85 @@ def chart_to_markdown(chart: object, name: str) -> str:
label = str(row_index + 1)
cells = [label]
for _, values in series_data:
cells.append(_format_chart_value(values[row_index]) if row_index < len(values) else "")
value = values[row_index] if row_index < len(values) else None
cells.append(_chart_value_cell(value))
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def chart_to_markdown(chart: object, name: str) -> str:
"""Render category, scatter, and bubble data without flattening chart semantics.
A native PowerPoint chart stores its data in embedded XML, not in any text
frame. Public python-pptx APIs cover category values and scatter/bubble Y
values, but not XY X coordinates or bubble sizes. Read only those missing
display caches from the series XML. Preserve every readable value and emit
explicit warnings for missing dimensions or series rather than discarding
the chart's remaining content.
"""
header, chart_type = _chart_header(chart, name)
try:
series = list(chart.series)
except (ValueError, TypeError, AttributeError, KeyError):
return _chart_data_unavailable(header, "chart series are unavailable")
if not series:
return _chart_data_unavailable(header, "chart has no readable series")
family = _chart_family(chart_type, series)
if family in {"scatter", "bubble"}:
return _xy_chart_to_markdown(series, family=family, header=header)
return _category_chart_to_markdown(chart, series, header)
def _chart_reference_id(element: object) -> str | None:
"""Return the first chart relationship id carried by an OOXML shape subtree."""
for descendant in element.iter():
if descendant.tag.rsplit("}", 1)[-1] != "chart":
continue
relationship_id = descendant.get(f"{{{RELATIONSHIP_NS}}}id")
if relationship_id:
return relationship_id
return None
def _unexposed_chartex_markdown(
slide: object,
emitted_relationship_ids: set[str],
) -> list[str]:
"""Report ChartEx objects omitted from ``slide.shapes`` by python-pptx."""
blocks: list[str] = []
seen_relationship_ids: set[str] = set()
slide_element = getattr(slide, "element", None)
if slide_element is None:
slide_element = getattr(slide, "_element", None)
if slide_element is None:
return blocks
for graphic_data in slide_element.iter(f"{{{DRAWINGML_NS}}}graphicData"):
if graphic_data.get("uri") != CHARTEX_URI:
continue
relationship_id = _chart_reference_id(graphic_data)
if relationship_id and (
relationship_id in emitted_relationship_ids
or relationship_id in seen_relationship_ids
):
continue
if relationship_id:
seen_relationship_ids.add(relationship_id)
name = "ChartEx chart"
current = graphic_data
while current is not None:
name_element = current.find(f".//{{{PRESENTATIONML_NS}}}cNvPr")
if name_element is not None and name_element.get("name"):
name = name_element.get("name")
break
current = current.getparent() if hasattr(current, "getparent") else None
chart_name = normalize_text(str(name)).replace("\n", " ") or "ChartEx chart"
header = f"> [Chart] {chart_name} — ChartEx"
blocks.append(_chart_data_unavailable(header, "unsupported ChartEx data model"))
return blocks
def _image_part_for_shape(shape: object) -> object | None:
"""Return the first embedded image part referenced by a shape."""
element = getattr(shape, "element", None)
@@ -787,6 +1075,7 @@ def convert_presentation_to_markdown(
if diagram.get("shape_id") is not None
}
emitted_diagram_ids: set[str] = set()
emitted_chart_relationship_ids: set[str] = set()
for item in iter_leaf_shapes(slide.shapes):
shape = item.shape
@@ -843,10 +1132,31 @@ def convert_presentation_to_markdown(
continue
if getattr(shape, "has_chart", False):
shape_element = getattr(shape, "element", None)
if shape_element is None:
shape_element = getattr(shape, "_element", None)
relationship_id = (
_chart_reference_id(shape_element)
if shape_element is not None
else None
)
if relationship_id:
emitted_chart_relationship_ids.add(relationship_id)
try:
blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart")))
except (ValueError, AttributeError, KeyError):
blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}")
except (ValueError, TypeError, AttributeError, KeyError) as exc:
raw_name = getattr(shape, "name", "Chart")
name = normalize_text("" if raw_name is None else str(raw_name)) or "Chart"
blocks.append(
_chart_data_unavailable(
f"> [Chart] {name}",
f"chart read failed ({type(exc).__name__})",
)
)
blocks.extend(
_unexposed_chartex_markdown(slide, emitted_chart_relationship_ids)
)
for diagram in slide_diagrams:
if str(diagram.get("diagram_id")) in emitted_diagram_ids:
@@ -0,0 +1,591 @@
#!/usr/bin/env python3
"""
PPT Master - SVG Authoring View
Create a lightweight, non-destructive view of PPTX-imported SVG files for
human or model inspection. The source SVG remains the round-trip authority;
the output copy keeps visible SVG content and compact shape intent while
hiding bulky import-only payloads and duplicate hidden geometry carriers.
Usage:
python3 scripts/svg_authoring_view.py <svg-file-or-directory> -o <output-dir>
Examples:
python3 scripts/svg_authoring_view.py analysis/source_svg_import/svg-flat -o analysis/authoring-svg
python3 scripts/svg_authoring_view.py imported/slide_06.svg -o /tmp/slide-authoring-view
Dependencies:
None (standard library only).
This tool produces an inspection/authoring projection, not a final template or
release export source. Keep the complete imported SVG for native restoration.
Directory runs prepare and stage the complete batch before publishing it, so a
failed page leaves the existing destination set unchanged.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import tempfile
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
# These fields identify the source OOXML object or guard its exact imported
# fallback. They belong in the complete import SVG, not its lightweight view.
IMPORT_SOURCE_ATTRIBUTES = {
"data-name",
"data-pptx-preview-sha256",
"data-pptx-shape-id",
"data-pptx-shape-name",
"data-pptx-shape-scope",
"data-pptx-shape-style",
}
# Compact native-shape intent is intentionally not in the removal set:
# data-pptx-object, data-pptx-prst, and data-pptx-frame remain useful while
# reviewing the visible fallback. Structural markers also pass through
# unchanged; this projection never defines restoration policy.
def _local_name(name: object) -> str:
return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
@dataclass
class ProjectionStats:
txbody_metadata: int = 0
hidden_geometry_carriers: int = 0
geometry_preview_wrappers: int = 0
geometry_detail_markers: int = 0
asset_references_rewritten: int = 0
source_attributes: Counter[str] = field(default_factory=Counter)
def as_dict(self) -> dict[str, object]:
return {
"txbody_metadata": self.txbody_metadata,
"hidden_geometry_carriers": self.hidden_geometry_carriers,
"geometry_preview_wrappers": self.geometry_preview_wrappers,
"geometry_detail_markers": self.geometry_detail_markers,
"source_attributes": dict(sorted(self.source_attributes.items())),
"asset_references_rewritten": self.asset_references_rewritten,
}
def merge(self, other: "ProjectionStats") -> None:
self.txbody_metadata += other.txbody_metadata
self.hidden_geometry_carriers += other.hidden_geometry_carriers
self.geometry_preview_wrappers += other.geometry_preview_wrappers
self.geometry_detail_markers += other.geometry_detail_markers
self.asset_references_rewritten += other.asset_references_rewritten
self.source_attributes.update(other.source_attributes)
@dataclass
class ProjectionReport:
source: Path
output: Path
original_bytes: int
projected_bytes: int
stats: ProjectionStats
def as_dict(self) -> dict[str, object]:
saved = self.original_bytes - self.projected_bytes
reduction = (saved / self.original_bytes * 100) if self.original_bytes else 0.0
return {
"source": str(self.source),
"output": str(self.output),
"original_bytes": self.original_bytes,
"projected_bytes": self.projected_bytes,
"bytes_saved": saved,
"reduction_percent": round(reduction, 2),
"removed": self.stats.as_dict(),
}
def _is_hidden_geometry_carrier(element: ET.Element) -> bool:
if element.get("data-pptx-part") != "geometry":
return False
visibility = (element.get("visibility") or "").strip().lower()
display = (element.get("display") or "").strip().lower()
style = (element.get("style") or "").replace(" ", "").lower()
return (
visibility == "hidden"
or display == "none"
or "visibility:hidden" in style
or "display:none" in style
)
def _append_tail(parent: ET.Element, index: int, tail: str | None) -> None:
if not tail:
return
if index > 0:
previous = list(parent)[index - 1]
previous.tail = (previous.tail or "") + tail
else:
parent.text = (parent.text or "") + tail
def _remove_child(parent: ET.Element, child: ET.Element) -> None:
children = list(parent)
index = children.index(child)
tail = child.tail
parent.remove(child)
_append_tail(parent, index, tail)
def _unwrap_preview(parent: ET.Element, wrapper: ET.Element) -> bool:
"""Promote a marker-only preview wrapper without changing its geometry."""
if wrapper.attrib or (wrapper.text and wrapper.text.strip()):
return False
siblings = list(parent)
index = siblings.index(wrapper)
promoted = list(wrapper)
wrapper_tail = wrapper.tail
for child in promoted:
wrapper.remove(child)
parent.remove(wrapper)
for offset, child in enumerate(promoted):
parent.insert(index + offset, child)
if promoted:
promoted[-1].tail = (promoted[-1].tail or "") + (wrapper_tail or "")
else:
_append_tail(parent, index, wrapper_tail)
return True
def _strip_import_attributes(element: ET.Element, stats: ProjectionStats) -> None:
for name in list(element.attrib):
if name not in IMPORT_SOURCE_ATTRIBUTES:
continue
stats.source_attributes[name] += 1
del element.attrib[name]
def _project_subtree(parent: ET.Element, stats: ProjectionStats) -> None:
for child in list(parent):
part = child.get("data-pptx-part")
tag = _local_name(child.tag)
if tag == "metadata" and part == "txbody":
stats.txbody_metadata += 1
_remove_child(parent, child)
continue
if _is_hidden_geometry_carrier(child):
stats.hidden_geometry_carriers += 1
_remove_child(parent, child)
continue
_project_subtree(child, stats)
_strip_import_attributes(child, stats)
if part == "geometry-preview":
child.attrib.pop("data-pptx-part", None)
if _unwrap_preview(parent, child):
stats.geometry_preview_wrappers += 1
elif part == "geometry-detail":
child.attrib.pop("data-pptx-part", None)
stats.geometry_detail_markers += 1
def _rewrite_asset_reference(value: str, source_dir: Path, output_dir: Path) -> str:
if not value or value.startswith("#"):
return value
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc or not parsed.path:
return value
resolved = (source_dir / parsed.path).resolve()
try:
relative = os.path.relpath(resolved, output_dir).replace(os.sep, "/")
except ValueError:
relative = resolved.as_uri()
return urlunsplit(("", "", relative, parsed.query, parsed.fragment))
def _rewrite_asset_references(
root: ET.Element,
source_dir: Path,
output_dir: Path,
stats: ProjectionStats,
) -> None:
for element in root.iter():
for name in ("href", f"{{{XLINK_NS}}}href"):
current = element.get(name)
if current is None:
continue
rewritten = _rewrite_asset_reference(current, source_dir, output_dir)
if rewritten != current:
element.set(name, rewritten)
stats.asset_references_rewritten += 1
def _render_projection(source: Path, output: Path) -> tuple[ProjectionReport, bytes]:
"""Build one projection in memory without changing source or destination."""
original = source.read_bytes()
parser = ET.XMLParser(
target=ET.TreeBuilder(insert_comments=True, insert_pis=True),
)
root = ET.fromstring(original, parser=parser)
if _local_name(root.tag) != "svg":
raise ValueError(f"Root element is not <svg>: {source}")
stats = ProjectionStats()
_project_subtree(root, stats)
_strip_import_attributes(root, stats)
_rewrite_asset_references(root, source.parent, output.parent, stats)
projected = ET.tostring(root, encoding="utf-8", xml_declaration=False)
if not projected.endswith(b"\n"):
projected += b"\n"
report = ProjectionReport(
source=source,
output=output,
original_bytes=len(original),
projected_bytes=len(projected),
stats=stats,
)
return report, projected
def project_svg(source: Path, output: Path) -> ProjectionReport:
"""Write one lightweight authoring projection without changing the source."""
report, projected = _render_projection(source, output)
output.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{output.name}.",
suffix=".tmp",
dir=output.parent,
delete=False,
) as stream:
temporary = Path(stream.name)
stream.write(projected)
temporary.replace(output)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return report
def _nearest_existing_directory(path: Path) -> Path:
candidate = path
while not os.path.lexists(candidate):
parent = candidate.parent
if parent == candidate:
break
candidate = parent
if not candidate.is_dir():
raise NotADirectoryError(f"Output parent is not a directory: {candidate}")
return candidate
def _ensure_directory(path: Path, created: list[Path]) -> None:
missing: list[Path] = []
candidate = path
while not candidate.exists():
if os.path.lexists(candidate):
raise NotADirectoryError(f"Output parent is not a directory: {candidate}")
missing.append(candidate)
parent = candidate.parent
if parent == candidate:
raise NotADirectoryError(f"Cannot resolve output parent: {path}")
candidate = parent
if not candidate.is_dir():
raise NotADirectoryError(f"Output parent is not a directory: {candidate}")
for directory in reversed(missing):
directory.mkdir()
created.append(directory)
def _remove_created_directories(created: list[Path]) -> list[str]:
errors: list[str] = []
for directory in reversed(created):
try:
directory.rmdir()
except FileNotFoundError:
continue
except OSError as exc:
errors.append(f"could not remove {directory}: {exc}")
return errors
def _rollback_published_files(
published: list[tuple[Path, Path | None]],
created: list[Path],
) -> list[str]:
errors: list[str] = []
for target, backup in reversed(published):
try:
if backup is None:
target.unlink(missing_ok=True)
else:
backup.replace(target)
except OSError as exc:
errors.append(f"could not restore {target}: {exc}")
errors.extend(_remove_created_directories(created))
return errors
def _publish_existing_directory(
staged: list[tuple[ProjectionReport, Path]],
staging_root: Path,
*,
force: bool,
) -> None:
backup_root = staging_root / "previous"
backups: dict[Path, Path | None] = {}
for index, (report, _) in enumerate(staged):
target = report.output
if not os.path.lexists(target):
backups[target] = None
continue
if not force:
raise FileExistsError(f"Output file already exists: {target}")
if target.is_dir() and not target.is_symlink():
raise IsADirectoryError(f"Output target is a directory: {target}")
backup = backup_root / f"{index:06d}.svg"
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(target, backup, follow_symlinks=False)
backups[target] = backup
created: list[Path] = []
published: list[tuple[Path, Path | None]] = []
try:
for report, _ in staged:
_ensure_directory(report.output.parent, created)
staging_device = staging_root.stat().st_dev
for report, _ in staged:
if report.output.parent.stat().st_dev != staging_device:
raise OSError(
f"Cannot atomically publish across filesystems: {report.output}"
)
if backups[report.output] is None and os.path.lexists(report.output):
raise FileExistsError(
f"Output appeared while projections were staged: {report.output}"
)
for report, staged_file in staged:
target = report.output
staged_file.replace(target)
published.append((target, backups[target]))
except OSError as exc:
rollback_errors = _rollback_published_files(published, created)
if rollback_errors:
details = "; ".join(rollback_errors)
raise RuntimeError(
f"Batch publish failed ({exc}); rollback was incomplete: {details}"
) from exc
raise
def project_svg_batch(
mapping: list[tuple[Path, Path]],
output_dir: Path,
*,
force: bool,
) -> list[ProjectionReport]:
"""Project and publish a directory mapping as one recoverable transaction."""
rendered = [_render_projection(source, output) for source, output in mapping]
staging_parent = _nearest_existing_directory(output_dir.parent)
with tempfile.TemporaryDirectory(
prefix=".svg-authoring-view-",
dir=staging_parent,
) as temporary:
staging_root = Path(temporary)
new_root = staging_root / "projected"
staged: list[tuple[ProjectionReport, Path]] = []
for report, projected in rendered:
relative = report.output.relative_to(output_dir)
staged_file = new_root / relative
staged_file.parent.mkdir(parents=True, exist_ok=True)
staged_file.write_bytes(projected)
staged.append((report, staged_file))
if not output_dir.exists():
created: list[Path] = []
try:
_ensure_directory(output_dir.parent, created)
if os.path.lexists(output_dir):
raise FileExistsError(
f"Output directory appeared while projections were staged: {output_dir}"
)
if output_dir.parent.stat().st_dev != staging_root.stat().st_dev:
raise OSError(
f"Cannot atomically publish across filesystems: {output_dir}"
)
new_root.replace(output_dir)
except OSError as exc:
cleanup_errors = _remove_created_directories(created)
if cleanup_errors:
details = "; ".join(cleanup_errors)
raise RuntimeError(
f"Batch publish failed ({exc}); cleanup was incomplete: {details}"
) from exc
raise
else:
_publish_existing_directory(
staged,
staging_root,
force=force,
)
return [report for report, _ in rendered]
def _is_within(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
except ValueError:
return False
return True
def _source_mapping(input_path: Path, output_dir: Path) -> list[tuple[Path, Path]]:
if input_path.is_file():
if input_path.suffix.lower() != ".svg":
raise ValueError(f"Input file must use the .svg extension: {input_path}")
return [(input_path, output_dir / input_path.name)]
sources = sorted(
path for path in input_path.rglob("*")
if path.is_file() and path.suffix.lower() == ".svg"
)
if not sources:
raise ValueError(f"No SVG files found under: {input_path}")
return [(source, output_dir / source.relative_to(input_path)) for source in sources]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Create lightweight, non-destructive authoring views of PPTX-imported SVG files."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("input", type=Path, help="SVG file or directory to project")
parser.add_argument(
"-o",
"--output-dir",
type=Path,
required=True,
help="Explicit destination directory for projected SVG copies",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace projected files that already exist (never changes source files)",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
input_path = args.input.resolve()
output_dir = args.output_dir.resolve()
if not input_path.exists():
print(f"Error: input does not exist: {input_path}", file=sys.stderr)
return 1
if output_dir.exists() and not output_dir.is_dir():
print(f"Error: output path is not a directory: {output_dir}", file=sys.stderr)
return 1
if input_path.is_dir() and _is_within(output_dir, input_path):
print("Error: output directory must not be inside the input directory", file=sys.stderr)
return 1
try:
mapping = _source_mapping(input_path, output_dir)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
same_file = [source for source, target in mapping if source.resolve() == target.resolve()]
if same_file:
print(f"Error: output would overwrite source SVG: {same_file[0]}", file=sys.stderr)
return 1
collisions = [target for _, target in mapping if os.path.lexists(target)]
if collisions and not args.force:
print(
f"Error: {len(collisions)} output file(s) already exist; use --force to replace projections. "
f"First collision: {collisions[0]}",
file=sys.stderr,
)
return 1
reports: list[ProjectionReport] = []
try:
if input_path.is_file():
source, output = mapping[0]
reports.append(project_svg(source, output))
else:
reports = project_svg_batch(
mapping,
output_dir,
force=args.force,
)
except (ET.ParseError, OSError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
total_stats = ProjectionStats()
original_bytes = 0
projected_bytes = 0
for report in reports:
original_bytes += report.original_bytes
projected_bytes += report.projected_bytes
total_stats.merge(report.stats)
bytes_saved = original_bytes - projected_bytes
reduction = (bytes_saved / original_bytes * 100) if original_bytes else 0.0
result = {
"input": str(input_path),
"output_dir": str(output_dir),
"file_count": len(reports),
"files": [report.as_dict() for report in reports],
"totals": {
"original_bytes": original_bytes,
"projected_bytes": projected_bytes,
"bytes_saved": bytes_saved,
"reduction_percent": round(reduction, 2),
"removed": total_stats.as_dict(),
},
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1048,6 +1048,35 @@ def _open_browser(url: str) -> bool:
return False
def _reuse_running_server(existing: dict, *, open_browser: bool) -> int:
"""Idempotent relaunch: point at the already-running preview instead of failing.
A relaunch while the server is alive is the normal second-preview flow
(the first server keeps serving after the browser tab is closed), so it
must re-open the browser and exit 0 not error out.
"""
pid = existing.get('pid', '?')
try:
port = int(existing.get('port', 0) or 0)
except (TypeError, ValueError):
port = 0
if not port:
logger.error(
'live preview is already running for this project (pid=%s) but its '
'lock records no usable port; run --shutdown, then start again',
pid,
)
return 1
url = _server_url(port)
logger.info(
'live preview already running for this project (pid=%s), reusing: %s',
pid, url,
)
if open_browser and not _open_browser(url):
logger.info('browser did not auto-open; open %s manually', url)
return 0
def _open_browser_async(url: str, delay: float = 0.4) -> None:
"""Open the browser shortly after Flask starts binding its socket."""
def _open() -> None:
@@ -1121,15 +1150,7 @@ def main(argv: Optional[list[str]] = None) -> int:
legacy_existing = _legacy_live_lock(project_path)
if legacy_existing:
existing_pid = legacy_existing.get('pid', '?')
existing_port = legacy_existing.get('port', '?')
logger.error(
'live preview is already running for this project via legacy lock '
'(pid=%s, port=%s). Open http://%s:%s, click '
'Exit preview in the browser, or stop pid %s',
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
)
return 1
return _reuse_running_server(legacy_existing, open_browser=not args.no_browser)
runtime_dir = _runtime_dir(project_path)
lock_file = _lock_file(project_path)
@@ -1137,14 +1158,7 @@ def main(argv: Optional[list[str]] = None) -> int:
if args.daemon:
existing = _read_lock(lock_file)
if existing and _process_alive(_lock_pid(existing)):
existing_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?')
logger.error(
'live preview is already running for this project '
'(pid=%s, port=%s). Open http://%s:%s',
existing_pid, existing_port, PUBLIC_HOST, existing_port,
)
return 1
return _reuse_running_server(existing, open_browser=not args.no_browser)
try:
runtime_dir.mkdir(parents=True, exist_ok=True)
@@ -1197,8 +1211,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# Per-project mutual exclusion. The major driver of orphaned servers is
# --live mode (which used to disable idle timeout entirely) combined with
# silent restarts; refusing duplicate launches catches the accumulation
# at its source. Stale locks (dead pid) are overwritten by _claim_lock.
# silent restarts; reusing the running server on duplicate launches catches
# the accumulation at its source. Stale locks (dead pid) are overwritten
# by _claim_lock.
try:
runtime_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
@@ -1206,15 +1221,7 @@ def main(argv: Optional[list[str]] = None) -> int:
return 1
existing = _claim_lock(lock_file, port)
if existing:
existing_pid = existing.get('pid', '?')
existing_port = existing.get('port', '?')
logger.error(
'live preview is already running for this project '
'(pid=%s, port=%s). Open http://%s:%s, click '
'Exit preview in the browser, or run: kill %s',
existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
)
return 1
return _reuse_running_server(existing, open_browser=not args.no_browser)
# atexit covers normal interpreter shutdown (Ctrl+C / SystemExit);
# /api/shutdown and idle timeout call _release_lock directly before
# os._exit since atexit handlers do not run on os._exit.
@@ -260,6 +260,18 @@ def _get_font_size_px(elem: ET.Element) -> float | None:
return parse_first_number(style_size)
def _effective_line_font_size_px(
text_el: ET.Element,
line_group: list[ET.Element],
) -> float:
"""Return the positioned line starter's effective font size."""
line_size = _get_font_size_px(line_group[0])
if line_size is not None:
return line_size
parent_size = _get_font_size_px(text_el)
return parent_size if parent_size is not None else 16.0
def _classify_paragraph_block(
text_el: ET.Element,
is_svg_tag,
@@ -292,6 +304,7 @@ def _classify_paragraph_block(
is treated as a section break and rejected.
- Every line-break tspan that sets x repeats the parent <text>'s x.
- No nested tspan inside any line carries x/y/dy.
- Adjacent lines with different effective font sizes start new paragraphs.
"""
base_x = parse_first_number(get_attr(text_el, "x"))
child_view = _build_paragraph_child_view(text_el, is_svg_tag)
@@ -358,6 +371,10 @@ def _classify_paragraph_block(
extras: list[float] = [0.0] # first line never has space-before
soft_breaks: list[bool] = [False] # first line starts a paragraph
line_font_sizes = [
_effective_line_font_size_px(text_el, group)
for group in line_groups
]
for idx, d in enumerate(dy_values[1:], start=1):
if d + DY_TOLERANCE_PX < base:
return None # below base — line overlap, not a paragraph
@@ -368,11 +385,12 @@ def _classify_paragraph_block(
extra = 0.0
# dy at the base line-height = soft break (SVG was simulating wrap);
# dy strictly greater than base = hard paragraph break. List markers
# also start a fresh paragraph so bullet/ordered items do not merge
# into the previous item when exported to PowerPoint.
# and font-size changes also start a fresh paragraph so semantically
# distinct visual lines do not merge into one PowerPoint line.
is_soft = (
abs(extra) <= DY_TOLERANCE_PX
and not _starts_with_list_marker(line_groups[idx])
and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6
)
extras.append(0.0 if is_soft else extra)
soft_breaks.append(is_soft)
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,12 @@ class ConvertContext:
defs: dict[str, ET.Element] = field(default_factory=dict)
id_counter: int = 2 # 1 is reserved for spTree root
# Imported PPTX shape ids are reserved before conversion so newly authored
# SVG elements cannot steal an id referenced by a native connector.
reserved_shape_ids: frozenset[int] = frozenset()
source_shape_id_map: dict[tuple[str, str], int] = field(default_factory=dict)
claimed_shape_ids: set[int] = field(default_factory=set)
referenced_shape_ids: set[int] = field(default_factory=set)
slide_num: int = 1
translate_x: float = 0.0
translate_y: float = 0.0
@@ -88,9 +94,49 @@ class ConvertContext:
def next_id(self) -> int:
"""Allocate the next shape ID."""
cid = self.id_counter
self.id_counter += 1
while cid in self.reserved_shape_ids or cid in self.claimed_shape_ids:
cid += 1
self.id_counter = cid + 1
self.claimed_shape_ids.add(cid)
return cid
def claim_shape_id(
self,
source_id: str | None,
source_scope: str | None = None,
) -> int:
"""Claim a pre-reserved imported shape id, or allocate a fresh one."""
if source_id is None:
return self.next_id()
scope = source_scope or 'slide'
key = (scope, source_id)
shape_id = self.source_shape_id_map.get(key)
if shape_id is None:
raise ValueError(
f'Unreserved data-pptx-shape-id {source_id!r} in scope {scope!r}'
)
if shape_id in self.claimed_shape_ids:
raise ValueError(
f'Duplicate data-pptx-shape-id {source_id!r} in scope {scope!r}'
)
self.claimed_shape_ids.add(shape_id)
return shape_id
def reference_shape_id(
self,
source_id: str,
source_scope: str | None = None,
) -> int:
"""Resolve and record a connector target in the imported id space."""
scope = source_scope or 'slide'
shape_id = self.source_shape_id_map.get((scope, source_id))
if shape_id is None:
raise ValueError(
f'Unknown connector shape reference {source_id!r} in scope {scope!r}'
)
self.referenced_shape_ids.add(shape_id)
return shape_id
def next_rel_id(self) -> str:
"""Allocate the next relationship ID (rIdN)."""
rid = f'rId{self.rel_id_counter}'
@@ -155,6 +201,10 @@ class ConvertContext:
return ConvertContext(
defs=self.defs,
id_counter=self.id_counter,
reserved_shape_ids=self.reserved_shape_ids,
source_shape_id_map=self.source_shape_id_map,
claimed_shape_ids=self.claimed_shape_ids,
referenced_shape_ids=self.referenced_shape_ids,
slide_num=self.slide_num,
translate_x=self.translate_x + dx,
translate_y=self.translate_y + dy,
@@ -2,12 +2,28 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import math
import re
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from pptx_shapes import (
has_relationship_attributes,
resolve_preset_preview_hash,
svg_preset_preview_fingerprint,
svg_text_fingerprint,
validate_ooxml_xfrm,
)
from pptx_to_svg.preset_authoring import (
AUTHORING_ATTR,
AUTHORING_VALUE,
validate_authored_preset_group,
validate_authored_preset_tree,
)
from resource_paths import icon_search_dirs_for_svg
from .context import ConvertContext, ShapeResult
@@ -29,7 +45,11 @@ from .elements import (
convert_text, convert_image, convert_nested_svg,
)
from ..animation_config import is_chrome_id
from ..native_objects import convert_native_object, native_marker_transform
from ..native_objects import (
convert_native_object,
native_marker_transform,
snapshot_native_fallback_freshness,
)
from ..semantic_markers import is_static_page_frame
@@ -128,6 +148,125 @@ def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None:
return cx, cy
def _txbody_metadata(elem: ET.Element) -> ET.Element | None:
for child in elem:
if (
child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata'
and child.get('data-pptx-part') == 'txbody'
):
return child
return None
_TXBODY_UNCHANGED_ATTR = 'data-pptx-runtime-txbody-unchanged'
_PREVIEW_UNCHANGED_ATTR = 'data-pptx-runtime-preview-unchanged'
def _mark_unchanged_txbody_groups(root: ET.Element) -> None:
"""Snapshot author-visible text state before exporter preprocessing."""
for group in root.iter():
if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g':
continue
metadata = _txbody_metadata(group)
if metadata is None:
continue
expected = metadata.get('data-pptx-text-sha256')
actual = svg_text_fingerprint(group)
group.set(_TXBODY_UNCHANGED_ATTR, '1' if expected == actual else '0')
def _mark_unchanged_preset_previews(root: ET.Element) -> None:
"""Snapshot visible preset layers before exporter preprocessing."""
for group in root.iter():
if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g':
continue
if (
group.get('data-pptx-object') not in {'shape', 'connector'}
or group.get('data-pptx-prst') is None
):
continue
try:
expected = resolve_preset_preview_hash(group)
except ValueError as exc:
raise SvgNativeConversionError(
f'Invalid preset preview fingerprint contract: {exc}'
) from exc
if expected is None:
continue
actual = svg_preset_preview_fingerprint(group)
group.set(_PREVIEW_UNCHANGED_ATTR, '1' if expected == actual else '0')
def _require_unchanged_preset_preview(group: ET.Element) -> None:
try:
expected = resolve_preset_preview_hash(group)
except ValueError as exc:
raise SvgNativeConversionError(
f'Invalid preset preview fingerprint contract: {exc}'
) from exc
if expected is None:
return
snapshot = group.get(_PREVIEW_UNCHANGED_ATTR)
if snapshot == '1':
return
if snapshot is None and svg_preset_preview_fingerprint(group) == expected:
return
raise SvgNativeConversionError(
'Visible preset preview was edited without updating its native '
'data-pptx-prst/frame/adjustment carrier; export stopped to avoid '
'silently discarding the SVG edit'
)
def _decode_unchanged_txbody(
group: ET.Element,
metadata: ET.Element,
) -> str | None:
expected_hash = metadata.get('data-pptx-text-sha256')
if not expected_hash:
raise SvgNativeConversionError('txbody metadata requires a text hash')
snapshot = group.get(_TXBODY_UNCHANGED_ATTR)
if snapshot == '0':
return None
if snapshot != '1' and svg_text_fingerprint(group) != expected_hash:
return None
if metadata.get('data-pptx-encoding') != 'base64':
raise SvgNativeConversionError('txbody metadata requires base64 encoding')
try:
raw = base64.b64decode((metadata.text or '').strip(), validate=True)
txbody = ET.fromstring(raw)
decoded = raw.decode('utf-8')
except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
raise SvgNativeConversionError(f'Invalid txbody metadata: {exc}') from exc
if txbody.tag != (
'{http://schemas.openxmlformats.org/presentationml/2006/main}txBody'
):
raise SvgNativeConversionError('txbody metadata payload must be p:txBody')
if has_relationship_attributes(txbody):
raise SvgNativeConversionError(
'txbody metadata must not contain part-local relationship attributes'
)
return decoded
def _append_shape_text(
shape: ShapeResult,
txbody_xml: str,
) -> ShapeResult:
if not shape.xml.lstrip().startswith('<p:sp>') or not shape.xml.rstrip().endswith('</p:sp>'):
raise SvgNativeConversionError('Native txBody can only attach to p:sp')
closing = shape.xml.rfind('</p:sp>')
return ShapeResult(
xml=(
shape.xml[:closing]
+ txbody_xml
+ '\n'
+ shape.xml[closing:]
),
bounds_emu=shape.bounds_emu,
)
# ---------------------------------------------------------------------------
# Group handling
# ---------------------------------------------------------------------------
@@ -240,6 +379,71 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return native_result
if (
elem.get('data-pptx-object') in {'shape', 'connector'}
and elem.get('data-pptx-prst') is not None
):
if elem.get(AUTHORING_ATTR) == AUTHORING_VALUE:
authoring_errors = validate_authored_preset_group(elem)
if authoring_errors:
raise SvgNativeConversionError(
'Invalid authored preset shape: '
+ '; '.join(authoring_errors)
)
_require_unchanged_preset_preview(elem)
txbody_meta = _txbody_metadata(elem)
logical_text_shape = (
elem.get('data-pptx-object') == 'shape'
and (
elem.get('data-pptx-prst') is not None
or elem.get('data-pptx-geometry-kind') == 'custom'
)
and txbody_meta is not None
)
if logical_text_shape:
carrier_children = [
child for child in elem
if child.get('data-pptx-part') == 'geometry'
]
allowed_parts = {
'geometry',
'geometry-detail',
'geometry-preview',
'txbody',
}
has_foreign_visual = any(
child.tag.replace(f'{{{SVG_NS}}}', '') not in {'text', 'metadata'}
and child.get('data-pptx-part') not in allowed_parts
for child in elem
)
native_text = _decode_unchanged_txbody(elem, txbody_meta)
if len(carrier_children) == 1 and not has_foreign_visual and native_text is not None:
geometry_ctx = child_ctx
if transform and not native_subtree_active:
geometry_ctx = ctx.child(
0, 0, 1.0, 1.0,
transform_matrix=parse_transform_matrix(transform),
filter_id=filter_id,
style_overrides=style_overrides,
opacity_multiplier=local_opacity,
)
geometry_result = convert_element(carrier_children[0], geometry_ctx)
ctx.sync_from_child(geometry_ctx)
if geometry_result is None:
raise SvgNativeConversionError(
'Logical text shape has no convertible geometry carrier'
)
restored = _append_shape_text(
geometry_result,
native_text,
)
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return restored
child_results: list[ShapeResult] = []
for child in elem:
result = convert_element(child, child_ctx)
@@ -251,10 +455,28 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
if not child_results:
return None
# Single-child non-semantic groups are flattened to reduce nesting. Top-level
# semantic groups are preserved so animations target the group, not its
# individual child shapes.
if len(child_results) == 1 and not should_animate_group:
# A logical imported preset may contain several render-only SVG detail
# paths, but after those are skipped it owns exactly one native object.
# Flatten that carrier even at the top level; otherwise animation grouping
# would turn one source ``p:sp`` into a ``p:grpSp`` wrapper. Retarget an
# optional animation to the restored leaf shape ID.
logical_native_shape_group = (
elem.get('data-pptx-object') in {'shape', 'connector'}
and (
elem.get('data-pptx-prst') is not None
or elem.get('data-pptx-geometry-kind') == 'custom'
)
)
explicit_native_group = elem.get('data-pptx-object') == 'group'
if (
len(child_results) == 1
and not explicit_native_group
and (not should_animate_group or logical_native_shape_group)
):
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', child_results[0].xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return child_results[0]
# Multiple children, or a top-level semantic one-child group: wrap in
@@ -305,7 +527,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
off_y = int(round(group_y + delta_y))
shapes_xml = '\n'.join(result.xml for result in child_results)
group_id = ctx.next_id()
group_id = (
ctx.claim_shape_id(
elem.get('data-pptx-shape-id'),
elem.get('data-pptx-shape-scope'),
)
if elem.get('data-pptx-object') == 'group'
else ctx.next_id()
)
# Record top-level semantic groups (e.g. <g id="p02-title">) so the
# PPTX builder can emit per-element entrance timing. Only the outermost
@@ -324,6 +553,8 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
rot_emu = 0 if matrix_supported else int(angle_deg * 60000)
rot_attr = f' rot="{rot_emu}"' if rot_emu else ''
validate_ooxml_xfrm(off_x, off_y, group_w, group_h)
validate_ooxml_xfrm(group_x, group_y, group_w, group_h)
return ShapeResult(xml=f'''<p:grpSp>
<p:nvGrpSpPr>
@@ -413,6 +644,16 @@ def _is_full_canvas_rect(
"""Return whether a rect is a safe candidate for native slide background."""
if elem.get('transform') or elem.get('filter') or elem.get('clip-path'):
return False
if any(
elem.get(attr) is not None
for attr in (
'data-pptx-object',
'data-pptx-prst',
'data-pptx-frame',
'data-pptx-geometry-status',
)
):
return False
if _f(elem.get('rx')) > 0 or _f(elem.get('ry')) > 0:
return False
@@ -529,6 +770,115 @@ def collect_defs(root: ET.Element) -> dict[str, ET.Element]:
return defs
def _build_source_shape_id_map(root: ET.Element) -> dict[tuple[str, str], int]:
"""Allocate page-unique ids for part-scoped imported shape identities."""
source_entries: list[tuple[tuple[str, str], int]] = []
seen_keys: set[tuple[str, str]] = set()
for elem in root.iter():
raw_id = elem.get('data-pptx-shape-id')
if raw_id is None:
continue
scope = elem.get('data-pptx-shape-scope') or 'slide'
if re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None:
raise SvgNativeConversionError(
f'Invalid data-pptx-shape-scope {scope!r}'
)
try:
shape_id = int(raw_id)
except ValueError as exc:
raise SvgNativeConversionError(
f'Invalid data-pptx-shape-id {raw_id!r}'
) from exc
if shape_id < 2 or shape_id > 0xFFFFFFFF:
raise SvgNativeConversionError(
f'data-pptx-shape-id must be between 2 and 4294967295, got {raw_id!r}'
)
key = (scope, raw_id)
if key in seen_keys:
continue
seen_keys.add(key)
source_entries.append((key, shape_id))
preferred_ids = {shape_id for _key, shape_id in source_entries}
next_fresh = max(preferred_ids, default=1) + 1
used: set[int] = set()
mapping: dict[tuple[str, str], int] = {}
for key, preferred in source_entries:
output_id = preferred
if output_id in used:
while next_fresh in preferred_ids or next_fresh in used:
next_fresh += 1
if next_fresh > 0xFFFFFFFF:
raise SvgNativeConversionError('Exhausted PowerPoint shape id range')
output_id = next_fresh
next_fresh += 1
used.add(output_id)
mapping[key] = output_id
return mapping
def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str, Any]:
"""Describe the native geometry decision for conversion diagnostics."""
xml = result.xml.lstrip()
if xml.startswith('<p:grpSp>'):
return {'output_geometry': 'group', 'fidelity': 'visual-only'}
if xml.startswith('<p:pic>'):
return {'output_geometry': 'picture', 'fidelity': 'native-normalized'}
if xml.startswith('<p:graphicFrame>'):
return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'}
preset_match = re.search(r'<a:prstGeom prst="([^"]+)"', xml)
if preset_match is not None:
preset = preset_match.group(1)
source_preset = elem.get('data-pptx-prst')
is_connector = xml.startswith('<p:cxnSp>')
fidelity = (
'exact'
if source_preset == preset
and elem.get('data-pptx-frame') is not None
and not is_connector
else 'native-normalized'
)
return {
'output_geometry': 'preset',
'preset': preset,
'fidelity': fidelity,
}
if re.search(r'<a:custGeom(?:\s|>)', xml):
carrier = next(
(
candidate
for candidate in elem.iter()
if candidate.get('data-pptx-part') == 'geometry'
),
elem,
)
source_custom = (
carrier.get('data-pptx-geometry-kind') == 'custom'
and carrier.get('data-pptx-frame') is not None
)
expected_hash = carrier.get('data-pptx-geometry-sha256')
actual_hash = hashlib.sha256(
(carrier.get('d') or '').strip().encode('utf-8')
).hexdigest()
unchanged = source_custom and expected_hash == actual_hash
if unchanged:
fidelity = 'exact'
geometry_source = 'preserved-metadata'
elif source_custom:
fidelity = 'native-normalized'
geometry_source = 'svg-recompiled'
else:
fidelity = 'visual-only'
geometry_source = 'svg-authored'
return {
'output_geometry': 'custom',
'fidelity': fidelity,
'geometry_source': geometry_source,
}
return {'output_geometry': 'unknown', 'fidelity': 'visual-only'}
def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Dispatch an SVG element to the appropriate converter."""
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
@@ -545,6 +895,13 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
event['id'] = elem_id
for attr in (
'data-pptx-layer',
'data-pptx-object',
'data-pptx-shape-id',
'data-pptx-frame',
'data-pptx-prst',
'data-pptx-part',
'data-pptx-geometry-status',
'data-pptx-geometry-reason',
'data-pptx-placeholder',
'data-pptx-placeholder-bounds',
'data-pptx-placeholder-idx',
@@ -553,9 +910,20 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
value = elem.get(attr)
if value is not None:
event[attr] = value
adjustments = {
attr[len('data-pptx-av-'):]: value
for attr, value in elem.attrib.items()
if attr.startswith('data-pptx-av-')
}
if adjustments:
event['adjustments'] = dict(sorted(adjustments.items()))
event.update(metadata)
ctx.trace_events.append(event)
if elem.get('data-pptx-part') == 'geometry-detail':
trace('skip', reason='render-only-preset-geometry-detail')
return None
converter = _CONVERTERS.get(tag)
if converter:
try:
@@ -570,6 +938,7 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
metadata['shape_id'] = int(shape_match.group(1))
if result.bounds_emu is not None:
metadata['bounds_emu'] = list(result.bounds_emu)
metadata.update(_geometry_trace_metadata(elem, result))
trace('native', **metadata)
else:
trace('skip', reason='empty-or-non-rendering')
@@ -701,6 +1070,20 @@ def convert_svg_to_slide_shapes(
"""
tree = ET.parse(str(svg_path))
root = tree.getroot()
if root.get('transform'):
raise SvgNativeConversionError(
'Root <svg> transform is unsupported; apply transforms to child '
'elements or groups'
)
authored_errors = validate_authored_preset_tree(root)
if authored_errors:
raise SvgNativeConversionError(
'Invalid authored preset structure: ' + '; '.join(authored_errors)
)
_mark_unchanged_txbody_groups(root)
_mark_unchanged_preset_previews(root)
if native_objects:
snapshot_native_fallback_freshness(root)
trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
trace_steps: list[dict[str, Any]] = []
@@ -806,8 +1189,11 @@ def convert_svg_to_slide_shapes(
)
defs = collect_defs(root)
source_shape_id_map = _build_source_shape_id_map(root)
ctx = ConvertContext(
defs=defs,
reserved_shape_ids=frozenset(source_shape_id_map.values()),
source_shape_id_map=source_shape_id_map,
slide_num=slide_num,
viewport_width=viewport_width,
viewport_height=viewport_height,
@@ -884,6 +1270,15 @@ def convert_svg_to_slide_shapes(
if tag not in _NON_VISUAL_TAGS:
skipped += 1
unresolved_connector_targets = sorted(
ctx.referenced_shape_ids - ctx.claimed_shape_ids
)
if unresolved_connector_targets:
raise SvgNativeConversionError(
'Connector target shape ids were reserved but not restored: '
+ ', '.join(str(shape_id) for shape_id in unresolved_connector_targets)
)
# Animation target fallback. Semantic <g id="..."> groups are the
# preferred anchors (set inside convert_g). When the SVG has none
# at the root we fall back to top-level primitives, but only when
@@ -6,13 +6,15 @@ import math
import re
from xml.etree import ElementTree as ET
from pptx_shapes import validate_ooxml_line_width
from .context import ConvertContext
from .theme_colors import ThemeColorSpec, color_node_xml
from .utils import (
SVG_NS, ANGLE_UNIT, DASH_PRESETS,
px_to_emu, _f, _get_attr, parse_svg_length,
combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
parse_svg_color, resolve_url_id,
matrix_multiply, parse_svg_color, parse_transform_matrix, resolve_url_id,
)
@@ -442,6 +444,33 @@ def _emit_line_end(
return f'<a:{dml_tag} type="{typ}" w="{w_bucket}" len="{len_bucket}"/>'
def _effective_stroke_scale(elem: ET.Element, ctx: ConvertContext) -> float:
"""Approximate the effective SVG geometry transform as one line-width scale."""
vector_effect = _get_attr(elem, 'vector-effect', ctx)
if vector_effect and vector_effect.strip().lower() == 'non-scaling-stroke':
return 1.0
if ctx.use_transform_matrix:
matrix = ctx.transform_matrix
else:
matrix = (
ctx.scale_x, 0.0,
0.0, ctx.scale_y,
ctx.translate_x, ctx.translate_y,
)
# The context already contains ancestor transforms. Shape converters apply
# the leaf element's transform directly, so compose that local matrix once.
transform = elem.get('transform')
if transform:
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
# DrawingML has one line width. sqrt(|det|) equals the uniform scale for a
# similarity transform and the principal-scale geometric mean otherwise.
a, b, c, d, _e, _f = matrix
return math.sqrt(abs(a * d - b * c))
def build_stroke_xml(
elem: ET.Element,
ctx: ConvertContext,
@@ -452,8 +481,9 @@ def build_stroke_xml(
if not stroke or stroke.strip().lower() in ('none', 'transparent'):
return '<a:ln><a:noFill/></a:ln>'
width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
width_emu = px_to_emu(width)
source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx))
validate_ooxml_line_width(width_emu)
# Dash pattern
dash_xml = ''
@@ -468,7 +498,7 @@ def build_stroke_xml(
parts = re.split(r'[\s,]+', dasharray.strip())
d_raw = float(parts[0])
sp_raw = float(parts[1]) if len(parts) > 1 else d_raw
sw = max(width, 0.001)
sw = max(source_width, 0.001)
d_pct = int(d_raw / sw * 100000)
sp_pct = int(sp_raw / sw * 100000)
dash_xml = f'<a:custDash><a:ds d="{d_pct}" sp="{sp_pct}"/></a:custDash>'
@@ -125,17 +125,17 @@ def _font_size_hpt(raw: str, field: str) -> int:
def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec:
"""Load required title/body defaults for template slide-master txStyles."""
"""Load required title/body defaults for explicit Layout master txStyles."""
lock_path = project_path / "spec_lock.md"
if not lock_path.is_file():
raise ThemeFontError(
"template mode requires spec_lock.md typography title and body rows"
"explicit Layout export requires spec_lock.md typography title and body rows"
)
rows = _typography_rows(lock_path)
missing = [field for field in ("title", "body") if field not in rows]
if missing:
raise ThemeFontError(
"template mode requires spec_lock.md typography rows: "
"explicit Layout export requires spec_lock.md typography rows: "
+ ", ".join(missing)
)
return MasterTextStyleSpec(
@@ -7,6 +7,8 @@ import math
import re
from xml.etree import ElementTree as ET
from pptx_shapes import validate_ooxml_xfrm
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
# ---------------------------------------------------------------------------
@@ -264,31 +266,68 @@ def _rotate_matrix(angle_deg: float, cx: float | None = None, cy: float | None =
)
def _skew_matrix(angle_deg: float, *, x_axis: bool) -> AffineMatrix:
radians = math.radians(angle_deg)
if abs(math.cos(radians)) <= 1e-12:
raise ValueError(f'Invalid SVG skew angle {angle_deg!r}')
tangent = math.tan(radians)
if x_axis:
return (1.0, 0.0, tangent, 1.0, 0.0, 0.0)
return (1.0, tangent, 0.0, 1.0, 0.0, 0.0)
def parse_transform_matrix(transform_str: str) -> AffineMatrix:
"""Parse an SVG transform list into one affine matrix."""
"""Parse a complete SVG transform list into one affine matrix.
Unsupported or malformed operations fail closed. Treating an unknown
operation as the identity would silently discard a visible SVG edit.
"""
if not transform_str:
return IDENTITY_MATRIX
matrix = IDENTITY_MATRIX
for name, raw_args in _TRANSFORM_RE.findall(transform_str):
cursor = 0
matched = False
for match in _TRANSFORM_RE.finditer(transform_str):
if re.fullmatch(r'[\s,]*', transform_str[cursor:match.start()]) is None:
raise ValueError(f'Invalid SVG transform syntax {transform_str!r}')
matched = True
name, raw_args = match.groups()
if re.fullmatch(r'(?:[\s,]*' + _NUMBER_RE.pattern + r')*[\s,]*', raw_args) is None:
raise ValueError(f'Invalid arguments for SVG transform {name!r}')
args = [float(n) for n in _NUMBER_RE.findall(raw_args)]
if not all(math.isfinite(value) for value in args):
raise ValueError(f'Non-finite arguments for SVG transform {name!r}')
name = name.lower()
local = IDENTITY_MATRIX
if name == 'matrix' and len(args) >= 6:
if name == 'matrix' and len(args) == 6:
local = (args[0], args[1], args[2], args[3], args[4], args[5])
elif name == 'translate' and args:
elif name == 'translate' and len(args) in {1, 2}:
local = _translate_matrix(args[0], args[1] if len(args) > 1 else 0.0)
elif name == 'scale' and args:
elif name == 'scale' and len(args) in {1, 2}:
local = _scale_matrix(args[0], args[1] if len(args) > 1 else None)
elif name == 'rotate' and args:
elif name == 'rotate' and len(args) in {1, 3}:
local = _rotate_matrix(
args[0],
args[1] if len(args) > 2 else None,
args[2] if len(args) > 2 else None,
)
elif name == 'skewx' and len(args) == 1:
local = _skew_matrix(args[0], x_axis=True)
elif name == 'skewy' and len(args) == 1:
local = _skew_matrix(args[0], x_axis=False)
else:
raise ValueError(
f'Unsupported or malformed SVG transform {match.group(0)!r}'
)
matrix = matrix_multiply(matrix, local)
cursor = match.end()
if (
not matched
or re.fullmatch(r'[\s,]*', transform_str[cursor:]) is None
):
raise ValueError(f'Invalid SVG transform syntax {transform_str!r}')
return matrix
@@ -299,12 +338,32 @@ def transform_point(matrix: AffineMatrix, x: float, y: float) -> tuple[float, fl
return a * x + c * y + e, b * x + d * y + f
def validate_dml_shape_matrix(matrix: AffineMatrix) -> None:
"""Reject affine shear that a DrawingML shape transform cannot express."""
a, b, c, d, _e, _f = matrix
x_length = math.hypot(a, b)
y_length = math.hypot(c, d)
if x_length <= 1e-12 or y_length <= 1e-12:
raise ValueError(
'SVG zero-scale transform cannot be represented by a visible '
'DrawingML shape'
)
dot = a * c + b * d
if abs(dot) > x_length * y_length * 1e-9:
raise ValueError(
'SVG shear/skew cannot be represented by a DrawingML '
'shape transform'
)
def rect_to_dml_xfrm(
x: float,
y: float,
w: float,
h: float,
matrix: AffineMatrix,
*,
preserve_degenerate_axes: bool = False,
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
"""Map a transformed SVG rectangle to DrawingML xfrm attributes.
@@ -322,11 +381,18 @@ def rect_to_dml_xfrm(
vx = p3[0] - p0[0]
vy = p3[1] - p0[1]
rect_w = max(math.hypot(ux, uy), 0.001)
rect_h = max(math.hypot(vx, vy), 0.001)
rect_w = math.hypot(ux, uy)
rect_h = math.hypot(vx, vy)
validate_dml_shape_matrix(matrix)
if not preserve_degenerate_axes:
rect_w = max(rect_w, 0.001)
rect_h = max(rect_h, 0.001)
cross = ux * vy - uy * vx
if cross < 0:
if rect_w <= 1e-12 and rect_h > 1e-12:
angle_deg = math.degrees(math.atan2(vy, vx)) - 90.0
flip_attr = ''
elif cross < 0:
angle_deg = math.degrees(math.atan2(-uy, -ux))
flip_attr = ' flipH="1"'
else:
@@ -342,6 +408,7 @@ def rect_to_dml_xfrm(
off_y = px_to_emu(center_y - rect_h / 2)
ext_cx = px_to_emu(rect_w)
ext_cy = px_to_emu(rect_h)
validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy)
xs = [p0[0], p1[0], p2[0], p3[0]]
ys = [p0[1], p1[1], p2[1], p3[1]]
@@ -27,6 +27,12 @@ from .chartex import (
_chart_ex_style_xml,
_chart_ex_xml,
)
from .fallback_hash import (
native_fallback_contract_warnings,
require_fresh_native_fallback,
snapshot_native_fallback_freshness,
stamp_native_fallback_baseline,
)
from .marker_common import (
CHART_CONTENT_TYPE,
CHARTEX_CONTENT_TYPE,
@@ -44,6 +50,7 @@ from .marker_common import (
_validate_bounds_inputs,
native_marker_transform,
)
from .marker_status import native_marker_status_errors
from .table import (
_build_native_table,
_native_table_warnings,
@@ -59,6 +66,8 @@ __all__ = [
"convert_native_object",
"native_object_marker_warnings",
"native_marker_transform",
"snapshot_native_fallback_freshness",
"stamp_native_fallback_baseline",
"validate_native_object_marker",
"validate_native_object_marker_with_warnings",
]
@@ -101,7 +110,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
colors_name,
)
ctx.package_files[style_part] = _chart_ex_style_xml()
ctx.package_files[colors_part] = _chart_ex_colors_xml()
ctx.package_files[colors_part] = _chart_ex_colors_xml(payload)
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
@@ -170,22 +179,28 @@ def _validate_native_object_marker_payload(
validate_chrome: bool = True,
ctx: ConvertContext | None = None,
ancestors: tuple[ET.Element, ...] = (),
require_fresh_fallback: bool = False,
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
kind = (elem.get("data-pptx-native") or "").strip().lower()
if not kind:
return "", {}, None
status_errors = native_marker_status_errors(elem)
if status_errors:
raise RuntimeError("; ".join(status_errors))
if kind not in _NATIVE_KINDS:
raise RuntimeError(f"Unsupported data-pptx-native value: {kind}")
if _local_tag(elem) != "g":
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
native_marker_transform(elem.get("transform"))
if require_fresh_fallback:
require_fresh_native_fallback(elem, use_runtime_snapshot=True)
payload = _load_payload(elem, kind)
bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors)
off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx)
table_rows = None
if kind == "table":
table_rows, col_count = _validate_table_payload(payload)
table_rows, col_count, _merge_layout = _validate_table_payload(payload)
if ext_cx < col_count or ext_cy < len(table_rows):
raise RuntimeError(
"Native PPTX table bounds must provide at least one EMU per row and column"
@@ -218,26 +233,39 @@ def validate_native_object_marker_with_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Validate a data-pptx-native marker and return non-fatal warnings."""
kind, payload, table_rows = _validate_native_object_marker_payload(
elem,
ancestors=ancestors,
)
warnings = (
native_fallback_contract_warnings(
elem,
document_root=document_root,
)
if kind else []
)
if kind == "table" and table_rows is not None:
return _native_table_warnings(elem, table_rows)
if kind == "chart":
return _native_chart_chrome_warnings(elem, payload)
return []
warnings.extend(_native_table_warnings(elem, table_rows))
elif kind == "chart":
warnings.extend(_native_chart_chrome_warnings(elem, payload))
return warnings
def native_object_marker_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Return non-fatal warnings for a data-pptx-native marker."""
return validate_native_object_marker_with_warnings(elem, ancestors=ancestors)
return validate_native_object_marker_with_warnings(
elem,
ancestors=ancestors,
document_root=document_root,
)
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
@@ -250,11 +278,20 @@ def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult
elem,
validate_chrome=False,
ctx=ctx,
require_fresh_fallback=True,
)
marker_id = elem.get("id") or "<unnamed>"
for warning in native_fallback_contract_warnings(
elem,
use_runtime_snapshot=True,
):
print(
f" Warning: data-pptx-native marker {marker_id}: {warning}",
file=sys.stderr,
)
if kind == "table":
return _build_native_table(elem, ctx, payload)
payload, warnings = _native_chart_export_payload(elem, payload)
marker_id = elem.get("id") or "<unnamed>"
for warning in warnings:
print(
f" Warning: data-pptx-native marker {marker_id}: {warning}",
@@ -222,6 +222,155 @@ _DEFAULT_CHART_COLORS = [
"9E480E",
]
_AXIS_ROLE_DEFAULTS = {
"category": ("text", "bottom"),
"secondary_category": ("text", "bottom"),
"secondary_value": ("value", "right"),
"value": ("value", "left"),
"x": ("value", "bottom"),
"y": ("value", "left"),
}
def _chart_axes(
payload: dict[str, Any],
allowed_roles: set[str],
) -> dict[str, dict[str, Any]]:
"""Normalize the narrow classic-chart axis contract."""
raw_axes = payload.get("axes")
if raw_axes is None:
return {}
if not isinstance(raw_axes, dict):
raise RuntimeError("Native PPTX chart axes must be an object")
unknown_roles = set(raw_axes) - allowed_roles
if unknown_roles:
roles = ", ".join(sorted(unknown_roles))
raise RuntimeError(f"Native PPTX chart axes contains unsupported role(s): {roles}")
axes: dict[str, dict[str, Any]] = {}
for role, raw_config in raw_axes.items():
if not isinstance(raw_config, dict):
raise RuntimeError(f"Native PPTX chart axes.{role} must be an object")
allowed_fields = {
"kind", "label_position", "major_gridlines", "major_unit",
"maximum", "minimum", "number_format", "position", "reverse",
"visible",
}
unknown_fields = set(raw_config) - allowed_fields
if unknown_fields:
fields = ", ".join(sorted(unknown_fields))
raise RuntimeError(
f"Native PPTX chart axes.{role} contains unsupported field(s): {fields}"
)
default_kind, default_position = _AXIS_ROLE_DEFAULTS[role]
kind = _compact_key(raw_config.get("kind") or default_kind)
if kind not in {"date", "text", "value"}:
raise RuntimeError(
f"Native PPTX chart axes.{role}.kind must be date, text, or value"
)
if role in {"category", "secondary_category"} and kind not in {"date", "text"}:
raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be date or text")
if role in {"value", "secondary_value", "x", "y"} and kind != "value":
raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be value")
position_aliases = {
"b": "bottom",
"bottom": "bottom",
"l": "left",
"left": "left",
"r": "right",
"right": "right",
"t": "top",
"top": "top",
}
position = position_aliases.get(
_compact_key(raw_config.get("position") or default_position)
)
if position is None:
raise RuntimeError(
f"Native PPTX chart axes.{role}.position must be bottom, left, right, or top"
)
allowed_positions = (
{"bottom", "top"}
if role in {"category", "secondary_category", "x"}
else {"left", "right"}
)
if position not in allowed_positions:
choices = ", ".join(sorted(allowed_positions))
raise RuntimeError(
f"Native PPTX chart axes.{role}.position must be one of: {choices}"
)
config: dict[str, Any] = {"kind": kind, "position": position}
for field in ("visible", "reverse", "major_gridlines"):
value = raw_config.get(field)
if value is None:
continue
if not isinstance(value, bool):
raise RuntimeError(
f"Native PPTX chart axes.{role}.{field} must be a boolean"
)
config[field] = value
raw_label_position = raw_config.get("label_position")
if raw_label_position is not None:
label_aliases = {
"high": "high",
"low": "low",
"nextto": "next_to",
"none": "none",
}
label_position = label_aliases.get(_compact_key(raw_label_position))
if label_position is None:
raise RuntimeError(
f"Native PPTX chart axes.{role}.label_position must be one of: "
"high, low, next_to, none"
)
config["label_position"] = label_position
raw_number_format = raw_config.get("number_format")
if raw_number_format is not None:
if not isinstance(raw_number_format, str):
raise RuntimeError(
f"Native PPTX chart axes.{role}.number_format must be a string"
)
if not raw_number_format.strip():
raise RuntimeError(
f"Native PPTX chart axes.{role}.number_format must be non-empty"
)
config["number_format"] = raw_number_format
for field in ("minimum", "maximum", "major_unit"):
value = raw_config.get(field)
if value is None:
continue
number = _chart_number(value)
if field == "major_unit":
if role not in {"value", "secondary_value", "x", "y"}:
raise RuntimeError(
f"Native PPTX chart axes.{role}.major_unit is unsupported"
)
if number <= 0:
raise RuntimeError(
f"Native PPTX chart axes.{role}.major_unit must be positive"
)
config[field] = number
if (
config.get("minimum") is not None
and config.get("maximum") is not None
and config["minimum"] >= config["maximum"]
):
raise RuntimeError(
f"Native PPTX chart axes.{role}.minimum must be less than maximum"
)
axes[role] = config
return axes
def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool:
return axes.get("category", {}).get("kind") == "date"
def _chart_kind(payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
raw_type = payload.get("type") or payload.get("chart_type") or "column"
@@ -440,7 +589,7 @@ def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str,
return style
def _category_series(payload: dict[str, Any], categories: list[str]) -> list[dict[str, Any]]:
def _category_series(payload: dict[str, Any], categories: list[Any]) -> list[dict[str, Any]]:
raw_series = payload.get("series", [])
if not categories or not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX chart requires non-empty categories and series")
@@ -506,7 +655,21 @@ def _category_chart_data(
alias_grouping: str | None,
alias_style: str | None,
) -> dict[str, Any]:
categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")]
axes = _chart_axes(payload, {"category", "value"})
if axes and chart_type in {"bar", "doughnut", "of_pie", "pie"}:
raise RuntimeError(
f"Native PPTX {chart_type} chart axes are outside current support"
)
if _category_axis_is_date(axes) and chart_type != "area":
raise RuntimeError(
"Native PPTX date category axes are currently supported for area charts only"
)
raw_categories = _chart_list(payload.get("categories", []), "categories")
categories = (
[_chart_number(item) for item in raw_categories]
if _category_axis_is_date(axes)
else [str(item) for item in raw_categories]
)
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
series = _category_series(payload, categories)
@@ -571,6 +734,7 @@ def _category_chart_data(
grouping,
len(categories),
),
"axes": axes,
"series": series,
}
@@ -615,10 +779,33 @@ def _plot_series_area_style(plot_payload: dict[str, Any]) -> bool:
return False
def _combo_series_indices(
plot_payload: dict[str, Any],
series_count: int,
) -> list[int] | None:
raw_indices = plot_payload.get("series_indices")
if raw_indices is None:
return None
indices: list[int] = []
for value in _chart_list(raw_indices, "plots[].series_indices"):
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise RuntimeError(
"Native PPTX combo series_indices must contain non-negative integers"
)
indices.append(value)
if len(indices) != series_count or len(set(indices)) != len(indices):
raise RuntimeError(
"Native PPTX combo series_indices must be unique and match series length"
)
return indices
def _combo_plot_entry(
plot_payload: dict[str, Any],
categories: list[str],
categories: list[Any],
*,
category_is_numeric: bool,
axes: dict[str, dict[str, Any]],
fallback_series: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _combo_plot_type(plot_payload)
@@ -626,31 +813,82 @@ def _combo_plot_entry(
raise RuntimeError(
"Native PPTX combo line plot with series fill_opacity requires area_fill: true"
)
plot_series = fallback_series or _category_series(plot_payload, categories)
axis = _combo_axis_name(plot_payload)
category_role = "secondary_category" if axis == "secondary" else "category"
axis_is_date = axes.get(category_role, {}).get("kind") == "date"
raw_numeric = plot_payload.get("category_numeric")
if raw_numeric is not None and not isinstance(raw_numeric, bool):
raise RuntimeError(
"Native PPTX combo plot category_numeric must be a boolean"
)
if axis_is_date and raw_numeric is False:
raise RuntimeError(
"Native PPTX combo date-axis categories must remain numeric"
)
plot_category_is_numeric = axis_is_date or (
raw_numeric if raw_numeric is not None else category_is_numeric
)
raw_plot_categories = plot_payload.get("categories")
category_items = (
categories
if raw_plot_categories is None
else _chart_list(raw_plot_categories, "plots[].categories")
)
plot_categories = (
[_chart_number(item) for item in category_items]
if plot_category_is_numeric
else [str(item) for item in category_items]
)
if not plot_categories:
raise RuntimeError("Native PPTX combo plot categories must be non-empty")
plot_series = fallback_series or _category_series(plot_payload, plot_categories)
grouping = (
_chart_grouping(chart_type, plot_payload, alias_grouping)
if chart_type in {"area", "column", "line"}
else None
)
entry: dict[str, Any] = {
"axis": _combo_axis_name(plot_payload),
"axis": axis,
"categories": plot_categories,
"category_is_numeric": plot_category_is_numeric,
"data_labels": _chart_data_labels(
plot_payload,
chart_type,
grouping,
len(categories),
len(plot_categories),
),
"grouping": grouping,
"series": plot_series,
"type": chart_type,
}
series_indices = _combo_series_indices(plot_payload, len(plot_series))
if series_indices is not None:
entry["series_indices"] = series_indices
if chart_type == "line":
entry["line_style"] = _line_style(plot_payload, alias_style)
return entry
def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")]
axes = _chart_axes(
payload,
{"category", "secondary_category", "secondary_value", "value"},
)
raw_category_numeric = payload.get("category_numeric")
if raw_category_numeric is not None and not isinstance(raw_category_numeric, bool):
raise RuntimeError("Native PPTX combo category_numeric must be a boolean")
primary_axis_is_date = _category_axis_is_date(axes)
if primary_axis_is_date and raw_category_numeric is False:
raise RuntimeError("Native PPTX combo date-axis categories must remain numeric")
category_is_numeric = primary_axis_is_date or raw_category_numeric is True
raw_categories = _chart_list(payload.get("categories", []), "categories")
categories = (
[_chart_number(item) for item in raw_categories]
if category_is_numeric
else [str(item) for item in raw_categories]
)
if not categories:
raise RuntimeError("Native PPTX combo chart categories must be non-empty")
raw_plots = payload.get("plots", payload.get("chart_plots"))
plots: list[dict[str, Any]] = []
@@ -658,7 +896,12 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
for item in _chart_list(raw_plots, "plots"):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX combo plots must be objects")
plots.append(_combo_plot_entry(item, categories))
plots.append(_combo_plot_entry(
item,
categories,
category_is_numeric=category_is_numeric,
axes=axes,
))
else:
raw_series = _chart_list(payload.get("series", []), "series")
if not raw_series:
@@ -668,8 +911,22 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
raise RuntimeError("Native PPTX chart series entries must be objects")
if not (item.get("type") or item.get("chart_type")):
raise RuntimeError("Native PPTX combo series entries require type")
if any(
field in item
for field in ("categories", "category_numeric", "series_indices")
):
raise RuntimeError(
"Native PPTX combo typed series with plot-scoped metadata "
"must use plots"
)
one_series = _category_series({"series": [item]}, categories)
plot = _combo_plot_entry(item, categories, fallback_series=one_series)
plot = _combo_plot_entry(
item,
categories,
category_is_numeric=category_is_numeric,
axes=axes,
fallback_series=one_series,
)
signature = (
plot["axis"],
plot.get("grouping"),
@@ -694,15 +951,52 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
if not plots:
raise RuntimeError("Native PPTX combo chart requires at least one plot")
if not any(plot["axis"] == "primary" for plot in plots):
raise RuntimeError("Native PPTX combo chart requires a primary-axis plot")
has_secondary_plot = any(plot["axis"] == "secondary" for plot in plots)
if not has_secondary_plot and {
"secondary_category", "secondary_value",
}.intersection(axes):
raise RuntimeError(
"Native PPTX combo secondary axes require a secondary-axis plot"
)
series_index_groups = [plot.get("series_indices") for plot in plots]
if any(group is not None for group in series_index_groups):
if any(group is None for group in series_index_groups):
raise RuntimeError(
"Native PPTX combo series_indices must cover every plot"
)
flat_indices = [
index
for group in series_index_groups
for index in group
]
if sorted(flat_indices) != list(range(len(flat_indices))):
raise RuntimeError(
"Native PPTX combo series_indices must form one contiguous range"
)
flat_series: list[dict[str, Any]] = []
independent_categories = any(
plot["categories"] != categories
or plot["category_is_numeric"] != category_is_numeric
for plot in plots
)
next_column = 1
for plot in plots:
plot["start_index"] = len(flat_series)
if independent_categories:
plot["category_column"] = next_column
plot["start_column"] = next_column + 1
next_column += len(plot["series"]) + 1
flat_series.extend(plot["series"])
if not flat_series:
raise RuntimeError("Native PPTX combo chart requires at least one series")
return {
"axes": axes,
"categories": categories,
"category_is_numeric": category_is_numeric,
"independent_categories": independent_categories,
"kind": "combo",
"plots": plots,
"series": flat_series,
@@ -866,6 +1160,9 @@ def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, A
def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
if _data_labels_config(payload) is not None:
raise RuntimeError("Native PPTX stock chart data labels are outside current support")
axes = _chart_axes(payload, {"category", "value"})
if "category" in axes and not _category_axis_is_date(axes):
raise RuntimeError("Native PPTX stock chart category axis must be date")
categories = [
_chart_number(item)
for item in _chart_list(payload.get("categories", payload.get("dates", [])), "categories")
@@ -884,6 +1181,7 @@ def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
if len(series) != 4:
raise RuntimeError("Native PPTX stock chart requires exactly four series: open, high, low, close")
return {
"axes": axes,
"categories": categories,
"kind": "category",
"series": series,
@@ -911,6 +1209,7 @@ def _xy_chart_data(
raise RuntimeError(
f"Native PPTX {chart_type} chart data labels are outside current support"
)
axes = _chart_axes(payload, {"x", "y"})
raw_series = payload.get("series", [])
if not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX XY chart requires non-empty series")
@@ -966,6 +1265,7 @@ def _xy_chart_data(
if chart_type == "scatter" and scatter_style not in style_aliases:
raise RuntimeError("Native PPTX scatter_style is unsupported")
return {
"axes": axes,
"kind": "xy",
"type": chart_type,
"scatter_style": style_aliases.get(scatter_style, "marker"),
@@ -8,6 +8,7 @@ from xml.etree import ElementTree as ET
from ..drawingml.utils import detect_text_lang, _xml_escape
from .chart_data import (
_DEFAULT_CHART_COLORS,
_category_axis_is_date,
_chart_list,
_data_label_position,
_data_label_point_items,
@@ -51,17 +52,42 @@ def _string_cache(values: list[str]) -> str:
return f'<c:strCache><c:ptCount val="{len(values)}"/>{points}</c:strCache>'
def _number_cache(values: list[int | float]) -> str:
def _number_cache(
values: list[int | float],
number_format: str = "General",
) -> str:
points = "".join(
f'<c:pt idx="{idx}"><c:v>{value}</c:v></c:pt>'
for idx, value in enumerate(values)
)
return (
'<c:numCache><c:formatCode>General</c:formatCode>'
f'<c:numCache><c:formatCode>{_xml_escape(number_format)}</c:formatCode>'
f'<c:ptCount val="{len(values)}"/>{points}</c:numCache>'
)
def _category_reference_xml(
categories: list[Any],
*,
column_index: int = 1,
numeric: bool,
number_format: str | None = None,
) -> str:
reference_tag = "numRef" if numeric else "strRef"
cache = (
_number_cache(categories, number_format or "General")
if numeric
else _string_cache([str(value) for value in categories])
)
return (
f"<c:cat><c:{reference_tag}>"
f"<c:f>Sheet1!${_excel_col(column_index)}$2:"
f"${_excel_col(column_index)}${len(categories) + 1}</c:f>"
f"{cache}"
f"</c:{reference_tag}></c:cat>"
)
def _series_color_xml(
color: str | None,
*,
@@ -273,7 +299,7 @@ def _marker_xml(symbol: str | None) -> str:
def _series_xml(
categories: list[str],
categories: list[Any],
series: list[dict[str, Any]],
*,
chart_type: str,
@@ -282,21 +308,41 @@ def _series_xml(
radar_marker_style: str | None = None,
radar_style: str = "marker",
colors: list[str],
category_is_numeric: bool = False,
category_number_format: str | None = None,
data_labels: dict[str, Any] | None = None,
data_label_font_size: int = 900,
data_label_color: str | None = None,
data_label_font_face: str | None = None,
category_column: int = 1,
color_start_index: int | None = None,
series_indices: list[int] | None = None,
start_column: int = 2,
start_index: int = 0,
) -> str:
parts: list[str] = []
category_xml = _category_reference_xml(
categories,
column_index=category_column,
numeric=category_is_numeric,
number_format=category_number_format,
)
for offset, item in enumerate(series):
index = start_index + offset
index = (
series_indices[offset]
if series_indices is not None
else start_index + offset
)
color_index = (
color_start_index
if color_start_index is not None
else start_index
) + offset
column_index = offset + start_column
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
color_xml = _series_color_xml(
_chart_color(colors, index),
_chart_color(colors, color_index),
fill_opacity=fill_opacity,
line_width=line_width,
)
@@ -325,7 +371,10 @@ def _series_xml(
smooth_xml = '<c:smooth val="0"/>'
if chart_type == "radar":
if radar_style == "filled":
color_xml = _series_color_xml(_chart_color(colors, index), line=False)
color_xml = _series_color_xml(
_chart_color(colors, color_index),
line=False,
)
marker_xml = _marker_xml(radar_marker_style)
invert_xml = '<c:invertIfNegative val="0"/>' if chart_type in {"bar", "column"} else ""
data_labels_xml = (
@@ -351,10 +400,7 @@ def _series_xml(
"</c:strRef></c:tx>"
f"{color_xml}{invert_xml}{marker_xml}{point_colors_xml}"
f"{data_labels_xml}"
"<c:cat><c:strRef>"
f"<c:f>Sheet1!$A$2:$A${len(categories) + 1}</c:f>"
f"{_string_cache(categories)}"
"</c:strRef></c:cat>"
f"{category_xml}"
"<c:val><c:numRef>"
f"<c:f>Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}</c:f>"
f"{_number_cache(item['values'])}"
@@ -573,6 +619,165 @@ def _line_area_chart_group_xml(
)
def _axis_scaling_xml(config: dict[str, Any]) -> str:
orientation = "maxMin" if config.get("reverse") else "minMax"
maximum = (
f'<c:max val="{config["maximum"]}"/>'
if config.get("maximum") is not None else ""
)
minimum = (
f'<c:min val="{config["minimum"]}"/>'
if config.get("minimum") is not None else ""
)
return f'<c:scaling><c:orientation val="{orientation}"/>{maximum}{minimum}</c:scaling>'
def _axis_position(config: dict[str, Any], default: str) -> str:
return {
"bottom": "b",
"left": "l",
"right": "r",
"top": "t",
}.get(str(config.get("position") or ""), default)
def _axis_label_position(config: dict[str, Any], default: str) -> str:
return {
"high": "high",
"low": "low",
"next_to": "nextTo",
"none": "none",
}.get(str(config.get("label_position") or ""), default)
def _axis_number_format_xml(
config: dict[str, Any],
default: str | None = None,
) -> str:
number_format = config.get("number_format", default)
if number_format is None:
return ""
return f'<c:numFmt formatCode="{_xml_escape(str(number_format))}" sourceLinked="0"/>'
def _axis_major_gridlines_xml(
config: dict[str, Any],
*,
default: bool,
color: str | None,
) -> str:
enabled = config.get("major_gridlines", default)
return _major_gridlines_xml(color) if enabled else ""
def _axis_pair_xml(
cat_ax_id: str,
val_ax_id: str,
*,
axis_font_size: int,
axis_title_font_size: int,
axis_titles: dict[str, Any],
chart_style: dict[str, str | None],
chart_type: str,
grouping: str | None,
show_value_axis_labels: bool,
axes: dict[str, dict[str, Any]],
secondary: bool,
) -> str:
category_role = "secondary_category" if secondary else "category"
value_role = "secondary_value" if secondary else "value"
category = axes.get(category_role, {})
value = axes.get(value_role, {})
category_kind = str(category.get("kind") or ("date" if chart_type == "stock" else "text"))
category_tag = "dateAx" if category_kind == "date" else "catAx"
default_cat_pos = "l" if chart_type == "bar" else "b"
default_val_pos = "r" if secondary else ("b" if chart_type == "bar" else "l")
cat_pos = _axis_position(category, default_cat_pos)
val_pos = _axis_position(value, default_val_pos)
cat_delete = _bool_attr(not category.get("visible", not secondary))
val_delete = _bool_attr(not value.get("visible", True))
cat_tick_label_pos = _axis_label_position(category, "nextTo")
default_val_tick = "nextTo" if show_value_axis_labels else "none"
val_tick_label_pos = _axis_label_position(value, default_val_tick)
default_value_format = "0%" if grouping == "percentStacked" else None
cat_number_format = _axis_number_format_xml(
category,
"m/d/yyyy" if category_kind == "date" else None,
)
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_tx_pr = _chart_tx_pr_xml(
axis_font_size,
chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
cat_title_xml = "" if secondary else _axis_title_xml(
_first_present(axis_titles.get("category"), axis_titles.get("x")),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
value_title_key = "secondary_value" if secondary else "value"
value_title = axis_titles.get(value_title_key)
if not secondary:
value_title = _first_present(value_title, axis_titles.get("y"))
val_title_xml = _axis_title_xml(
value_title,
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
cat_gridlines = _axis_major_gridlines_xml(
category,
default=False,
color=chart_style.get("grid_color"),
)
val_gridlines = _axis_major_gridlines_xml(
value,
default=not secondary,
color=chart_style.get("grid_color"),
)
if category_kind == "date":
category_tail = '<c:auto val="1"/><c:lblOffset val="100"/><c:baseTimeUnit val="days"/>'
else:
category_tail = (
'<c:auto val="1"/><c:lblAlgn val="ctr"/><c:lblOffset val="100"/>'
'<c:noMultiLvlLbl val="0"/>'
)
is_combo = chart_type == "combo"
cross_between = ""
if chart_type == "area" and category_kind == "date":
cross_between = '<c:crossBetween val="midCat"/>'
elif chart_type == "stock" or is_combo:
cross_between = '<c:crossBetween val="between"/>'
major_unit = (
f'<c:majorUnit val="{value["major_unit"]}"/>'
if value.get("major_unit") is not None else ""
)
value_crosses = "max" if secondary else "autoZero"
return (
f"<c:{category_tag}>"
f'<c:axId val="{cat_ax_id}"/>{_axis_scaling_xml(category)}'
f'<c:delete val="{cat_delete}"/><c:axPos val="{cat_pos}"/>'
f"{cat_gridlines}{cat_title_xml}{cat_number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{cat_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/>{category_tail}'
f"</c:{category_tag}>"
"<c:valAx>"
f'<c:axId val="{val_ax_id}"/>{_axis_scaling_xml(value)}'
f'<c:delete val="{val_delete}"/><c:axPos val="{val_pos}"/>'
f"{val_gridlines}{val_title_xml}{val_number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{val_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="{value_crosses}"/>'
f"{cross_between}{major_unit}"
"</c:valAx>"
)
def _secondary_axis_xml(
cat_ax_id: str,
val_ax_id: str,
@@ -582,42 +787,20 @@ def _secondary_axis_xml(
axis_titles: dict[str, Any],
chart_style: dict[str, str | None],
grouping: str | None = None,
axes: dict[str, dict[str, Any]] | None = None,
) -> str:
val_num_fmt = (
'<c:numFmt formatCode="0%" sourceLinked="0"/>'
if grouping == "percentStacked"
else ""
)
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
axis_tx_pr = _chart_tx_pr_xml(
axis_font_size,
chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
val_title_xml = _axis_title_xml(
axis_titles.get("secondary_value"),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
# Hidden secondary category axis controls secondary area fill baseline.
return (
"<c:catAx>"
f'<c:axId val="{cat_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
'<c:delete val="1"/><c:axPos val="b"/><c:majorTickMark val="none"/>'
'<c:minorTickMark val="none"/><c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/><c:auto val="1"/>'
'<c:lblAlgn val="ctr"/><c:lblOffset val="100"/><c:noMultiLvlLbl val="0"/>'
"</c:catAx>"
"<c:valAx>"
f'<c:axId val="{val_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="r"/>{val_title_xml}{val_num_fmt}'
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
'<c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="max"/>'
"</c:valAx>"
return _axis_pair_xml(
cat_ax_id,
val_ax_id,
axis_font_size=axis_font_size,
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
chart_type="combo",
grouping=grouping,
show_value_axis_labels=True,
axes=axes or {},
secondary=True,
)
@@ -645,7 +828,7 @@ def _combo_plot_xml(
axis_titles: dict[str, Any],
chart_style: dict[str, str | None],
) -> str:
categories = chart_data["categories"]
axes = chart_data.get("axes") or {}
primary_cat_ax_id = "2068027336"
primary_val_ax_id = "2113994440"
secondary_cat_ax_id = "2080229232"
@@ -653,10 +836,14 @@ def _combo_plot_xml(
parts: list[str] = []
for plot in sorted(chart_data["plots"], key=_combo_plot_layer):
categories = plot["categories"]
category_is_numeric = bool(plot.get("category_is_numeric"))
chart_type = plot["type"]
axis = plot.get("axis", "primary")
cat_ax_id = secondary_cat_ax_id if axis == "secondary" else primary_cat_ax_id
val_ax_id = secondary_val_ax_id if axis == "secondary" else primary_val_ax_id
category_role = "secondary_category" if axis == "secondary" else "category"
category_number_format = axes.get(category_role, {}).get("number_format")
start_index = int(plot.get("start_index", 0))
grouping = plot.get("grouping") or ("clustered" if chart_type == "column" else "standard")
ser_xml = _series_xml(
@@ -665,12 +852,17 @@ def _combo_plot_xml(
chart_type=chart_type,
grouping=grouping,
colors=colors,
category_is_numeric=category_is_numeric,
category_number_format=category_number_format,
data_labels=_data_labels_config(plot),
data_label_font_size=axis_font_size,
data_label_color=chart_style.get("text_color"),
data_label_font_face=chart_style.get("font_face"),
line_style=plot.get("line_style", "line"),
start_column=2 + start_index,
category_column=int(plot.get("category_column", 1)),
color_start_index=start_index,
series_indices=plot.get("series_indices"),
start_column=int(plot.get("start_column", 2 + start_index)),
start_index=start_index,
)
data_labels = _data_labels_config(plot)
@@ -717,8 +909,9 @@ def _combo_plot_xml(
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
chart_type="column",
chart_type="combo",
grouping=_combo_axis_grouping(chart_data["plots"], "primary"),
axes=axes,
)
if has_secondary_axis:
axes_xml += _secondary_axis_xml(
@@ -729,6 +922,7 @@ def _combo_plot_xml(
axis_titles=axis_titles,
chart_style=chart_style,
grouping=_combo_axis_grouping(chart_data["plots"], "secondary"),
axes=axes,
)
return "".join(parts) + axes_xml
@@ -772,6 +966,7 @@ def _chart_plot_xml(
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
axes=chart_data.get("axes") or {},
)
return (
f'<c:scatterChart><c:scatterStyle val="{scatter_style}"/>'
@@ -788,6 +983,7 @@ def _chart_plot_xml(
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
axes=chart_data.get("axes") or {},
)
return (
'<c:bubbleChart><c:varyColors val="0"/>'
@@ -803,6 +999,19 @@ def _chart_plot_xml(
if chart_type == "stock":
stock_cat_ax_id = "2068027336"
stock_val_ax_id = "2113994440"
stock_axes = chart_data.get("axes") or {}
stock_category_format = None
if stock_axes:
stock_category_format = (
stock_axes.get("category", {}).get("number_format")
or "m/d/yyyy"
)
stock_series_xml = _stock_series_xml(
categories,
series,
colors=colors,
category_number_format=stock_category_format,
)
axes_xml = _stock_axis_xml(
stock_cat_ax_id,
stock_val_ax_id,
@@ -810,10 +1019,11 @@ def _chart_plot_xml(
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
axes=stock_axes,
)
return (
"<c:stockChart>"
f"{_stock_series_xml(categories, series, colors=colors)}"
f"{stock_series_xml}"
'<c:hiLowLines/>'
'<c:upDownBars><c:gapWidth val="150"/><c:upBars/><c:downBars/></c:upDownBars>'
f'<c:axId val="{stock_cat_ax_id}"/><c:axId val="{stock_val_ax_id}"/>'
@@ -832,6 +1042,17 @@ def _chart_plot_xml(
radar_marker_style=chart_data.get("radar_marker_style"),
radar_style=chart_data.get("radar_style", "marker"),
colors=colors,
category_is_numeric=_category_axis_is_date(chart_data.get("axes") or {}),
category_number_format=(
(chart_data.get("axes") or {})
.get("category", {})
.get(
"number_format",
"m/d/yyyy"
if _category_axis_is_date(chart_data.get("axes") or {})
else None,
)
),
data_labels=chart_data.get("data_labels"),
data_label_font_size=axis_font_size,
data_label_color=chart_style.get("text_color"),
@@ -865,6 +1086,7 @@ def _chart_plot_xml(
chart_type=chart_type,
grouping=grouping,
show_value_axis_labels=chart_data.get("show_value_axis_labels", True),
axes=chart_data.get("axes") or {},
)
overlap_xml = (
'<c:overlap val="100"/>'
@@ -901,6 +1123,7 @@ def _chart_plot_xml(
chart_type=chart_type,
grouping=grouping,
show_value_axis_labels=chart_data.get("show_value_axis_labels", True),
axes=chart_data.get("axes") or {},
)
line_tail_xml = '<c:marker val="1"/><c:smooth val="0"/>' if chart_type == "line" else ""
return (
@@ -939,6 +1162,7 @@ def _chart_plot_xml(
chart_style=chart_style,
chart_type=chart_type,
show_value_axis_labels=chart_data.get("show_value_axis_labels", True),
axes=chart_data.get("axes") or {},
)
return (
f'<c:radarChart><c:radarStyle val="{radar_style}"/>'
@@ -962,51 +1186,20 @@ def _axis_xml(
chart_type: str,
grouping: str | None = None,
show_value_axis_labels: bool = True,
axes: dict[str, dict[str, Any]] | None = None,
) -> str:
cat_pos = "l" if chart_type == "bar" else "b"
val_pos = "b" if chart_type == "bar" else "l"
val_num_fmt = (
'<c:numFmt formatCode="0%" sourceLinked="0"/>'
if grouping == "percentStacked"
else ""
)
val_tick_label_pos = "nextTo" if show_value_axis_labels else "none"
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
axis_tx_pr = _chart_tx_pr_xml(
axis_font_size,
chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
cat_title_xml = _axis_title_xml(
_first_present(axis_titles.get("category"), axis_titles.get("x")),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
val_title_xml = _axis_title_xml(
_first_present(axis_titles.get("value"), axis_titles.get("y")),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
return (
"<c:catAx>"
f'<c:axId val="{cat_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="{cat_pos}"/>{cat_title_xml}<c:majorTickMark val="out"/>'
'<c:minorTickMark val="none"/><c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/><c:auto val="1"/>'
'<c:lblAlgn val="ctr"/><c:lblOffset val="100"/><c:noMultiLvlLbl val="0"/>'
"</c:catAx>"
"<c:valAx>"
f'<c:axId val="{val_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="{val_pos}"/>{_major_gridlines_xml(chart_style.get("grid_color"))}'
f"{val_title_xml}{val_num_fmt}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{val_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="autoZero"/>'
"</c:valAx>"
return _axis_pair_xml(
cat_ax_id,
val_ax_id,
axis_font_size=axis_font_size,
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
chart_type=chart_type,
grouping=grouping,
show_value_axis_labels=show_value_axis_labels,
axes=axes or {},
secondary=False,
)
@@ -1018,7 +1211,11 @@ def _xy_axis_xml(
axis_title_font_size: int,
axis_titles: dict[str, Any],
chart_style: dict[str, str | None],
axes: dict[str, dict[str, Any]] | None = None,
) -> str:
normalized_axes = axes or {}
x_axis = normalized_axes.get("x", {})
y_axis = normalized_axes.get("y", {})
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
axis_tx_pr = _chart_tx_pr_xml(
axis_font_size,
@@ -1037,24 +1234,56 @@ def _xy_axis_xml(
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
return (
"<c:valAx>"
f'<c:axId val="{x_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="b"/>{x_title_xml}<c:majorTickMark val="out"/>'
'<c:minorTickMark val="none"/><c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{y_ax_id}"/><c:crosses val="autoZero"/>'
'<c:crossBetween val="midCat"/>'
"</c:valAx>"
"<c:valAx>"
f'<c:axId val="{y_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="l"/>{_major_gridlines_xml(chart_style.get("grid_color"))}'
f'{y_title_xml}<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
'<c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{x_ax_id}"/><c:crosses val="autoZero"/>'
'<c:crossBetween val="midCat"/>'
"</c:valAx>"
def value_axis_xml(
axis_id: str,
cross_axis_id: str,
config: dict[str, Any],
*,
default_position: str,
default_gridlines: bool,
title_xml: str,
) -> str:
delete = _bool_attr(not config.get("visible", True))
position = _axis_position(config, default_position)
gridlines = _axis_major_gridlines_xml(
config,
default=default_gridlines,
color=chart_style.get("grid_color"),
)
number_format = _axis_number_format_xml(config)
tick_label_position = _axis_label_position(config, "nextTo")
major_unit = (
f'<c:majorUnit val="{config["major_unit"]}"/>'
if config.get("major_unit") is not None else ""
)
return (
"<c:valAx>"
f'<c:axId val="{axis_id}"/>{_axis_scaling_xml(config)}'
f'<c:delete val="{delete}"/><c:axPos val="{position}"/>'
f"{gridlines}{title_xml}{number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{tick_label_position}"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{cross_axis_id}"/><c:crosses val="autoZero"/>'
f'<c:crossBetween val="midCat"/>{major_unit}'
"</c:valAx>"
)
return value_axis_xml(
x_ax_id,
y_ax_id,
x_axis,
default_position="b",
default_gridlines=False,
title_xml=x_title_xml,
) + value_axis_xml(
y_ax_id,
x_ax_id,
y_axis,
default_position="l",
default_gridlines=True,
title_xml=y_title_xml,
)
@@ -1063,6 +1292,7 @@ def _stock_series_xml(
series: list[dict[str, Any]],
*,
colors: list[str],
category_number_format: str | None = None,
) -> str:
parts: list[str] = []
for index, item in enumerate(series):
@@ -1078,7 +1308,7 @@ def _stock_series_xml(
'<c:marker><c:symbol val="none"/></c:marker>'
"<c:cat><c:numRef>"
f"<c:f>Sheet1!$A$2:$A${len(categories) + 1}</c:f>"
f"{_number_cache(categories)}"
f"{_number_cache(categories, category_number_format or 'General')}"
"</c:numRef></c:cat>"
"<c:val><c:numRef>"
f"<c:f>Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}</c:f>"
@@ -1098,44 +1328,22 @@ def _stock_axis_xml(
axis_title_font_size: int,
axis_titles: dict[str, Any],
chart_style: dict[str, str | None],
axes: dict[str, dict[str, Any]] | None = None,
) -> str:
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
axis_tx_pr = _chart_tx_pr_xml(
axis_font_size,
chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
cat_title_xml = _axis_title_xml(
_first_present(axis_titles.get("category"), axis_titles.get("x")),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
val_title_xml = _axis_title_xml(
_first_present(axis_titles.get("value"), axis_titles.get("y")),
font_size=axis_title_font_size,
color=chart_style.get("text_color"),
font_face=chart_style.get("font_face"),
)
return (
"<c:dateAx>"
f'<c:axId val="{cat_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
'<c:delete val="0"/><c:axPos val="b"/>'
f'{cat_title_xml}<c:numFmt formatCode="m/d/yyyy" sourceLinked="1"/>'
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
'<c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/>'
'<c:auto val="1"/><c:lblOffset val="100"/><c:baseTimeUnit val="days"/>'
"</c:dateAx>"
"<c:valAx>"
f'<c:axId val="{val_ax_id}"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
f'<c:delete val="0"/><c:axPos val="l"/>{_major_gridlines_xml(chart_style.get("grid_color"))}'
f'{val_title_xml}<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
'<c:tickLblPos val="nextTo"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="autoZero"/>'
"</c:valAx>"
normalized_axes = dict(axes or {})
normalized_axes.setdefault("category", {"kind": "date", "position": "bottom"})
return _axis_pair_xml(
cat_ax_id,
val_ax_id,
axis_font_size=axis_font_size,
axis_title_font_size=axis_title_font_size,
axis_titles=axis_titles,
chart_style=chart_style,
chart_type="stock",
grouping=None,
show_value_axis_labels=True,
axes=normalized_axes,
secondary=False,
)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,154 @@
"""Visible-fallback fingerprint contract for native chart/table markers."""
from __future__ import annotations
import re
import secrets
from xml.etree import ElementTree as ET
from pptx_shapes import (
NATIVE_FALLBACK_SHA256_ATTR,
svg_native_fallback_fingerprint,
)
NATIVE_FALLBACK_RUNTIME_ATTR = "data-pptx-runtime-fallback-unchanged"
_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR = "data-pptx-runtime-fallback-token"
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_RUNTIME_TOKEN = secrets.token_hex(16)
def stamp_native_fallback_baseline(
elem: ET.Element,
*,
document_root: ET.Element | None = None,
) -> str:
"""Record the current canonical visible-subtree hash on one marker."""
digest = svg_native_fallback_fingerprint(
elem,
document_root=document_root,
)
elem.set(NATIVE_FALLBACK_SHA256_ATTR, digest)
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
return digest
def snapshot_native_fallback_freshness(root: ET.Element) -> None:
"""Snapshot raw marker freshness before exporter preprocessing mutates SVG."""
for elem in root.iter():
if elem.tag.rsplit("}", 1)[-1] == "metadata":
continue
if not (elem.get("data-pptx-native") or "").strip():
continue
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
elem.set(NATIVE_FALLBACK_RUNTIME_ATTR, "invalid")
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
elif expected is None:
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
else:
actual = svg_native_fallback_fingerprint(
elem,
document_root=root,
)
elem.set(
NATIVE_FALLBACK_RUNTIME_ATTR,
"1" if actual == expected else "0",
)
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
def native_fallback_contract_warnings(
elem: ET.Element,
*,
use_runtime_snapshot: bool = False,
document_root: ET.Element | None = None,
) -> list[str]:
"""Return non-blocking diagnostics for default/checker compatibility."""
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
return [
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256; "
"default SVG fallback export remains available, but "
"--native-objects will fail"
]
if expected is None:
return [
f"has no {NATIVE_FALLBACK_SHA256_ATTR} baseline; legacy marker "
"remains native-compatible, but stale fallback edits cannot be detected"
]
if _native_fallback_is_fresh(
elem,
expected,
use_runtime_snapshot=use_runtime_snapshot,
document_root=document_root,
):
return []
return [
"visible SVG fallback differs from its recorded baseline; default "
"fallback export remains available, but --native-objects will fail"
]
def require_fresh_native_fallback(
elem: ET.Element,
*,
use_runtime_snapshot: bool = False,
document_root: ET.Element | None = None,
) -> None:
"""Fail the editable replacement route when a recorded fallback is stale."""
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
raise RuntimeError(
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256"
)
if expected is None:
return
if _native_fallback_is_fresh(
elem,
expected,
use_runtime_snapshot=use_runtime_snapshot,
document_root=document_root,
):
return
raise RuntimeError(
"Visible native-object SVG fallback was edited after its baseline was "
"recorded; --native-objects stopped to avoid discarding the SVG edit. "
"Use the default fallback export, or deliberately update the native "
"metadata and baseline together"
)
def _expected_native_fallback_hash(
elem: ET.Element,
) -> tuple[str | None, bool]:
raw = elem.get(NATIVE_FALLBACK_SHA256_ATTR)
if raw is None:
return None, False
if raw != raw.strip() or _SHA256_RE.fullmatch(raw) is None:
return None, True
return raw.lower(), False
def _native_fallback_is_fresh(
elem: ET.Element,
expected: str,
*,
use_runtime_snapshot: bool,
document_root: ET.Element | None,
) -> bool:
if (
use_runtime_snapshot
and elem.get(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR) == _RUNTIME_TOKEN
):
snapshot = elem.get(NATIVE_FALLBACK_RUNTIME_ATTR)
if snapshot == "1":
return True
if snapshot in {"0", "invalid"}:
return False
return svg_native_fallback_fingerprint(
elem,
document_root=document_root,
) == expected
@@ -0,0 +1,60 @@
"""Validate native-object fallback and release-route status attributes."""
from __future__ import annotations
from xml.etree import ElementTree as ET
VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"})
ROUTE_STATUSES = frozenset({"reconstruction-only"})
def native_marker_status_errors(elem: ET.Element) -> list[str]:
"""Return invalid or contradictory native-object status declarations."""
errors: list[str] = []
visual_raw = elem.get("data-pptx-visual-status")
route_raw = elem.get("data-pptx-route-status")
native_raw = elem.get("data-pptx-native")
fallback_raw = elem.get("data-pptx-native-status")
visual = visual_raw.strip() if visual_raw is not None else None
route = route_raw.strip() if route_raw is not None else None
native = native_raw.strip() if native_raw is not None else ""
fallback = fallback_raw.strip() if fallback_raw is not None else ""
if visual_raw is not None and visual_raw != visual:
errors.append("data-pptx-visual-status must not contain surrounding whitespace")
if route_raw is not None and route_raw != route:
errors.append("data-pptx-route-status must not contain surrounding whitespace")
if visual is not None and visual not in VISUAL_STATUSES:
errors.append(f"unsupported data-pptx-visual-status value: {visual!r}")
if route is not None and route not in ROUTE_STATUSES:
errors.append(f"unsupported data-pptx-route-status value: {route!r}")
if visual == "placeholder" and route != "reconstruction-only":
errors.append(
"data-pptx-visual-status='placeholder' requires "
"data-pptx-route-status='reconstruction-only'"
)
if route == "reconstruction-only" and visual != "placeholder":
errors.append(
"data-pptx-route-status='reconstruction-only' requires "
"data-pptx-visual-status='placeholder'"
)
if native and fallback:
errors.append(
"data-pptx-native and data-pptx-native-status are mutually exclusive"
)
return errors
def native_marker_release_block_reason(elem: ET.Element) -> str | None:
"""Return invalid status metadata that must block an export.
A valid ``reconstruction-only`` declaration is diagnostic rather than a
release block: default export keeps its visible placeholder, while an
active native marker may still reconstruct the editable object.
"""
errors = native_marker_status_errors(elem)
if errors:
return f"invalid-status: {errors[0]}"
return None
@@ -2,12 +2,13 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml
from ..drawingml.utils import _xml_escape
from ..drawingml.utils import _xml_escape, detect_text_lang, font_px_to_hpt
from .chart_style import _font_face_xml
from .marker_common import (
TABLE_URI,
@@ -17,6 +18,7 @@ from .marker_common import (
_compact_key,
_first_present,
_font_size_hpt,
_hex_or_none,
_normalized_fallback_text,
_number,
_powerpoint_emu,
@@ -32,19 +34,48 @@ def _table_text_run(
bold: bool | None,
font_size: int | None,
font_face: str | None,
language: str | None,
theme_color_spec: ThemeColorSpec | None,
italic: bool | None = None,
underline: bool | None = None,
strike: bool | None = None,
alt_language: str | None = None,
exact_font_face: bool = False,
) -> str:
size_attr = f' sz="{font_size}"' if font_size is not None else ""
bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else ""
italic_attr = f' i="{_bool_attr(italic)}"' if italic is not None else ""
underline_attr = (
f' u="{"sng" if underline else "none"}"'
if underline is not None else ""
)
strike_attr = (
f' strike="{"sngStrike" if strike else "noStrike"}"'
if strike is not None else ""
)
resolved_language = language or detect_text_lang(text)
language_attr = f' lang="{_xml_escape(resolved_language)}"'
alt_language_attr = (
f' altLang="{_xml_escape(alt_language)}"' if alt_language else ""
)
color_xml = (
f'<a:solidFill>{color_node_xml(color, theme_color_spec, "text")}</a:solidFill>'
if color else ""
)
if exact_font_face and font_face:
escaped_face = _xml_escape(font_face)
font_xml = (
f'<a:latin typeface="{escaped_face}"/>'
f'<a:ea typeface="{escaped_face}"/>'
)
else:
font_xml = _font_face_xml(font_face)
space_attr = ' xml:space="preserve"' if text != text.strip() else ""
return (
f'<a:r><a:rPr lang="en-US"{size_attr}{bold_attr}>'
f'<a:r><a:rPr{language_attr}{alt_language_attr}{size_attr}{bold_attr}'
f'{italic_attr}{underline_attr}{strike_attr}>'
f'{color_xml}'
f'{_font_face_xml(font_face)}'
f'{font_xml}'
"</a:rPr>"
f"<a:t{space_attr}>{_xml_escape(text)}</a:t></a:r>"
)
@@ -56,15 +87,17 @@ def _cell_payload(value: Any) -> dict[str, Any]:
return {"text": "" if value is None else str(value)}
_TABLE_SPAN_KEYS = {
_TABLE_CANONICAL_SPAN_KEYS = {
"col_span",
"row_span",
}
_TABLE_UNSUPPORTED_SPAN_KEYS = {
"colSpan",
"grid_span",
"gridSpan",
"hMerge",
"merge",
"merged",
"row_span",
"rowSpan",
"vMerge",
}
@@ -78,6 +111,42 @@ _TABLE_MAX_ROWS = 1000
_TABLE_MAX_COLUMNS = 1000
@dataclass(frozen=True)
class _TableMergeRegion:
row: int
col: int
row_span: int
col_span: int
@dataclass(frozen=True)
class _TableBorderSpec:
style: str
color: str | None = None
width: float | None = None
@dataclass(frozen=True)
class _TableRun:
text: str
bold: bool | None = None
italic: bool | None = None
underline: bool | None = None
strike: bool | None = None
color: str | None = None
font_size: int | None = None
font_family: str | None = None
lang: str | None = None
alt_lang: str | None = None
@dataclass(frozen=True)
class _TableParagraph:
text: str
align: str | None = None
runs: tuple[_TableRun, ...] | None = None
def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
columns = payload.get("columns") or []
rows = payload.get("rows") or []
@@ -92,24 +161,265 @@ def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
return table_rows
def _check_table_spans(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
def _table_cell_paragraphs(
cell_data: dict[str, Any],
) -> tuple[_TableParagraph, ...] | None:
if "paragraphs" not in cell_data:
return None
if "text" in cell_data:
raise RuntimeError(
"Native PPTX table cell text and paragraphs are mutually exclusive"
)
raw_paragraphs = cell_data.get("paragraphs")
if not isinstance(raw_paragraphs, list) or not raw_paragraphs:
raise RuntimeError(
"Native PPTX table cell paragraphs must be a non-empty list"
)
paragraphs: list[_TableParagraph] = []
for idx, value in enumerate(raw_paragraphs, start=1):
if isinstance(value, str):
paragraphs.append(_TableParagraph(value))
continue
if not isinstance(value, dict):
raise RuntimeError(
f"Native PPTX table paragraph {idx} must be a string or object"
)
if set(value) - {"text", "runs", "align"}:
raise RuntimeError(
f"Native PPTX table paragraph {idx} accepts text/runs/align only"
)
has_text = "text" in value
has_runs = "runs" in value
if has_text == has_runs:
raise RuntimeError(
f"Native PPTX table paragraph {idx} requires exactly one of text/runs"
)
align = value.get("align")
if align is not None and align not in {"l", "ctr", "r"}:
raise RuntimeError(
f"Native PPTX table paragraph {idx} align must be l, ctr, or r"
)
if has_text:
text = value.get("text")
if not isinstance(text, str):
raise RuntimeError(
f"Native PPTX table paragraph {idx} text must be a string"
)
paragraphs.append(_TableParagraph(text, align))
continue
raw_runs = value.get("runs")
if not isinstance(raw_runs, list) or not raw_runs:
raise RuntimeError(
f"Native PPTX table paragraph {idx} runs must be a non-empty list"
)
runs = tuple(
_table_run(run, paragraph_idx=idx, run_idx=run_idx)
for run_idx, run in enumerate(raw_runs, start=1)
)
paragraphs.append(
_TableParagraph("".join(run.text for run in runs), align, runs)
)
return tuple(paragraphs)
def _table_run(
value: Any,
*,
paragraph_idx: int,
run_idx: int,
) -> _TableRun:
label = f"paragraph {paragraph_idx} run {run_idx}"
if not isinstance(value, dict) or "text" not in value:
raise RuntimeError(f"Native PPTX table {label} must be a text object")
allowed = {
"text", "bold", "italic", "underline", "strike", "color",
"font_size", "font_family", "lang", "alt_lang",
}
unknown = set(value) - allowed
if unknown:
fields = ", ".join(sorted(unknown))
raise RuntimeError(
f"Native PPTX table {label} contains unsupported field(s): {fields}"
)
text = value.get("text")
if not isinstance(text, str):
raise RuntimeError(f"Native PPTX table {label} text must be a string")
booleans: dict[str, bool | None] = {}
for field in ("bold", "italic", "underline", "strike"):
raw = value.get(field)
if raw is not None and not isinstance(raw, bool):
raise RuntimeError(
f"Native PPTX table {label} {field} must be a JSON boolean"
)
booleans[field] = raw
color: str | None = None
if value.get("color") is not None:
if not isinstance(value["color"], str):
raise RuntimeError(f"Native PPTX table {label} color must be a string")
color = _hex_or_none(value["color"])
if color is None:
raise RuntimeError(f"Native PPTX table {label} color is unsupported")
font_size: int | None = None
if value.get("font_size") is not None:
font_size_px = _number(value["font_size"], f"table {label} font_size")
if not 100 / 75 <= font_size_px <= 400000 / 75:
raise RuntimeError(
f"Native PPTX table {label} font_size is outside DrawingML range"
)
font_size = font_px_to_hpt(font_size_px)
if not 100 <= font_size <= 400000:
raise RuntimeError(
f"Native PPTX table {label} font_size is outside DrawingML range"
)
font_family: str | None = None
if value.get("font_family") is not None:
if not isinstance(value["font_family"], str):
raise RuntimeError(
f"Native PPTX table {label} font_family must be a string"
)
font_family = value["font_family"].strip()
if not font_family or "," in font_family:
raise RuntimeError(
f"Native PPTX table {label} font_family must be one typeface"
)
languages: dict[str, str | None] = {}
for field in ("lang", "alt_lang"):
raw = value.get(field)
if raw is None:
languages[field] = None
continue
if not isinstance(raw, str) or not raw.strip():
raise RuntimeError(
f"Native PPTX table {label} {field} must be a non-empty string"
)
languages[field] = raw.strip()
return _TableRun(
text=text,
bold=booleans["bold"],
italic=booleans["italic"],
underline=booleans["underline"],
strike=booleans["strike"],
color=color,
font_size=font_size,
font_family=font_family,
lang=languages["lang"],
alt_lang=languages["alt_lang"],
)
def _table_span_value(
cell_data: dict[str, Any],
key: str,
*,
row_idx: int,
col_idx: int,
) -> int:
value = cell_data.get(key, 1)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} {key} must be a "
"positive JSON integer"
)
return value
def _merge_covered_cell_is_blank(value: Any) -> bool:
if value is None or value == "":
return True
if not isinstance(value, dict):
return False
if any(key != "text" for key in value):
return False
text = value.get("text")
return text is None or text == ""
def _resolve_table_merge_layout(
payload: dict[str, Any],
table_rows: list[list[Any]],
col_count: int,
) -> dict[tuple[int, int], _TableMergeRegion]:
for key in _TABLE_TOP_LEVEL_SPAN_KEYS:
if key in payload:
raise RuntimeError(
"Native PPTX table merged cells are not supported; use SVG fallback "
"or merge cells in PowerPoint after export"
f"Native PPTX table uses unsupported top-level merged-cell field: {key}"
)
owners: dict[tuple[int, int], _TableMergeRegion] = {}
for row_idx, row in enumerate(table_rows, start=1):
for col_idx, cell in enumerate(row, start=1):
if not isinstance(cell, dict):
continue
used_keys = sorted(key for key in _TABLE_SPAN_KEYS if key in cell)
if used_keys:
keys = ", ".join(used_keys)
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} uses unsupported "
f"merged-cell field(s): {keys}"
if isinstance(cell, dict):
used_keys = sorted(
key for key in _TABLE_UNSUPPORTED_SPAN_KEYS if key in cell
)
if used_keys:
keys = ", ".join(used_keys)
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} uses "
f"unsupported merged-cell field(s): {keys}; use row_span/col_span "
"on the merge anchor"
)
position = (row_idx - 1, col_idx - 1)
owner = owners.get(position)
if owner is not None:
if isinstance(cell, dict) and any(
key in cell for key in _TABLE_CANONICAL_SPAN_KEYS
):
raise RuntimeError(
f"Native PPTX table merge anchor R{row_idx}C{col_idx} overlaps "
f"merge rooted at R{owner.row + 1}C{owner.col + 1}"
)
if not _merge_covered_cell_is_blank(cell):
raise RuntimeError(
f"Native PPTX table merge-covered cell R{row_idx}C{col_idx} "
"must be blank"
)
continue
cell_data = _cell_payload(cell)
row_span = _table_span_value(
cell_data, "row_span", row_idx=row_idx, col_idx=col_idx,
)
col_span = _table_span_value(
cell_data, "col_span", row_idx=row_idx, col_idx=col_idx,
)
if row_span == 1 and col_span == 1:
continue
if (
row_idx - 1 + row_span > len(table_rows)
or col_idx - 1 + col_span > col_count
):
raise RuntimeError(
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} exceeds "
f"the resolved {len(table_rows)}x{col_count} grid"
)
region = _TableMergeRegion(
row=row_idx - 1,
col=col_idx - 1,
row_span=row_span,
col_span=col_span,
)
for covered_row in range(region.row, region.row + region.row_span):
for covered_col in range(region.col, region.col + region.col_span):
covered_position = (covered_row, covered_col)
prior = owners.get(covered_position)
if prior is not None:
raise RuntimeError(
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} "
f"overlaps merge rooted at R{prior.row + 1}C{prior.col + 1}"
)
owners[covered_position] = region
return owners
def _grid_is_strict(payload: dict[str, Any]) -> bool:
@@ -174,29 +484,40 @@ def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]
def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
_table_bool(style.get("band_row"), "style.band_row", default=True)
if "borders" in style:
raise RuntimeError(
"Native PPTX table per-side borders are supported on cells only"
)
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
_table_cell_paragraphs(cell_data)
if "bold" in cell_data:
_table_bool(cell_data["bold"], "cell bold", default=False)
for side in ("left", "right", "top", "bottom"):
_table_padding_value(cell_data, style, side)
border_width = _table_border_width(cell_data, style)
if border_width > 0:
for border in _table_border_specs(cell_data, style).values():
if border is None or border.style == "none":
continue
assert border.width is not None
_powerpoint_line_width_emu(
border_width,
border.width,
"table border_width",
)
_table_anchor(cell_data, style)
def _validate_table_payload(payload: dict[str, Any]) -> tuple[list[list[Any]], int]:
def _validate_table_payload(
payload: dict[str, Any],
) -> tuple[list[list[Any]], int, dict[tuple[int, int], _TableMergeRegion]]:
table_rows = _table_rows(payload)
_check_table_spans(payload, table_rows)
col_count = _validate_table_lengths(payload, table_rows)
for row in table_rows:
row.extend([""] * (col_count - len(row)))
merge_layout = _resolve_table_merge_layout(payload, table_rows, col_count)
_table_header_rows(payload, len(table_rows))
_validate_table_cell_formatting(payload, table_rows)
return table_rows, col_count
return table_rows, col_count, merge_layout
def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]:
@@ -204,9 +525,16 @@ def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]:
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
text = _normalized_fallback_text(cell_data.get("text"))
if text:
counts[text] = counts.get(text, 0) + 1
paragraphs = _table_cell_paragraphs(cell_data)
texts = (
[paragraph.text for paragraph in paragraphs]
if paragraphs is not None
else [cell_data.get("text")]
)
for value in texts:
text = _normalized_fallback_text(value)
if text:
counts[text] = counts.get(text, 0) + 1
return counts
@@ -360,35 +688,158 @@ def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> flo
return _number(1 if width_raw is None else width_raw, "table border_width")
_TABLE_BORDER_SIDES = ("left", "right", "top", "bottom")
_TABLE_BORDER_TAGS = {
"left": "lnL",
"right": "lnR",
"top": "lnT",
"bottom": "lnB",
}
def _strict_table_border_color(value: Any, side: str) -> str:
raw = value if isinstance(value, str) else ""
if len(raw) != 7 or not raw.startswith("#"):
raise RuntimeError(
f"Native PPTX table {side} border color must be #RRGGBB"
)
try:
int(raw[1:], 16)
except ValueError as exc:
raise RuntimeError(
f"Native PPTX table {side} border color must be #RRGGBB"
) from exc
return raw[1:].upper()
def _table_border_override(value: Any, side: str) -> _TableBorderSpec:
if not isinstance(value, dict):
raise RuntimeError(
f"Native PPTX table {side} border must be an object"
)
border_style = value.get("style")
if border_style == "none":
if set(value) != {"style"}:
raise RuntimeError(
f"Native PPTX table {side} border style none accepts no other fields"
)
return _TableBorderSpec("none")
if border_style != "solid":
raise RuntimeError(
f"Native PPTX table {side} border style must be solid or none"
)
if set(value) != {"style", "color", "width"}:
raise RuntimeError(
f"Native PPTX table {side} solid border requires style/color/width only"
)
width = _number(value.get("width"), f"table {side} border width")
if width <= 0:
raise RuntimeError(
f"Native PPTX table {side} solid border width must be positive"
)
_powerpoint_line_width_emu(width, f"table {side} border width")
return _TableBorderSpec(
"solid",
color=_strict_table_border_color(value.get("color"), side),
width=width,
)
def _table_border_specs(
cell_data: dict[str, Any],
style: dict[str, Any],
) -> dict[str, _TableBorderSpec | None]:
raw_borders = cell_data.get("borders")
if raw_borders is None:
border_overrides: dict[str, Any] = {}
elif not isinstance(raw_borders, dict):
raise RuntimeError("Native PPTX table cell borders must be an object")
else:
unknown = sorted(set(raw_borders) - set(_TABLE_BORDER_SIDES))
if unknown:
raise RuntimeError(
"Native PPTX table cell borders use unsupported side(s): "
+ ", ".join(unknown)
)
border_overrides = raw_borders
legacy_width = _table_border_width(cell_data, style)
legacy_spec = (
_TableBorderSpec(
"solid",
color=_clean_hex(
cell_data.get(
"border_color",
cell_data.get("borderColor", style.get("border_color")),
),
"#D9DEE7",
),
width=legacy_width,
)
if legacy_width > 0
else None
)
return {
side: (
_table_border_override(border_overrides[side], side)
if side in border_overrides
else legacy_spec
)
for side in _TABLE_BORDER_SIDES
}
def _table_border_xml(
cell_data: dict[str, Any],
style: dict[str, Any],
theme_color_spec: ThemeColorSpec | None,
) -> str:
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
width = _table_border_width(cell_data, style)
if width <= 0:
border_xml: list[str] = []
for side, border in _table_border_specs(cell_data, style).items():
if border is None:
continue
tag = _TABLE_BORDER_TAGS[side]
if border.style == "none":
border_xml.append(f'<a:{tag}><a:noFill/></a:{tag}>')
continue
assert border.color is not None and border.width is not None
line_width = _powerpoint_line_width_emu(
border.width, f"table {side} border width",
)
border_xml.append(
f'<a:{tag} w="{line_width}">'
f'<a:solidFill>{color_node_xml(border.color, theme_color_spec, "stroke")}'
'</a:solidFill>'
'<a:prstDash val="solid"/>'
f'</a:{tag}>'
)
return "".join(border_xml)
def _table_merge_attrs(
region: _TableMergeRegion | None,
row_idx: int,
col_idx: int,
) -> str:
if region is None:
return ""
color = _clean_hex(color_raw, "#D9DEE7")
line = (
f'<a:solidFill>{color_node_xml(color, theme_color_spec, "stroke")}</a:solidFill>'
'<a:prstDash val="solid"/>'
)
line_width = _powerpoint_line_width_emu(width, "table border_width")
return "".join(
f'<a:{tag} w="{line_width}">{line}</a:{tag}>'
for tag in ("lnL", "lnR", "lnT", "lnB")
)
attrs: list[str] = []
if row_idx == region.row and region.row_span > 1:
attrs.append(f'rowSpan="{region.row_span}"')
if col_idx == region.col and region.col_span > 1:
attrs.append(f'gridSpan="{region.col_span}"')
if col_idx > region.col:
attrs.append('hMerge="1"')
if row_idx > region.row:
attrs.append('vMerge="1"')
return (" " + " ".join(attrs)) if attrs else ""
def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
table_rows, col_count = _validate_table_payload(payload)
table_rows, col_count, merge_layout = _validate_table_payload(payload)
header_rows = _table_header_rows(payload, len(table_rows))
preserve_source_style = elem.get("data-pptx-native-source") == "pptx"
for row in table_rows:
row.extend([""] * (col_count - len(row)))
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
header_fill = _clean_hex(style.get("header_fill"), "#1F4E79")
header_text = _clean_hex(style.get("header_text"), "#FFFFFF")
@@ -397,6 +848,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA")
font_face = str(style["font_family"]) if style.get("font_family") else None
body_font_size = _font_size_hpt(style.get("font_size"), 18)
band_rows_enabled = _table_bool(
style.get("band_row"),
"style.band_row",
default=True,
)
header_font_size = _font_size_hpt(
style.get("header_font_size", style.get("font_size")),
18,
@@ -424,7 +880,20 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
for row_idx, row in enumerate(table_rows):
is_header = row_idx < header_rows
cells_xml: list[str] = []
for cell in row:
for col_idx, cell in enumerate(row):
merge_region = merge_layout.get((row_idx, col_idx))
merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx)
if merge_region is not None and (
row_idx != merge_region.row or col_idx != merge_region.col
):
cells_xml.append(
f'<a:tc{merge_attrs}>'
'<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>'
'<a:tcPr/>'
'</a:tc>'
)
continue
cell_data = _cell_payload(cell)
if preserve_source_style:
fill = (
@@ -440,7 +909,9 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
fill = _clean_hex(
cell_data.get("fill"),
header_fill if is_header else (
band_fill if row_idx % 2 == 0 and row_idx else body_fill
band_fill
if band_rows_enabled and row_idx % 2 == 0 and row_idx
else body_fill
),
)
color = _clean_hex(
@@ -450,7 +921,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
if align not in {"l", "ctr", "r"}:
align = "l"
text = "" if cell_data.get("text") is None else str(cell_data.get("text"))
paragraphs = _table_cell_paragraphs(cell_data)
if preserve_source_style:
bold = (
_table_bool(cell_data["bold"], "cell bold", default=False)
@@ -469,7 +940,75 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
)
if is_header and "font_size" not in cell_data:
cell_font_size = header_font_size
paragraph_props = f'<a:pPr algn="{align}"/>' if align != "l" else "<a:pPr/>"
language = (
str(cell_data.get("lang") or style.get("lang") or "").strip()
or None
)
if paragraphs is None:
text = (
"" if cell_data.get("text") is None
else str(cell_data.get("text"))
)
paragraph_props = (
f'<a:pPr algn="{align}"/>' if align != "l" else "<a:pPr/>"
)
text_run_xml = _table_text_run(
text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
language=language,
theme_color_spec=ctx.theme_color_spec,
)
paragraphs_xml = (
f"<a:p>{paragraph_props}{text_run_xml}</a:p>"
)
else:
paragraph_parts: list[str] = []
for paragraph in paragraphs:
paragraph_align = paragraph.align or align
paragraph_props = (
f'<a:pPr algn="{paragraph_align}"/>'
if paragraph.align is not None or paragraph_align != "l"
else "<a:pPr/>"
)
if paragraph.runs is None:
text_run_xml = _table_text_run(
paragraph.text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
language=language,
theme_color_spec=ctx.theme_color_spec,
)
else:
text_run_xml = "".join(
_table_text_run(
run.text,
color=run.color or color,
bold=run.bold if run.bold is not None else bold,
font_size=(
run.font_size
if run.font_size is not None
else cell_font_size
),
font_face=run.font_family or font_face,
language=run.lang or language,
theme_color_spec=ctx.theme_color_spec,
italic=run.italic,
underline=run.underline,
strike=run.strike,
alt_language=run.alt_lang,
exact_font_face=run.font_family is not None,
)
for run in paragraph.runs
)
paragraph_parts.append(
f"<a:p>{paragraph_props}{text_run_xml}</a:p>"
)
paragraphs_xml = "".join(paragraph_parts)
anchor_keys = {"valign", "vertical_align"}
anchor_attr = ""
if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style):
@@ -486,20 +1025,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
'</a:solidFill>'
if fill else ""
)
text_run_xml = _table_text_run(
text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
theme_color_spec=ctx.theme_color_spec,
)
cells_xml.append(
"<a:tc>"
f"<a:tc{merge_attrs}>"
"<a:txBody><a:bodyPr/><a:lstStyle/>"
f"<a:p>{paragraph_props}"
f"{text_run_xml}"
"</a:p></a:txBody>"
f"{paragraphs_xml}"
"</a:txBody>"
f'<a:tcPr{tc_pr_attrs}>{border_xml}{fill_xml}</a:tcPr>'
"</a:tc>"
)
@@ -507,7 +1037,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
shape_id = ctx.next_id()
first_row = _bool_attr(header_rows > 0)
band_row = _bool_attr(_table_bool(style.get("band_row"), "style.band_row", default=True))
band_row = _bool_attr(band_rows_enabled)
table_style_id = style.get("table_style_id")
if table_style_id is None and not preserve_source_style:
table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
@@ -127,6 +127,28 @@ def _minimal_workbook(rows: list[list[Any]]) -> bytes:
def _minimal_category_chart_workbook(chart_data: dict[str, Any]) -> bytes:
if chart_data.get("kind") == "combo" and chart_data.get("independent_categories"):
plots = chart_data["plots"]
column_count = max(
int(plot["start_column"]) + len(plot["series"]) - 1
for plot in plots
)
rows: list[list[Any]] = [[None] * column_count]
for plot in plots:
category_column = int(plot["category_column"]) - 1
start_column = int(plot["start_column"]) - 1
for offset, series in enumerate(plot["series"]):
rows[0][start_column + offset] = series["name"]
for point_index, category in enumerate(plot["categories"], start=1):
while len(rows) <= point_index:
rows.append([None] * column_count)
rows[point_index][category_column] = category
for offset, series in enumerate(plot["series"]):
rows[point_index][start_column + offset] = (
series["values"][point_index - 1]
)
return _minimal_workbook(rows)
categories = chart_data["categories"]
series = chart_data["series"]
rows: list[list[Any]] = [[None] + [item["name"] for item in series]]
@@ -5,6 +5,7 @@ from __future__ import annotations
import sys
import json
import math
import re
import shutil
import argparse
from datetime import datetime
@@ -37,6 +38,7 @@ if __package__ in {None, ''}:
from .dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
from .discovery import find_svg_files, find_notes_files
from .builder import create_pptx_with_native_svg
from ..native_objects.marker_status import native_marker_release_block_reason
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
from ..drawingml.theme_fonts import (
ThemeFontError,
@@ -47,12 +49,10 @@ from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_d
from .slide_xml import TRANSITIONS
from .template_structure import (
TemplateStructureError,
load_native_structure_contract,
load_pptx_structure_lock,
native_structure_lock_errors,
parse_preserve_slides,
parse_template_slides,
template_lock_errors,
template_prototype_errors,
)
from ..animation_config import (
load_animation_config,
@@ -66,6 +66,50 @@ def _as_dict(value: object) -> dict:
return value if isinstance(value, dict) else {}
_PPTX_STRUCTURE_SECTION_RE = re.compile(
r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)"
)
_PPTX_STRUCTURE_MODE_RE = re.compile(
r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^\s#]+)[ \t]*(?:#.*)?$"
)
_LEGACY_PPTX_STRUCTURE_MODES = frozenset({
'baseline',
'generated',
'preserve',
'template',
})
def _declared_pptx_structure_mode(project_path: Path) -> str | None:
"""Return the explicitly locked SVG export mode, without legacy fallback."""
lock_path = project_path / 'spec_lock.md'
try:
content = lock_path.read_text(encoding='utf-8')
except OSError:
return None
section_match = _PPTX_STRUCTURE_SECTION_RE.search(content)
if section_match is None:
return None
mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1))
return mode_match.group(1).strip().lower() if mode_match else None
def _print_structure_migration_error(mode: str | None) -> None:
"""Explain how a legacy or absent SVG structure contract is restored."""
label = repr(mode) if mode else 'missing (legacy implicit baseline)'
print(
"Error: release SVG export requires spec_lock.md "
"pptx_structure.mode: structured; found " + label + ".",
file=sys.stderr,
)
print(
" Restore explicit Master/Layout metadata first by following "
"skills/ppt-master/workflows/restore-pptx-structure.md, then rerun "
"the quality gate and export.",
file=sys.stderr,
)
def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]]:
"""Return fallback-only native object statuses from SVG inputs."""
fallbacks: list[tuple[str, str, str]] = []
@@ -83,6 +127,50 @@ def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]
return fallbacks
def _release_blocked_graphics(
svg_files: list[Path],
) -> list[tuple[str, str, str]]:
"""Return graphics whose status metadata is invalid."""
blocked: list[tuple[str, str, str]] = []
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
for elem in root.iter():
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
reason = native_marker_release_block_reason(elem)
if reason is None:
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
blocked.append((svg_path.name, marker_id, reason))
return blocked
def _reconstruction_only_graphics(
svg_files: list[Path],
) -> list[tuple[str, str, bool]]:
"""Return valid placeholder routes for non-blocking diagnostics."""
diagnostics: list[tuple[str, str, bool]] = []
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
for elem in root.iter():
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
if elem.get('data-pptx-route-status') != 'reconstruction-only':
continue
if native_marker_release_block_reason(elem) is not None:
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
active_native = bool((elem.get('data-pptx-native') or '').strip())
diagnostics.append((svg_path.name, marker_id, active_native))
return diagnostics
def _recorded_narration_on_click_slides(
ref_files: list[Path],
animation_config: dict | None,
@@ -214,24 +302,30 @@ Recorded narration:
'conversion decisions for debugging.')
parser.add_argument('--native-objects', action='store_true', default=False,
help='Opt in to converting explicit data-pptx-native table/chart '
'markers into native PowerPoint objects. Default off: marked '
'groups export through their SVG fallback children. When set, '
'markers into editable PowerPoint objects. This editable-first '
'replacement may normalize styling or omit unmodeled marker-local '
'visuals. Default off: marked groups export through their SVG '
'fallback children. When set, '
'the default-flow export is named <project>_<ts>_native_charts.pptx '
'to tell it apart from a plain shape export.')
parser.add_argument(
'--pptx-structure',
choices=['baseline', 'template', 'preserve', 'flat'],
choices=[
'structured',
'flat',
'baseline',
'template',
'preserve',
'generated',
],
default=None,
help=(
'PPTX structure strategy for native export. When omitted, read '
'spec_lock.md pptx_structure.mode, falling back to baseline. baseline '
'promotes safe repeated background/chrome and extracts conservative '
'semantic page-role layout families plus exact family-wide '
'structurally marked chrome (legacy filenames/ids remain fallbacks); '
'template consumes explicit '
'data-pptx-layout/layer/placeholder metadata to build reusable layouts; '
'preserve is legacy compatibility for imported source packages; '
'flat leaves generated structure slide-local for debugging/comparison.'
'PPTX structure strategy for native export. Release export requires '
'spec_lock.md pptx_structure.mode: structured and complete explicit '
'Master/Layout metadata in every SVG. flat is an explicit diagnostic '
'override that leaves all generated objects slide-local. baseline, '
'template, preserve, and generated are accepted only so legacy '
'invocations can receive a targeted migration error.'
),
)
parser.add_argument('--no-image-optimize', action='store_true',
@@ -316,46 +410,44 @@ Recorded narration:
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
return 1
structure_lock = None
native_structure_contract = None
pptx_structure = args.pptx_structure
declared_structure_mode = _declared_pptx_structure_mode(project_path)
if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
_print_structure_migration_error(pptx_structure)
return 1
if pptx_structure is None:
if declared_structure_mode != 'structured':
_print_structure_migration_error(declared_structure_mode)
return 1
pptx_structure = 'structured'
elif pptx_structure == 'structured' and declared_structure_mode != 'structured':
_print_structure_migration_error(declared_structure_mode)
return 1
if pptx_structure == 'structured':
try:
structure_lock = load_pptx_structure_lock(project_path)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
pptx_structure = structure_lock.mode if structure_lock else 'baseline'
elif pptx_structure == 'preserve':
try:
structure_lock = load_pptx_structure_lock(project_path)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if structure_lock is None or structure_lock.mode != 'preserve':
if structure_lock is None or structure_lock.mode != 'structured':
print(
"Error: --pptx-structure preserve requires a preserve-mode "
"spec_lock.md with source_template/native_structure rows",
"Error: spec_lock.md must contain one complete "
"pptx_structure.mode: structured contract",
file=sys.stderr,
)
return 1
if pptx_structure == 'preserve':
if structure_lock is None:
print("Error: preserve mode requires spec_lock.md", file=sys.stderr)
return 1
try:
native_structure_contract = load_native_structure_contract(structure_lock)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
theme_font_spec = None
master_text_style_spec = None
theme_color_spec = None
if pptx_structure in {'baseline', 'template'}:
if pptx_structure == 'structured':
try:
theme_font_spec = load_theme_font_spec(project_path)
if pptx_structure == 'template':
master_text_style_spec = load_master_text_style_spec(project_path)
master_text_style_spec = load_master_text_style_spec(project_path)
theme_color_spec = load_theme_color_spec(project_path)
except (ThemeFontError, ThemeColorError) as exc:
print(f"Error: {exc}", file=sys.stderr)
@@ -391,16 +483,13 @@ Recorded narration:
print("Error: No SVG files found")
return 1
if (
pptx_structure in {'template', 'preserve'}
and structure_lock is not None
):
# Compatibility kwargs remain until the builder's old baseline-specific
# parameters are removed. Structured export never activates either path.
structured_baseline = False
baseline_layout_specs = None
if pptx_structure == 'structured' and structure_lock is not None:
try:
template_specs = (
parse_preserve_slides(native_files)
if pptx_structure == 'preserve'
else parse_template_slides(native_files)
)
template_specs = parse_template_slides(native_files)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
@@ -410,25 +499,61 @@ Recorded narration:
for message in lock_errors:
print(f" {message}", file=sys.stderr)
return 1
if pptx_structure == 'preserve':
if native_structure_contract is None:
print("Error: preserve mode contract is unavailable", file=sys.stderr)
return 1
preserve_errors = native_structure_lock_errors(
template_specs,
structure_lock,
native_structure_contract,
prototype_errors = template_prototype_errors(
template_specs,
structure_lock,
)
if prototype_errors:
print(
"Error: structured template output does not match page_layouts "
"prototypes:",
file=sys.stderr,
)
for message in prototype_errors:
print(f" {message}", file=sys.stderr)
return 1
release_blocked = _release_blocked_graphics(native_files)
if release_blocked:
print(
"Error: invalid PPTX graphic status metadata cannot enter an export. "
"Correct the reported visual/route/native status attributes first.",
file=sys.stderr,
)
for filename, marker_id, status in release_blocked[:20]:
print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
if len(release_blocked) > 20:
print(
f" ... and {len(release_blocked) - 20} more",
file=sys.stderr,
)
return 1
reconstruction_only = _reconstruction_only_graphics(native_files)
if reconstruction_only:
print(
"Warning: reconstruction-only PPTX chart placeholder(s) have no baked "
"preview. Default export keeps the placeholder; --native-objects "
"reconstructs entries that carry a valid active native marker.",
file=sys.stderr,
)
for filename, marker_id, active_native in reconstruction_only[:20]:
route = "active native reconstruction" if active_native else "placeholder fallback"
print(f" {filename}: {marker_id} ({route})", file=sys.stderr)
if len(reconstruction_only) > 20:
print(
f" ... and {len(reconstruction_only) - 20} more",
file=sys.stderr,
)
if preserve_errors:
print(
"Error: PPTX structure does not match native_structure.json:",
file=sys.stderr,
)
for message in preserve_errors:
print(f" {message}", file=sys.stderr)
return 1
if args.native_objects:
print(
"Warning: --native-objects is an editable-first replacement route. "
"Native charts/tables may normalize styling or omit SVG details that "
"are not represented by marker metadata; use the default export when "
"exact fallback artwork is required.",
file=sys.stderr,
)
fallbacks = _native_object_fallbacks(native_files)
if fallbacks:
print(
@@ -748,6 +873,8 @@ Recorded narration:
image_quality=args.image_quality,
native_objects=args.native_objects,
pptx_structure=pptx_structure,
structured_baseline=structured_baseline,
baseline_layout_specs=baseline_layout_specs,
native_structure_contract=native_structure_contract,
theme_font_spec=theme_font_spec,
master_text_style_spec=master_text_style_spec,
@@ -794,26 +921,6 @@ Recorded narration:
print(f" [warn] svg_output backup skipped: {exc}")
elif verbose:
print(f" [info] svg_output/ not found, backup skipped")
if pptx_structure == 'preserve' and structure_lock is not None:
try:
preserve_sources = [
project_path / 'spec_lock.md',
structure_lock.source_template,
structure_lock.native_structure,
]
for source in preserve_sources:
if source is None or not source.is_file():
continue
source_path = source.resolve()
relative = source_path.relative_to(project_path.resolve())
destination = backup_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, destination)
if verbose:
print(f" preserve contract backup: {backup_dir}")
except (OSError, ValueError) as exc:
if verbose:
print(f" [warn] preserve contract backup skipped: {exc}")
return 0 if success else 1
@@ -39,6 +39,7 @@ AUDIO_CONTENT_TYPES = {
NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys())
AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px
AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU
AUDIO_MARKER_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG"
"cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu"
@@ -207,7 +208,14 @@ def _create_audio_pic_element(
shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
ET.SubElement(transform, _qn(DRAWINGML_NS, "off"), {"x": "0", "y": "0"})
ET.SubElement(
transform,
_qn(DRAWINGML_NS, "off"),
{
"x": str(AUDIO_MARKER_OFF_CANVAS_EMU),
"y": str(AUDIO_MARKER_OFF_CANVAS_EMU),
},
)
ET.SubElement(
transform,
_qn(DRAWINGML_NS, "ext"),
@@ -232,7 +240,7 @@ def create_audio_pic_xml(
media_rid: str,
poster_rid: str,
) -> str:
"""Create a visible audio picture shape carrying narration media."""
"""Create an off-canvas audio picture shape carrying narration media."""
element = _create_audio_pic_element(
shape_id,
shape_name,
@@ -2,7 +2,7 @@
"""
PPT Master - Template Package Validation
Read a generated template-mode PPTX back and verify its reusable Layout graph.
Read a generated structured PPTX back and verify its reusable Master/Layout graph.
Usage:
Imported by svg_to_pptx.pptx_package.builder.
@@ -19,6 +19,7 @@ from __future__ import annotations
import posixpath
import sys
import zipfile
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlsplit
@@ -29,7 +30,7 @@ if __name__ == "__main__":
print(__doc__)
raise SystemExit(0)
print(
"Use this validator through the template-mode SVG-to-PPTX exporter.",
"Use this validator through the structured SVG-to-PPTX exporter.",
file=sys.stderr,
)
raise SystemExit(1)
@@ -40,6 +41,7 @@ from .template_structure import (
TemplatePlaceholderBinding,
TemplateSlideSpec,
TemplateStructureError,
is_proxy_placeholder,
template_placeholder_bindings,
)
@@ -50,13 +52,30 @@ REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
)
SLIDE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
)
SLIDE_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
)
SLIDE_LAYOUT_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
)
SLIDE_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
)
SLIDE_MASTER_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
)
PRESENTATION_COLLECTION_ID_MIN = 1 << 31
PRESENTATION_SLIDE_ID_MIN = 256
_TOP_LEVEL_VISIBLE_TAGS = frozenset({
f"{{{PML_NS}}}sp",
f"{{{PML_NS}}}pic",
f"{{{PML_NS}}}graphicFrame",
f"{{{PML_NS}}}grpSp",
f"{{{PML_NS}}}cxnSp",
})
@dataclass(frozen=True)
@@ -137,6 +156,140 @@ class _PackageReader:
return result
def _top_level_shape_names(
root: ET.Element,
context: str,
errors: list[str],
) -> tuple[str, ...]:
"""Return deterministic names for every visible top-level OOXML shape."""
c_sld = root.find(f"{{{PML_NS}}}cSld")
shape_tree = (
c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None
)
if shape_tree is None:
errors.append(f"{context} has no p:cSld/p:spTree")
return ()
names: list[str] = []
for child in shape_tree:
if child.tag not in _TOP_LEVEL_VISIBLE_TAGS:
continue
c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
name = c_nv_pr.get("name") if c_nv_pr is not None else None
if not name:
errors.append(f"{context} contains a top-level shape without a name")
continue
names.append(name)
return tuple(names)
def _validate_named_shape_roster(
root: ET.Element,
expected_names: set[str] | tuple[str, ...] | list[str],
context: str,
errors: list[str],
*,
exact: bool,
ordered: bool = False,
) -> None:
"""Validate reusable structure and ordinary Slide carriers by stable name."""
expected_sequence = tuple(expected_names)
expected_set = set(expected_sequence)
actual_names = _top_level_shape_names(root, context, errors)
counts = Counter(actual_names)
duplicates = sorted(name for name, count in counts.items() if count > 1)
if duplicates:
errors.append(
f"{context} repeats structured shape name(s): " + ", ".join(duplicates)
)
actual_set = set(actual_names)
missing = sorted(expected_set - actual_set)
if missing:
errors.append(
f"{context} is missing structured shape(s): " + ", ".join(missing)
)
if exact:
unexpected = sorted(actual_set - expected_set)
if unexpected:
errors.append(
f"{context} contains unexpected shape(s): "
+ ", ".join(unexpected)
)
if ordered and not missing:
actual_sequence = (
actual_names
if exact
else tuple(name for name in actual_names if name in expected_set)
)
if actual_sequence != expected_sequence:
errors.append(
f"{context} structured shape order is {actual_sequence}, "
f"expected {expected_sequence}"
)
def _top_level_shape_by_name(
root: ET.Element,
name: str,
) -> ET.Element | None:
c_sld = root.find(f"{{{PML_NS}}}cSld")
shape_tree = (
c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None
)
if shape_tree is None:
return None
for child in shape_tree:
if child.tag not in _TOP_LEVEL_VISIBLE_TAGS:
continue
c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
if c_nv_pr is not None and c_nv_pr.get("name") == name:
return child
return None
def _has_explicit_background(root: ET.Element) -> bool:
c_sld = root.find(f"{{{PML_NS}}}cSld")
return c_sld is not None and c_sld.find(f"{{{PML_NS}}}bg") is not None
def _xml_payload_signature(elem: ET.Element) -> tuple[object, ...]:
"""Return a namespace-stable exact XML payload signature."""
return (
elem.tag,
tuple(sorted(elem.attrib.items())),
(elem.text or "").strip(),
tuple(_xml_payload_signature(child) for child in elem),
)
def _background_payload_signatures(
root: ET.Element,
) -> tuple[tuple[object, ...], ...]:
c_sld = root.find(f"{{{PML_NS}}}cSld")
backgrounds = (
c_sld.findall(f"{{{PML_NS}}}bg")
if c_sld is not None
else []
)
return tuple(_xml_payload_signature(background) for background in backgrounds)
def _expected_background_signature(
background_xml: str,
) -> tuple[object, ...]:
"""Parse a standalone p:bg fragment regardless of its serialized prefix."""
try:
wrapper = ET.fromstring(
f'<root xmlns:p="{PML_NS}" xmlns:a="{DML_NS}">'
f"{background_xml}</root>"
)
except ET.ParseError as exc:
raise ValueError(f"invalid expected p:bg payload: {exc}") from exc
children = list(wrapper)
if len(children) != 1 or children[0].tag != f"{{{PML_NS}}}bg":
raise ValueError("expected background payload must contain exactly one p:bg")
return _xml_payload_signature(children[0])
def _relationships_part_for(source_part: str) -> str:
directory = posixpath.dirname(source_part)
filename = posixpath.basename(source_part)
@@ -461,6 +614,8 @@ def _validate_registered_part_roster(
entries: list[ET.Element],
expected_targets: set[str],
context: str,
*,
min_id: int = PRESENTATION_COLLECTION_ID_MIN,
) -> set[int]:
relationships = [
relationship
@@ -513,10 +668,76 @@ def _validate_registered_part_roster(
entries,
context,
reader.errors,
min_value=PRESENTATION_COLLECTION_ID_MIN,
min_value=min_id,
)
def _numbered_part_family(
names: frozenset[str],
directory: str,
stem: str,
) -> set[str]:
"""Return exact numbered XML parts such as slide12.xml in one directory."""
prefix = f"{directory}/{stem}"
parts: set[str] = set()
for name in names:
if not name.startswith(prefix) or not name.endswith(".xml"):
continue
number = name[len(prefix):-4]
if (
number.isdigit()
and int(number) > 0
and posixpath.dirname(name) == directory
):
parts.add(name)
return parts
def _validate_physical_part_roster(
reader: _PackageReader,
expected_parts: set[str],
directory: str,
stem: str,
context: str,
) -> None:
actual_parts = _numbered_part_family(reader.names, directory, stem)
if actual_parts != expected_parts:
reader.errors.append(
f"{context} physical part roster differs; "
f"missing={sorted(expected_parts - actual_parts)}, "
f"extra={sorted(actual_parts - expected_parts)}"
)
def _validate_content_type_part_roster(
overrides: dict[str, list[str]],
expected_parts: set[str],
directory: str,
stem: str,
content_type: str,
errors: list[str],
context: str,
) -> None:
family = _numbered_part_family(
frozenset(name.lstrip("/") for name in overrides),
directory,
stem,
)
if family != expected_parts:
errors.append(
f"[Content_Types].xml {context} Override roster differs; "
f"missing={sorted(expected_parts - family)}, "
f"extra={sorted(family - expected_parts)}"
)
for part in sorted(expected_parts):
values = overrides.get(f"/{part}", [])
if values != [content_type]:
errors.append(
f"[Content_Types].xml must declare {part} exactly once as "
f"{content_type}"
)
def _validate_presentation_master_registration(
reader: _PackageReader,
master_parts: set[str],
@@ -538,13 +759,43 @@ def _validate_presentation_master_registration(
)
def _validate_presentation_slide_registration(
reader: _PackageReader,
slide_parts: set[str],
) -> None:
presentation_part = "ppt/presentation.xml"
presentation_root = reader.xml(presentation_part)
if presentation_root is None:
return
slide_id_entries = presentation_root.findall(
f"{{{PML_NS}}}sldIdLst/{{{PML_NS}}}sldId"
)
_validate_registered_part_roster(
reader,
presentation_part,
SLIDE_REL_TYPE,
slide_id_entries,
slide_parts,
"Presentation p:sldIdLst",
min_id=PRESENTATION_SLIDE_ID_MIN,
)
def validate_pptx_template_package(
pptx_path: str | Path,
specs: list[TemplateSlideSpec],
*,
expected_backgrounds: dict[str, str | None] | None = None,
expected_shape_rosters: dict[str, tuple[str, ...]] | None = None,
) -> None:
"""Validate a finished template-mode PPTX against its explicit SVG contract."""
"""Validate a finished structured PPTX against its explicit SVG contract.
Export supplies pre-packaging background and shape expectations for exact
serialization read-back. Callers that only have the finished package still
receive the portable structure, ownership, and relationship checks.
"""
if not specs:
raise ValueError("template package validation requires at least one slide spec")
raise ValueError("structured package validation requires at least one slide spec")
specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
bindings_by_layout: dict[str, tuple[TemplatePlaceholderBinding, ...]] = {}
@@ -564,6 +815,40 @@ def validate_pptx_template_package(
with zipfile.ZipFile(path) as package:
reader = _PackageReader(package, errors)
overrides = _content_type_overrides(reader)
if expected_backgrounds is not None:
for part, background_xml in sorted(expected_backgrounds.items()):
root = reader.xml(part)
if root is None:
continue
if background_xml is None:
expected_signatures: tuple[tuple[object, ...], ...] = ()
else:
try:
expected_signatures = (
_expected_background_signature(background_xml),
)
except ValueError as exc:
errors.append(f"{part} {exc}")
continue
actual_signatures = _background_payload_signatures(root)
if actual_signatures != expected_signatures:
errors.append(
f"{part} p:bg count/payload differs from the exact "
"structured-export expectation"
)
if expected_shape_rosters is not None:
for part, roster in sorted(expected_shape_rosters.items()):
root = reader.xml(part)
if root is None:
continue
_validate_named_shape_roster(
root,
roster,
part,
errors,
exact=True,
ordered=True,
)
layout_parts_by_key: dict[str, str] = {}
keys_by_layout_part: dict[str, str] = {}
slide_roots: dict[int, ET.Element] = {}
@@ -612,7 +897,11 @@ def validate_pptx_template_package(
used_master_parts: set[str] = set()
layout_parts_by_master: dict[str, set[str]] = {}
master_specs_by_part: dict[str, TemplateSlideSpec] = {}
master_parts_by_key: dict[str, str] = {}
keys_by_master_part: dict[str, str] = {}
for layout_key, layout_specs in specs_by_layout.items():
prototype = layout_specs[0]
layout_part = layout_parts_by_key.get(layout_key)
if layout_part is None:
errors.append(f"layout key {layout_key!r} has no resolved Layout part")
@@ -661,20 +950,178 @@ def validate_pptx_template_package(
f"Layout {layout_key!r}",
errors,
)
expected_layout_names = tuple(
f"{item.element_id} Layout"
if item.layer == "layout"
else f"{item.element_id} Placeholder"
for item in prototype.elements
if (
(item.layer == "layout" and not item.is_background)
or item.placeholder
)
)
_validate_named_shape_roster(
layout_root,
expected_layout_names,
f"Layout {layout_key!r}",
errors,
exact=True,
ordered=True,
)
expected_layout_background = any(
item.is_background for item in prototype.layout_elements
)
actual_layout_background = _has_explicit_background(layout_root)
if actual_layout_background != expected_layout_background:
errors.append(
f"Layout {layout_key!r} background ownership is "
f"{actual_layout_background}, expected "
f"{expected_layout_background} from the SVG contract"
)
if (
expected_layout_background
and expected_backgrounds is not None
and layout_part not in expected_backgrounds
):
errors.append(
f"Layout {layout_key!r} has no pre-promotion p:bg payload "
"for exact read-back"
)
slide_placeholders: dict[int, dict[int, _Placeholder]] = {}
for spec in layout_specs:
slide_root = slide_roots.get(spec.slide_num)
if slide_root is None:
continue
expected_slide_bindings = bindings
slide_placeholders[spec.slide_num] = _validate_placeholder_roster(
slide_root,
bindings,
expected_slide_bindings,
f"Slide {spec.slide_num}",
errors,
is_layout=False,
)
expected_slide_background = any(
item.layer == "slide" and item.is_background
for item in spec.elements
)
actual_slide_background = _has_explicit_background(slide_root)
if actual_slide_background != expected_slide_background:
errors.append(
f"Slide {spec.slide_num} background ownership is "
f"{actual_slide_background}, expected "
f"{expected_slide_background} from the SVG contract"
)
slide_part = f"ppt/slides/slide{spec.slide_num}.xml"
if (
expected_slide_background
and expected_backgrounds is not None
and slide_part not in expected_backgrounds
):
errors.append(
f"Slide {spec.slide_num} has no pre-promotion p:bg "
"payload for exact read-back"
)
expected_carrier_names = [
(
f"{item.element_id} Proxy Content"
if is_proxy_placeholder(item)
else f"{item.element_id} Placeholder Carrier"
)
for item in spec.placeholders
]
expected_carrier_names.extend(
"Placeholder Binding "
f"{binding.placeholder_type} {binding.effective_idx}"
for binding in bindings
if is_proxy_placeholder(binding.element)
)
_validate_named_shape_roster(
slide_root,
expected_carrier_names,
f"Slide {spec.slide_num}",
errors,
exact=False,
ordered=True,
)
bindings_by_element = {
binding.element.element_id: binding
for binding in bindings
}
for item in spec.placeholders:
proxy_binding = is_proxy_placeholder(item)
carrier_name = (
f"{item.element_id} Proxy Content"
if proxy_binding
else f"{item.element_id} Placeholder Carrier"
)
carrier = _top_level_shape_by_name(slide_root, carrier_name)
carrier_placeholder = (
carrier.find(f".//{{{PML_NS}}}ph")
if carrier is not None
else None
)
if proxy_binding:
if carrier_placeholder is not None:
errors.append(
f"Slide {spec.slide_num} proxy content "
f"{carrier_name!r} must remain ordinary"
)
elif carrier is not None and carrier_placeholder is None:
errors.append(
f"Slide {spec.slide_num} carrier "
f"{carrier_name!r} must own its p:ph binding"
)
if not proxy_binding:
continue
placeholder_binding = bindings_by_element[item.element_id]
binding_name = (
"Placeholder Binding "
f"{placeholder_binding.placeholder_type} "
f"{placeholder_binding.effective_idx}"
)
binding_shape = _top_level_shape_by_name(
slide_root,
binding_name,
)
if binding_shape is None:
continue
c_nv_pr = next(
binding_shape.iter(f"{{{PML_NS}}}cNvPr"),
None,
)
if (
c_nv_pr is None
or c_nv_pr.get("hidden", "0").lower()
not in {"1", "true"}
):
errors.append(
f"Slide {spec.slide_num} binding proxy "
f"{binding_name!r} must be hidden"
)
if binding_shape.find(f".//{{{PML_NS}}}ph") is None:
errors.append(
f"Slide {spec.slide_num} binding proxy "
f"{binding_name!r} has no p:ph"
)
alpha_values = {
alpha.get("val")
for alpha in binding_shape.findall(
f".//{{{DML_NS}}}srgbClr/{{{DML_NS}}}alpha"
)
}
proxy_text = "".join(
node.text or ""
for node in binding_shape.findall(
f".//{{{DML_NS}}}t"
)
)
if "0" not in alpha_values or proxy_text != "\u200b":
errors.append(
f"Slide {spec.slide_num} binding proxy "
f"{binding_name!r} must contain exactly one fully "
"transparent zero-width run"
)
prototype = layout_specs[0]
prototype_placeholders = slide_placeholders.get(
prototype.slide_num,
{},
@@ -683,7 +1130,9 @@ def validate_pptx_template_package(
idx = binding.effective_idx
layout_placeholder = layout_placeholders.get(idx)
prototype_placeholder = prototype_placeholders.get(idx)
if layout_placeholder is None or prototype_placeholder is None:
if is_proxy_placeholder(binding.element):
prototype_placeholder = None
if layout_placeholder is None:
continue
context = (
f"Layout {layout_key!r} placeholder "
@@ -699,13 +1148,19 @@ def validate_pptx_template_package(
round(value * EMU_PER_PX)
for value in binding.element.placeholder_bounds
)
else:
elif prototype_placeholder is not None:
expected_bounds = _shape_bounds(
prototype_placeholder.shape,
f"Slide {prototype.slide_num} placeholder "
f"{binding.element.element_id!r}",
errors,
)
else:
expected_bounds = None
errors.append(
f"{context} has neither explicit bounds nor a bound "
"prototype Slide placeholder"
)
if (
actual_bounds is not None
and expected_bounds is not None
@@ -716,22 +1171,31 @@ def validate_pptx_template_package(
f"{expected_bounds}"
)
prototype_size = _first_run_size(prototype_placeholder.shape)
if prototype_size is not None:
prompt_size = _first_run_size(layout_placeholder.shape)
prompt_size = _first_run_size(layout_placeholder.shape)
default_size = _level_one_default_size(
layout_placeholder.shape
)
if prototype_placeholder is not None:
prototype_size = _first_run_size(
prototype_placeholder.shape
)
if prototype_size is None:
continue
if prompt_size != prototype_size:
errors.append(
f"{context} prompt size is {prompt_size!r}, expected "
f"{prototype_size!r}"
)
default_size = _level_one_default_size(
layout_placeholder.shape
)
if default_size != prototype_size:
errors.append(
f"{context} level-1 default size is "
f"{default_size!r}, expected {prototype_size!r}"
)
elif prompt_size is not None and default_size != prompt_size:
errors.append(
f"{context} level-1 default size is {default_size!r}, "
f"expected its prompt size {prompt_size!r}"
)
master_target = _single_relationship_target(
reader,
@@ -752,6 +1216,32 @@ def validate_pptx_template_package(
continue
reader.xml(master_part)
used_master_parts.add(master_part)
previous_master_part = master_parts_by_key.setdefault(
prototype.master_key,
master_part,
)
if previous_master_part != master_part:
errors.append(
f"Master key {prototype.master_key!r} targets both "
f"{previous_master_part} and {master_part}"
)
previous_master_key = keys_by_master_part.setdefault(
master_part,
prototype.master_key,
)
if previous_master_key != prototype.master_key:
errors.append(
f"Master keys {previous_master_key!r} and "
f"{prototype.master_key!r} both target {master_part}"
)
previous_master_spec = master_specs_by_part.setdefault(
master_part,
prototype,
)
if previous_master_spec.master_key != prototype.master_key:
errors.append(
f"{master_part} is shared by conflicting SVG Master contracts"
)
layout_parts_by_master.setdefault(master_part, set()).add(
layout_part
)
@@ -761,6 +1251,52 @@ def validate_pptx_template_package(
master_root = reader.xml(master_part)
if master_root is None:
continue
master_spec = master_specs_by_part.get(master_part)
if master_spec is not None:
master_c_sld = master_root.find(f"{{{PML_NS}}}cSld")
actual_master_name = (
master_c_sld.get("name")
if master_c_sld is not None
else None
)
if actual_master_name != master_spec.master_name:
errors.append(
f"Master {master_part} has picker name "
f"{actual_master_name!r}, expected "
f"{master_spec.master_name!r}"
)
_validate_named_shape_roster(
master_root,
tuple(
f"{item.element_id} Master"
for item in master_spec.master_elements
if not item.is_background
),
f"Master {master_part}",
errors,
exact=True,
ordered=True,
)
expected_master_background = any(
item.is_background
for item in master_spec.master_elements
)
if (
expected_master_background
and not _has_explicit_background(master_root)
):
errors.append(
f"Master {master_part} is missing its explicit background"
)
if (
expected_master_background
and expected_backgrounds is not None
and master_part not in expected_backgrounds
):
errors.append(
f"Master {master_part} has no pre-promotion p:bg payload "
"for exact read-back"
)
layout_id_entries = master_root.findall(
f"{{{PML_NS}}}sldLayoutIdLst/"
f"{{{PML_NS}}}sldLayoutId"
@@ -784,10 +1320,64 @@ def validate_pptx_template_package(
f"{previous_owner} and {master_part}"
)
expected_slide_parts = {
f"ppt/slides/slide{spec.slide_num}.xml"
for spec in specs
}
expected_layout_parts = set(layout_parts_by_key.values())
_validate_physical_part_roster(
reader,
expected_slide_parts,
"ppt/slides",
"slide",
"Slide",
)
_validate_physical_part_roster(
reader,
expected_layout_parts,
"ppt/slideLayouts",
"slideLayout",
"Layout",
)
_validate_physical_part_roster(
reader,
used_master_parts,
"ppt/slideMasters",
"slideMaster",
"Master",
)
_validate_content_type_part_roster(
overrides,
expected_slide_parts,
"ppt/slides",
"slide",
SLIDE_CONTENT_TYPE,
errors,
"Slide",
)
_validate_content_type_part_roster(
overrides,
expected_layout_parts,
"ppt/slideLayouts",
"slideLayout",
SLIDE_LAYOUT_CONTENT_TYPE,
errors,
"Layout",
)
_validate_content_type_part_roster(
overrides,
used_master_parts,
"ppt/slideMasters",
"slideMaster",
SLIDE_MASTER_CONTENT_TYPE,
errors,
"Master",
)
_validate_presentation_slide_registration(reader, expected_slide_parts)
_validate_presentation_master_registration(reader, used_master_parts)
except (OSError, zipfile.BadZipFile) as exc:
raise ValueError(f"cannot read template PPTX package {path}: {exc}") from exc
if errors:
details = "\n".join(f" - {error}" for error in errors)
raise ValueError(f"template package read-back failed:\n{details}")
raise ValueError(f"structured package read-back failed:\n{details}")
@@ -9,18 +9,24 @@ from xml.etree import ElementTree as ET
from .chart_read import empty_chart_data, read_chart_data
from .diagram_read import read_smartart_diagrams
from .edit_safety import (
_chart_edit_capability,
_chart_frames,
_chart_reference,
_table_cell_merge_info,
_table_merge_topology,
_unsupported_chart_capability,
)
from .ooxml import (
CHART_REL_TYPE,
NS,
SlideRef,
_chart_containers,
_container_geometry,
_emu_to_px,
_normalize_part,
_paragraph_texts,
_parse_slide_refs,
_read_xml,
_qn,
_shape_identity,
_slide_relationships,
_table_containers,
@@ -36,16 +42,37 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str,
tables: list[dict[str, Any]] = []
for order, container in enumerate(_table_containers(slide_root), start=1):
shape_id, _shape_name = _shape_identity(container, order)
table = container.find(".//a:tbl", NS)
if table is None:
continue
merge_topology = _table_merge_topology(table)
merge_anchors = {
(int(item["row"]), int(item["col"])): item.get("anchor")
for item in merge_topology["slave_cells"]
}
rows: list[dict[str, Any]] = []
max_columns = 0
for row_index, row in enumerate(container.findall(".//a:tbl/a:tr", NS)):
for row_index, row in enumerate(table.findall("a:tr", NS)):
cells: list[dict[str, Any]] = []
for col_index, cell in enumerate(row.findall("a:tc", NS)):
merge_info = _table_cell_merge_info(cell)
merge_anchor = merge_anchors.get((row_index, col_index))
if merge_anchor is not None:
merge_info["merge_anchor"] = merge_anchor
merge_info["anchor_row"] = merge_anchor["row"]
merge_info["anchor_col"] = merge_anchor["col"]
elif merge_info["is_merge_slave"]:
merge_info["anchor_row"] = None
merge_info["anchor_col"] = None
else:
merge_info["anchor_row"] = row_index
merge_info["anchor_col"] = col_index
cells.append(
{
"row": row_index,
"col": col_index,
"text": "\n".join(_paragraph_texts(cell)),
**merge_info,
}
)
max_columns = max(max_columns, len(cells))
@@ -56,6 +83,7 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str,
"row_count": len(rows),
"column_count": max_columns,
"rows": rows,
"merge_topology": merge_topology,
}
)
return tables
@@ -64,19 +92,47 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str,
def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: SlideRef) -> list[dict[str, Any]]:
charts: list[dict[str, Any]] = []
relationships = _slide_relationships(zf, slide_ref.rels_name)
for order, container in enumerate(_chart_containers(slide_root), start=1):
for order, container in enumerate(_chart_frames(slide_root), start=1):
shape_id, _shape_name = _shape_identity(container, order)
chart = container.find(".//c:chart", NS)
rel_id = chart.attrib.get(_qn(NS["r"], "id")) if chart is not None else ""
chart_kind, rel_id = _chart_reference(container)
payload: dict[str, Any] = {"chart_id": f"s{slide_ref.index:02d}_ch{shape_id}"}
payload.update(empty_chart_data())
payload["chart_kind"] = chart_kind
if chart_kind == "chartex":
payload["chart_type"] = "chartEx"
payload["plot_types"] = ["chartEx"]
payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_chartex_unsupported",
"template-fill chart edits do not support ChartEx",
)
charts.append(payload)
continue
if chart_kind != "classic":
payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic DrawingML chart reference",
)
charts.append(payload)
continue
rel = relationships.get(rel_id)
if rel and rel.get("type") == CHART_REL_TYPE:
chart_part = _normalize_part(rel["target"], slide_ref.part_name)
try:
payload.update(read_chart_data(_read_xml(zf, chart_part)))
chart_root = _read_xml(zf, chart_part)
payload.update(read_chart_data(chart_root))
payload["edit_capability"] = _chart_edit_capability(chart_root)
except RuntimeError:
payload.update(empty_chart_data())
payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_part_unavailable",
"template-fill could not read the classic chart part",
)
else:
payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_relationship_unsupported",
"template-fill chart edits require a classic chart relationship",
)
charts.append(payload)
return charts
@@ -10,10 +10,16 @@ from __future__ import annotations
import io
import re
import sys
import zipfile
from typing import Any
from xml.etree import ElementTree as ET
from .edit_safety import (
_chart_frames,
_chart_reference,
_require_supported_chart_edit,
)
from .ooxml import (
CHART_CONTENT_TYPE,
CHART_REL_TYPE,
@@ -21,7 +27,6 @@ from .ooxml import (
PACKAGE_REL_TYPE,
REL_NS,
XLSX_CONTENT_TYPE,
_chart_containers,
_normalize_part,
_qn,
_rels_name_for_part,
@@ -34,11 +39,15 @@ from .selectors import _chart_selectors
def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]:
maps: dict[str, dict[str, str]] = {}
for order, container in enumerate(_chart_containers(slide_root), start=1):
for order, container in enumerate(_chart_frames(slide_root), start=1):
shape_id, shape_name = _shape_identity(container, order)
chart = container.find(".//c:chart", NS)
rel_id = chart.attrib.get(_qn(NS["r"], "id")) if chart is not None else ""
info = {"shape_id": shape_id, "shape_name": shape_name, "rel_id": rel_id or ""}
chart_kind, rel_id = _chart_reference(container)
info = {
"shape_id": shape_id,
"shape_name": shape_name,
"rel_id": rel_id,
"chart_kind": chart_kind,
}
maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info
maps[f"shape_id:{shape_id}"] = info
if shape_name:
@@ -168,6 +177,13 @@ def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) -
def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None:
capability = _require_supported_chart_edit(chart_root)
for warning in capability.get("warnings", []):
if not isinstance(warning, dict):
continue
code = warning.get("code") or "chart_edit_category_flattened"
message = warning.get("message") or "chart categories will be flattened"
print(f" Warning: {message} [{code}]", file=sys.stderr)
categories = [str(item) for item in chart_edit.get("categories", [])]
series_payload = chart_edit.get("series", [])
if not categories or not isinstance(series_payload, list) or not series_payload:
@@ -346,6 +362,17 @@ def _apply_chart_edits_to_slide_package(
continue
errors.append(", ".join(selectors) or "<missing selector>")
continue
chart_kind = chart_info.get("chart_kind", "")
if chart_kind != "classic":
code = (
"chart_edit_chartex_unsupported"
if chart_kind == "chartex"
else "chart_edit_plot_type_unsupported"
)
raise RuntimeError(
"template-fill chart edits require a supported classic chart "
f"[{code}]"
)
rel_id = chart_info.get("rel_id", "")
rel = _find_relationship(rels_root, rel_id)
if rel is None:
@@ -5,6 +5,7 @@ from __future__ import annotations
import unicodedata
from typing import Any
from .edit_safety import _is_verified_category_capability
from .selectors import (
_chart_selectors,
_replacement_selectors,
@@ -55,6 +56,78 @@ def _chart_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, An
return lookup
def _table_cell_lookup(table: dict[str, Any]) -> dict[tuple[int, int], dict[str, Any]]:
lookup: dict[tuple[int, int], dict[str, Any]] = {}
for row in table.get("rows", []):
for cell in row.get("cells", []):
if not isinstance(cell, dict):
continue
try:
key = (int(cell.get("row", -1)), int(cell.get("col", -1)))
except (TypeError, ValueError):
continue
lookup[key] = cell
return lookup
def _table_merge_slave_lookup(
table: dict[str, Any],
) -> dict[tuple[int, int], dict[str, int] | None]:
lookup: dict[tuple[int, int], dict[str, int] | None] = {}
topology = table.get("merge_topology")
if isinstance(topology, dict):
for slave in topology.get("slave_cells", []):
if not isinstance(slave, dict):
continue
try:
key = (int(slave.get("row", -1)), int(slave.get("col", -1)))
except (TypeError, ValueError):
continue
anchor = slave.get("anchor")
lookup[key] = anchor if isinstance(anchor, dict) else None
for key, cell in _table_cell_lookup(table).items():
if cell.get("is_merge_slave") is True or cell.get("merge_role") == "slave":
anchor = cell.get("merge_anchor")
lookup.setdefault(key, anchor if isinstance(anchor, dict) else None)
return lookup
def _chart_edit_capability_review(
chart: dict[str, Any],
) -> tuple[tuple[str, str] | None, list[tuple[str, str]]]:
capability = chart.get("edit_capability")
if not isinstance(capability, dict):
return None, [
(
"chart_edit_capability_unknown",
"chart edit capability is missing; runtime will inspect the actual "
"chart XML before mutation, and re-running template analysis is recommended",
)
]
if capability.get("supported") is not True:
return (
str(capability.get("code") or "chart_edit_capability_unknown"),
str(capability.get("message") or "chart edit capability is unsupported"),
), []
if not _is_verified_category_capability(capability):
return (
"chart_edit_capability_unknown",
"chart edit capability is not the verified single-plot c:cat/c:val model",
), []
capability_warnings: list[tuple[str, str]] = []
raw_warnings = capability.get("warnings")
if isinstance(raw_warnings, list):
for item in raw_warnings:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "chart_edit_category_flattened")
message = str(item.get("message") or "chart categories will be flattened")
capability_warnings.append((code, message))
return None, capability_warnings
def _visual_width(text: str) -> float:
"""Estimate rendered text width in Latin-character units.
@@ -339,10 +412,18 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
continue
row_count = int(table.get("row_count") or 0)
column_count = int(table.get("column_count") or 0)
table_cells = _table_cell_lookup(table)
merge_slaves = _table_merge_slave_lookup(table)
for cell in cells:
row = int(cell.get("row", -1))
col = int(cell.get("col", -1))
if row < 0 or col < 0 or row >= row_count or col >= column_count:
if (
row < 0
or col < 0
or row >= row_count
or col >= column_count
or (table_cells and (row, col) not in table_cells)
):
results.append(
{
"status": "ERROR",
@@ -355,6 +436,32 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
)
summary["error"] += 1
continue
if (row, col) in merge_slaves:
anchor = merge_slaves[(row, col)]
anchor_hint = ""
if anchor is not None:
anchor_hint = (
f"; edit merge anchor row={anchor.get('row')} "
f"col={anchor.get('col')} instead"
)
results.append(
{
"status": "ERROR",
"code": "table_cell_is_merge_slave",
"plan_slide": slide_index,
"source_slide": source_slide,
"selector": selectors[0] if selectors else "",
"table_id": table.get("table_id"),
"row": row,
"col": col,
"message": (
f"table cell row={row} col={col} is a merged-cell slave"
f"{anchor_hint}"
),
}
)
summary["error"] += 1
continue
summary["ok"] += 1
results.append(
{
@@ -397,23 +504,35 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
)
summary["error"] += 1
continue
if len(chart.get("plot_types") or []) > 1:
capability_error, capability_warnings = _chart_edit_capability_review(chart)
if capability_error is not None:
error_code, error_message = capability_error
results.append(
{
"status": "ERROR",
"code": "chart_combo_unsupported",
"code": error_code,
"plan_slide": slide_index,
"source_slide": source_slide,
"selector": selectors[0] if selectors else "",
"chart_id": chart.get("chart_id"),
"message": (
"template-fill chart edits do not support multi-plot / combination charts; "
"use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
),
"message": error_message,
}
)
summary["error"] += 1
continue
for warning_code, warning_message in capability_warnings:
results.append(
{
"status": "WARN",
"code": warning_code,
"plan_slide": slide_index,
"source_slide": source_slide,
"selector": selectors[0] if selectors else "",
"chart_id": chart.get("chart_id"),
"message": warning_message,
}
)
summary["warn"] += 1
categories = chart_edit.get("categories", [])
series = chart_edit.get("series", [])
if not isinstance(categories, list) or not isinstance(series, list) or not series:
@@ -0,0 +1,294 @@
"""Classify template-fill chart and table edits before package mutation."""
from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from .ooxml import NS, _qn
_XY_PLOTS = frozenset({"bubbleChart", "scatterChart"})
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _tag_namespace(tag: str) -> str:
if not tag.startswith("{") or "}" not in tag:
return ""
return tag[1:].split("}", 1)[0]
def _chart_reference(frame: ET.Element) -> tuple[str, str]:
"""Return ``(classic|chartex|unknown, relationship id)`` for a chart frame."""
graphic_data = frame.find(".//a:graphicData", NS)
if graphic_data is None:
return "", ""
for node in graphic_data.iter():
if _local_name(node.tag) != "chart":
continue
namespace = _tag_namespace(node.tag)
rel_id = node.attrib.get(_qn(NS["r"], "id"), "")
if namespace == NS["c"]:
return "classic", rel_id
if "chartex" in namespace.lower():
return "chartex", rel_id
return "unknown", rel_id
uri = graphic_data.attrib.get("uri", "").lower()
if "chartex" in uri:
return "chartex", ""
if "chart" in uri:
return "unknown", ""
return "", ""
def _chart_frames(slide_root: ET.Element) -> list[ET.Element]:
"""Return classic, ChartEx, and unknown chart graphic frames."""
return [
frame
for frame in slide_root.findall(".//p:graphicFrame", NS)
if _chart_reference(frame)[0]
]
def _unsupported_chart_capability(
code: str,
message: str,
*,
plot_type: str | None = None,
plot_count: int = 0,
data_model: str = "unknown",
) -> dict[str, Any]:
return {
"supported": False,
"code": code,
"message": message,
"plot_type": plot_type,
"plot_count": plot_count,
"data_model": data_model,
}
def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
"""Classify whether the category cache writer can safely edit a chart part."""
root_namespace = _tag_namespace(chart_root.tag)
if "chartex" in root_namespace.lower():
return _unsupported_chart_capability(
"chart_edit_chartex_unsupported",
"template-fill chart edits do not support ChartEx",
)
if root_namespace != NS["c"]:
return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic DrawingML chart part",
)
plot_area = chart_root.find(".//c:plotArea", NS)
if plot_area is None:
return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic chart plotArea",
)
plot_nodes = [
child
for child in list(plot_area)
if _tag_namespace(child.tag) == NS["c"] and _local_name(child.tag).endswith("Chart")
]
if len(plot_nodes) > 1:
return _unsupported_chart_capability(
"chart_edit_multi_plot_unsupported",
"template-fill chart edits do not support multi-plot or combination charts",
plot_count=len(plot_nodes),
)
if not plot_nodes:
return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported",
"template-fill chart edits require exactly one recognized chart plot",
)
plot = plot_nodes[0]
plot_type = _local_name(plot.tag)
if plot_type == "scatterChart":
return _unsupported_chart_capability(
"chart_edit_scatter_unsupported",
"template-fill chart edits do not support scatter xVal/yVal data",
plot_type=plot_type,
plot_count=1,
data_model="xy",
)
if plot_type == "bubbleChart":
return _unsupported_chart_capability(
"chart_edit_bubble_unsupported",
"template-fill chart edits do not support bubble xVal/yVal/bubbleSize data",
plot_type=plot_type,
plot_count=1,
data_model="bubble",
)
series_nodes = plot.findall("c:ser", NS)
if not series_nodes:
return _unsupported_chart_capability(
"chart_edit_no_series",
"template-fill chart edits require at least one editable series",
plot_type=plot_type,
plot_count=1,
data_model="category",
)
for series in series_nodes:
if any(
series.find(f"c:{tag}", NS) is not None
for tag in ("xVal", "yVal", "bubbleSize")
):
return _unsupported_chart_capability(
"chart_edit_data_model_unsupported",
"template-fill chart edits require c:cat/c:val series",
plot_type=plot_type,
plot_count=1,
)
category = series.find("c:cat", NS)
values = series.find("c:val", NS)
if category is None or values is None:
return _unsupported_chart_capability(
"chart_edit_data_model_unsupported",
"template-fill chart edits require c:cat/c:val on every series",
plot_type=plot_type,
plot_count=1,
)
capability_warnings: list[dict[str, str]] = []
if plot_area.find("c:dateAx", NS) is not None:
capability_warnings.append(
{
"code": "chart_edit_date_axis_flattened",
"message": (
"template-fill will flatten date-axis categories to the "
"replacement single-level category cache"
),
}
)
if any(
series.find("c:cat/c:multiLvlStrRef", NS) is not None
for series in series_nodes
):
capability_warnings.append(
{
"code": "chart_edit_multilevel_categories_flattened",
"message": (
"template-fill will flatten multi-level categories to the "
"replacement single-level category cache"
),
}
)
return {
"supported": True,
"code": "chart_edit_category_single_plot",
"message": "single classic plot uses c:cat/c:val series",
"plot_type": plot_type,
"plot_count": 1,
"data_model": "category",
"warnings": capability_warnings,
}
def _is_verified_category_capability(capability: Any) -> bool:
"""Return whether an analyzer capability matches the runtime structural gate."""
return bool(
isinstance(capability, dict)
and capability.get("supported") is True
and capability.get("code") == "chart_edit_category_single_plot"
and capability.get("data_model") == "category"
and capability.get("plot_count") == 1
and isinstance(capability.get("plot_type"), str)
and bool(capability.get("plot_type"))
and capability.get("plot_type") not in _XY_PLOTS
)
def _require_supported_chart_edit(chart_root: ET.Element) -> dict[str, Any]:
"""Raise before mutation unless ``chart_root`` uses the verified category model."""
capability = _chart_edit_capability(chart_root)
if not _is_verified_category_capability(capability):
code = capability.get("code") or "chart_edit_capability_unknown"
message = capability.get("message") or "chart edit capability is unknown"
raise RuntimeError(f"{message} [{code}]")
return capability
def _ooxml_bool(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "on", "true"}
def _positive_span(value: str | None) -> int:
try:
return max(int(value or "1"), 1)
except ValueError:
return 1
def _table_cell_merge_info(cell: ET.Element) -> dict[str, Any]:
"""Return the merge role encoded directly on one physical ``a:tc`` cell."""
h_merge = _ooxml_bool(cell.attrib.get("hMerge"))
v_merge = _ooxml_bool(cell.attrib.get("vMerge"))
row_span = _positive_span(cell.attrib.get("rowSpan"))
col_span = _positive_span(cell.attrib.get("gridSpan"))
is_merge_slave = h_merge or v_merge
is_merge_anchor = not is_merge_slave and (row_span > 1 or col_span > 1)
merge_role = "slave" if is_merge_slave else "anchor" if is_merge_anchor else "none"
return {
"merge_role": merge_role,
"is_merge_anchor": is_merge_anchor,
"is_merge_slave": is_merge_slave,
"row_span": row_span,
"col_span": col_span,
"h_merge": h_merge,
"v_merge": v_merge,
}
def _table_merge_topology(table: ET.Element) -> dict[str, Any]:
"""Describe merge anchors and physical slave cells in a DrawingML table."""
cell_states: list[tuple[int, int, dict[str, Any]]] = []
anchors: list[dict[str, int]] = []
for row_index, row in enumerate(table.findall("a:tr", NS)):
for col_index, cell in enumerate(row.findall("a:tc", NS)):
info = _table_cell_merge_info(cell)
cell_states.append((row_index, col_index, info))
if info["is_merge_anchor"]:
anchors.append(
{
"row": row_index,
"col": col_index,
"row_span": info["row_span"],
"col_span": info["col_span"],
}
)
covered_by: dict[tuple[int, int], dict[str, int]] = {}
for anchor in anchors:
for row_index in range(anchor["row"], anchor["row"] + anchor["row_span"]):
for col_index in range(anchor["col"], anchor["col"] + anchor["col_span"]):
if (row_index, col_index) == (anchor["row"], anchor["col"]):
continue
covered_by.setdefault(
(row_index, col_index),
{"row": anchor["row"], "col": anchor["col"]},
)
slave_cells: list[dict[str, Any]] = []
for row_index, col_index, info in cell_states:
if not info["is_merge_slave"]:
continue
slave: dict[str, Any] = {"row": row_index, "col": col_index}
anchor = covered_by.get((row_index, col_index))
if anchor is not None:
slave["anchor"] = anchor
slave_cells.append(slave)
return {
"has_merges": bool(anchors or slave_cells),
"anchors": anchors,
"slave_cells": slave_cells,
}
@@ -75,6 +75,13 @@ def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element:
def _xml_bytes(root: ET.Element) -> bytes:
root_namespace = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else ""
if root_namespace in {REL_NS, CT_NS}:
# OPC relationship/content-type roots conventionally use the default
# namespace. ElementTree's global prefix registry can be changed while
# parsing source parts; restore the package-root form before writing so
# strict consumers such as LibreOffice accept the generated package.
ET.register_namespace("", root_namespace)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from .edit_safety import _table_cell_merge_info
from .ooxml import NS, _shape_identity, _table_containers
from .selectors import _table_cell_text, _table_selectors
from .text_fill import _set_container_text
@@ -32,6 +33,7 @@ def _apply_table_edits_to_slide(
) -> None:
maps = _table_key_maps(slide_root, source_slide)
errors: list[str] = []
pending_edits: list[tuple[ET.Element, str]] = []
for table_edit in table_edits:
selectors = _table_selectors(table_edit)
table_frame = next((maps[key] for key in selectors if key in maps), None)
@@ -54,6 +56,16 @@ def _apply_table_edits_to_slide(
if col_index < 0 or col_index >= len(row_cells):
errors.append(f"{selectors[0] if selectors else '<table>'} row={row_index} col={col_index}")
continue
_set_container_text(row_cells[col_index], _table_cell_text(cell_edit))
target_cell = row_cells[col_index]
if _table_cell_merge_info(target_cell)["is_merge_slave"]:
target = selectors[0] if selectors else "<table>"
errors.append(
f"{target} row={row_index} col={col_index} is a merged-cell slave "
"[table_cell_is_merge_slave]; edit the merge anchor instead"
)
continue
pending_edits.append((target_cell, _table_cell_text(cell_edit)))
if errors:
raise RuntimeError(f"Invalid table edit target(s) on slide {source_slide}: {'; '.join(errors)}")
for target_cell, text in pending_edits:
_set_container_text(target_cell, text)
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
PPT Master - Template Preview PPTX Exporter
Export every SVG prototype in a template workspace as one structured review deck.
Usage:
python3 scripts/template_preview_pptx.py <template_workspace> [-o output.pptx]
Examples:
python3 scripts/template_preview_pptx.py projects/my_template
python3 scripts/template_preview_pptx.py templates/decks/my_template -o review.pptx
python3 scripts/template_preview_pptx.py templates/decks/legacy --visual-only
Dependencies:
python-pptx
"""
from __future__ import annotations
import argparse
import math
import re
import statistics
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
from pptx import Presentation # noqa: E402
from svg_to_pptx.drawingml.theme_fonts import ( # noqa: E402
MasterTextStyleSpec,
)
from svg_to_pptx.drawingml.utils import font_px_to_hpt # noqa: E402
from svg_to_pptx.pptx_package.builder import ( # noqa: E402
create_pptx_with_native_svg,
)
_FRONTMATTER_ID_RE = re.compile(
r"^(?:template_id|deck_id|layout_id)\s*:\s*(.+?)\s*$",
re.MULTILINE,
)
_FONT_SIZE_RE = re.compile(r"^([0-9]+(?:\.[0-9]+)?)(?:px)?$")
_FILENAME_UNSAFE_RE = re.compile(r"[\\/:*?\"<>|\x00-\x1f]+")
_TITLE_PLACEHOLDERS = frozenset({"title", "subtitle"})
_BODY_PLACEHOLDERS = frozenset({
"body",
"date",
"footer",
"slide-number",
})
_DEFAULT_TITLE_PX = 40.0
_DEFAULT_BODY_PX = 24.0
def _resolve_workspace(path: Path) -> tuple[Path, Path]:
"""Resolve one workspace root and its canonical template-source directory."""
candidate = path.expanduser().resolve()
nested_spec = candidate / "templates" / "design_spec.md"
if nested_spec.is_file():
return candidate, candidate / "templates"
direct_spec = candidate / "design_spec.md"
if direct_spec.is_file():
if candidate.name == "templates" and (candidate.parent / "exports").is_dir():
return candidate.parent, candidate
return candidate, candidate
raise ValueError(
"template workspace must contain templates/design_spec.md "
"(current structure) or design_spec.md (legacy flat package)"
)
def _template_id(spec_path: Path, workspace: Path) -> str:
"""Read a portable template id, falling back to the workspace directory name."""
text = spec_path.read_text(encoding="utf-8")
match = _FRONTMATTER_ID_RE.search(text)
raw = match.group(1).strip().strip("'\"") if match else workspace.name
safe = _FILENAME_UNSAFE_RE.sub("_", raw).strip(" ._")
return safe or "template"
def _style_property(style: str, name: str) -> str | None:
"""Return one inline CSS declaration value."""
for declaration in style.split(";"):
key, separator, value = declaration.partition(":")
if separator and key.strip().lower() == name:
return value.strip()
return None
def _font_size_px(element: ET.Element) -> float | None:
"""Read one finite positive SVG font size in px."""
raw = element.get("font-size")
if raw is None:
raw = _style_property(element.get("style", ""), "font-size")
if raw is None:
return None
match = _FONT_SIZE_RE.fullmatch(raw.strip())
if match is None:
return None
value = float(match.group(1))
return value if math.isfinite(value) and value > 0 else None
def _carrier_sizes(svg_files: list[Path]) -> tuple[list[float], list[float]]:
"""Collect authored title/body sizes from semantic placeholder carriers."""
title_sizes: list[float] = []
body_sizes: list[float] = []
for svg_path in svg_files:
root = ET.parse(svg_path).getroot()
for slot in root.iter():
placeholder = slot.get("data-pptx-placeholder")
if placeholder not in _TITLE_PLACEHOLDERS | _BODY_PLACEHOLDERS:
continue
for carrier in slot.iter():
if carrier.get("data-pptx-placeholder-carrier") != "true":
continue
size = _font_size_px(carrier)
if size is None:
continue
target = title_sizes if placeholder in _TITLE_PLACEHOLDERS else body_sizes
target.append(size)
return title_sizes, body_sizes
def _master_text_style(svg_files: list[Path]) -> tuple[MasterTextStyleSpec, float, float]:
"""Build review-only Master text defaults without requiring a project lock."""
title_sizes, body_sizes = _carrier_sizes(svg_files)
title_px = float(statistics.median(title_sizes)) if title_sizes else _DEFAULT_TITLE_PX
body_px = float(statistics.median(body_sizes)) if body_sizes else _DEFAULT_BODY_PX
return (
MasterTextStyleSpec(
title_hpt=font_px_to_hpt(title_px),
body_hpt=font_px_to_hpt(body_px),
),
title_px,
body_px,
)
def _verify_output(output_path: Path) -> tuple[int, int, int]:
"""Reopen the review deck and return slide/master/layout counts."""
presentation = Presentation(str(output_path))
master_count = len(presentation.slide_masters)
layout_count = sum(len(master.slide_layouts) for master in presentation.slide_masters)
return len(presentation.slides), master_count, layout_count
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Export a complete template workspace as a structured PPTX review deck."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"template_workspace",
help=(
"Workspace containing templates/design_spec.md; legacy flat template "
"directories are also accepted."
),
)
parser.add_argument(
"-o",
"--output",
help=(
"Output PPTX path. Default: "
"<template_workspace>/exports/<template_id>_template_preview.pptx"
),
)
parser.add_argument(
"--force",
action="store_true",
help="Replace an existing review PPTX after an intentional re-export.",
)
parser.add_argument(
"--visual-only",
action="store_true",
help=(
"Export a legacy SVG roster as slide-local DrawingML for visual review. "
"This does not validate or claim a reusable Master/Layout contract."
),
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
workspace, template_dir = _resolve_workspace(Path(args.template_workspace))
svg_files = sorted(template_dir.glob("*.svg"))
if not svg_files:
raise ValueError(f"template directory has no SVG prototypes: {template_dir}")
template_id = _template_id(template_dir / "design_spec.md", workspace)
output_path = (
Path(args.output).expanduser().resolve()
if args.output
else workspace / "exports" / f"{template_id}_template_preview.pptx"
)
if output_path.suffix.lower() != ".pptx":
raise ValueError(f"output must use a .pptx extension: {output_path}")
if output_path.exists() and not args.force:
raise ValueError(
f"output already exists: {output_path}; use --force to replace it"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
text_style: MasterTextStyleSpec | None = None
if not args.visual_only:
text_style, title_px, body_px = _master_text_style(svg_files)
print("PPT Master - Template Preview PPTX Exporter")
print(f" Workspace: {workspace}")
print(f" Template source: {template_dir}")
print(f" SVG prototypes: {len(svg_files)}")
if args.visual_only:
print(" Review mode: visual-only legacy compatibility")
else:
print(f" Review Master defaults: title {title_px:g}px, body {body_px:g}px")
print(f" Output: {output_path}")
success = create_pptx_with_native_svg(
svg_files=svg_files,
output_path=output_path,
canvas_format=None,
verbose=True,
transition=None,
enable_notes=False,
animation=None,
image_optimize=False,
native_objects=True,
pptx_structure="flat" if args.visual_only else "structured",
master_text_style_spec=text_style,
)
if not success or not output_path.is_file():
print("Error: template preview export did not produce a PPTX", file=sys.stderr)
return 1
slide_count, master_count, layout_count = _verify_output(output_path)
if slide_count != len(svg_files):
print(
"Error: review PPTX slide count does not match the template SVG roster "
f"({slide_count} != {len(svg_files)})",
file=sys.stderr,
)
return 1
label = "Visual-only template preview" if args.visual_only else "Template preview"
print(
f"[OK] {label} verified: "
f"{slide_count} slides, {master_count} master(s), {layout_count} layout(s)"
)
print(output_path)
return 0
except (OSError, ET.ParseError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -21,9 +21,11 @@ The `layouts/` directory contains pre-built page layout templates organized by d
- **Human browsing**: [layouts/README.md](./layouts/README.md)
- **Slim lookup (discovery only)**: [layouts/layouts_index.json](./layouts/layouts_index.json) — used to answer "what templates exist?". Step 3 triggers on an explicit directory path supplied by the user, not on names from this index.
Every brand/layout/deck package uses one workspace routing contract. Whether created under this library or under `projects/`, source files live in `templates/`, bitmaps in `images/`, runtime icons in `icons/`, and on-demand review files in `exports/`. Empty optional directories are omitted, so a normal checked-in workspace has no `exports/`. Library `exports/` directories are Git-ignored, and Step 3 never copies them. Existing flat packages remain readable; flat placement alone does not imply legacy Master/Layout metadata.
## Brand Identity Presets
The `brands/` directory holds brand-only templates: identity bundles (color / typography / logo / voice / icon style) without an SVG page roster. Brands follow the **same explicit-path trigger rule as layout templates** — at SKILL.md Step 3 the user supplies the brand directory path to apply it; bare brand names never trigger. Both layout and brand inputs land in the same project directory (`<project_path>/templates/`). When supplied together, Step 3 fuses them into a single `design_spec.md` (brand wins on identity tokens, layout wins on page structure) — see `SKILL.md` Step 3 for the precedence table.
The `brands/` directory holds brand-only templates: identity bundles (color / typography / logo / voice / icon style) without an SVG page roster. Brands follow the **same explicit-path trigger and workspace routing as layout templates** — at SKILL.md Step 3 the user supplies the workspace root; bare brand names never trigger. `templates/`, `images/`, and `icons/` map to their matching project peers. When supplied together, Step 3 fuses them into one `design_spec.md` (brand wins on identity tokens, layout wins on page structure) — see `SKILL.md` Step 3 for the precedence table.
A brand is structurally a layout template minus its page roster. Use a brand when the user wants identity locking with free page layout; use a layout template when fixed page structures are also required.

Some files were not shown because too many files have changed in this diff Show More