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-12 00:01:51 +08:00
parent a61708780e
commit 1682603705
96 changed files with 9209 additions and 2598 deletions
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-07-10T16:01:26Z"
"syncedAt": "2026-07-11T16:01:50Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "46681d90f0fd92bb22fbf38b43b507faad47a812",
"commit": "1bd2fd585aac793ca2589e6f18f17a412fd11005",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-07-10T16:00:00Z"
"syncedAt": "2026-07-11T15:59:59Z"
}
+2 -2
View File
@@ -90,7 +90,7 @@ English | [中文](./README_CN.md)
---
Drop in your source material, and the deck you get back is **more than just editable**: it has native slide transitions and entrance animations, speaker notes that can become audio narration, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — a complete deck you can present as-is and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
Drop in your source material, and the deck you get back is **more than just editable**: it has native slide transitions plus opt-in entrance animations (off by default), speaker notes that can become audio narration, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — a complete deck you can present as-is and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
## Product Positioning
@@ -306,7 +306,7 @@ AI: Sure. Let's confirm the design spec:
The AI handles everything — content analysis, visual design, SVG generation, and PPTX export.
> **Output:** Native-shapes `.pptx` (directly editable) saved to `exports/<name>_<timestamp>.pptx`. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. Pass `--svg-snapshot` to additionally emit an SVG-image preview pptx alongside the native pptx in `exports/` (see [FAQ](./docs/faq.md)). Requires Office 2016+. By default charts and tables export as SVG-derived shapes (pixel-consistent across PowerPoint / Keynote / WPS); pass `--native-objects` to instead emit them as **real editable PowerPoint chart / table objects backed by data** (rendering may vary across apps), saved as `exports/<name>_<timestamp>_native_charts.pptx`.
> **Output:** The SVG pipeline has one PPTX export route: PPT Master's converter reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. Step 7 still always runs `finalize_svg.py`, producing self-contained files in `svg_final/` for visual inspection or manual insertion as SVG pictures; PowerPoint's manual **Convert to Shape** command is outside the supported contract. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. By default charts and tables export as SVG-derived shapes (pixel-consistent across PowerPoint / Keynote / WPS); pass `--native-objects` to instead emit them as **real editable PowerPoint chart / table objects backed by data** (rendering may vary across apps), saved as `exports/<name>_<timestamp>_native_charts.pptx`.
> **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md).
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "3f3f2418163ccb438d010f3acbce4a3651b758a5",
"commit": "dbf1e363abb4dba36f7c883ee7ba1495073394d0",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-10T16:00:00Z"
"syncedAt": "2026-07-11T15:59:59Z"
}
@@ -16,7 +16,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
| Any route that authors or regenerates slide visuals through SVG | `svg_output/` is the complete page-design source: every visible text, image, shape, chart/table fallback, and layout element that should appear on the exported slide is present in that page SVG or referenced by it. |
| Templates, `design_spec.md`, and `spec_lock.md` | Authoring/control inputs. They guide SVG creation but MUST NOT supply visible slide content that is absent from the completed SVG during export. |
| Semantic SVG markers | Minimal rendering-neutral compiler hints used only after existing Layout/Layer/Placeholder/Native metadata has been considered. They never replace native SVG geometry, text, styles, grouping, or asset references. |
| SVG-to-PPTX export | Translation only: map SVG content to DrawingML/native objects and reorganize represented content into Master/Layout/Slide structure without inventing new visible page content. |
| `svg_final/` | Mandatory derived, self-contained SVG visual preview. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. |
| SVG-to-PPTX export | The only supported generated-PPTX route reads `svg_output/` and maps its content through the project converter to DrawingML/native objects. It may reorganize represented content into Master/Layout/Slide structure but MUST NOT invent new visible page content. |
| Direct PPTX and presentation-behavior workflows | Remain separate. `template-fill-pptx`, `native-enhance-pptx`, animations, transitions, speaker notes, narration, and package relationships are not required to round-trip through SVG. |
**MUST — page-design closure**: For an SVG-authoring route, inspect the final page SVG to determine what the exported slide looks like. Do not reinterpret “SVG is the page-design language” as “SVG is the complete PPTX package description language.”
@@ -78,7 +79,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
| Script | Purpose |
|--------|---------|
| `${SKILL_DIR}/scripts/source_to_md.py` | Unified source-to-Markdown dispatcher — default Step 1 entry for explicit file(s) or URL(s) |
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data |
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data / SmartArt structure |
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
| `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) |
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
@@ -197,7 +198,7 @@ For PPTX sources, `import-sources` automatically runs the standard intake enrich
python3 ${SKILL_DIR}/scripts/pptx_intake.py <project_path>/sources/<source.pptx> -o <project_path>/analysis
```
For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, observed usage) and `<stem>.slide_library.json` (text slots, geometry, native tables, native chart caches), and merges that deck's Strategist-facing digest into the single multi-deck index `analysis/source_profile.json` (`decks[]`, one self-contained entry per source deck, with prefixed artifact pointers). In the main generation path these are source facts and recommendation candidates, not replica constraints; beautify and template-fill workflows decide separately which fields become locked constraints.
For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, observed usage) and `<stem>.slide_library.json` (text slots, geometry, native tables, native chart caches, SmartArt nodes/connections), and merges that deck's Strategist-facing digest into the single multi-deck index `analysis/source_profile.json` (`decks[]`, one self-contained entry per source deck, with prefixed artifact pointers). In the main generation path these are source facts and recommendation candidates, not replica constraints; beautify and template-fill workflows decide separately which fields become locked constraints.
Multi-deck: several PPTX files may be imported into one main-pipeline project — each gets its own `<stem>.*` artifacts and a deck entry in `source_profile.json`. `source_profile.json` stays the single must-read index (one entry for a one-deck project, several for a combined-source project). Stems must be distinct; re-importing the same stem replaces that deck's entry. The beautify / template-fill workflows remain single-deck (1:1 to one chosen source deck) and read that deck's `<stem>.*` artifacts.
@@ -217,11 +218,12 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project
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. 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>/templates/` output 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 |
| 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.
@@ -261,16 +263,40 @@ The architecture has three independent reference bundles. Full schema in [`docs/
| `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). |
```bash
TEMPLATE_DIR=<user-supplied path>
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.
cp -r ${TEMPLATE_DIR}/* <project_path>/templates/
find <project_path>/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/ \;
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
```
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/`.)
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.
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.
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 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.
@@ -330,26 +356,26 @@ Single-path Step 3 does **not** add provenance (the source is self-evident from
The fused frontmatter `kind` describes the resulting bundle: `deck` when both identity and structure are present, `layout` when only structure is present, and `brand` when only identity is present. Keep this field accurate; the Strategist confirmation server uses it to show template adherence only for bundles that actually own page structure.
**✅ Checkpoint — Default path proceeds to Step 4 without user interaction. If the user supplied one or more explicit template paths, those have been dispatched (or fused) into `<project_path>/templates/` before advancing.**
**✅ Checkpoint — Default path proceeds to Step 4 without user interaction. If the user supplied one or more explicit template paths, those have been copied, staged in place, or fused into `<project_path>/templates/` before advancing.**
---
### Step 4: Strategist Phase (MANDATORY — cannot be skipped)
🚧 **GATE**: Step 3 complete; default free-design path taken, or (if triggered) template files copied into the project.
🚧 **GATE**: Step 3 complete; default free-design path taken, or (if triggered) template files copied or confirmed in place in the project.
First, read the role definition:
```
Read references/strategist.md
```
> ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full IXI section structure. See `strategist.md` Section 1.
> ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full IX section structure. See `strategist.md` Section 1.
**Artifact ownership**: fact-channel and source/derived artifact boundaries are defined in [`references/artifact-ownership.md`](references/artifact-ownership.md). This Step uses those ownership rules; it does not redefine them.
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Strategist confirmation stage, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Strategist confirmation stage, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table / SmartArt entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the content-type files in `sources/`** — primarily `<stem>.md`, but also any user-supplied content the import archived there: `.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml` (a `metrics.json` or `data.csv` may carry core content — judge by what the file holds). Text, tables, and chart data values come from these (`ppt_to_md` now transcribes native chart data into Markdown tables). **Do NOT read pipeline sidecars in `sources/` as content**: `*.conversion_profile.json` (conversion audit) and `*_files/image_manifest.json` (asset index) are process metadata — open them only to audit a conversion or resolve assets, never as slide content. Converted-source originals archived in `sources/` (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) are read via their converted `<stem>.md`, not scanned directly in the main pipeline. The `analysis/` chart / table entries are a **structural digest** for outline decisions (which slides carried charts, type, series names) — not a second copy of the values; do NOT also pull chart values from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract; beautify uses it for native chart / table data while keeping slide text from the Markdown.
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the content-type files in `sources/`** — primarily `<stem>.md`, but also any user-supplied content the import archived there: `.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml` (a `metrics.json` or `data.csv` may carry core content — judge by what the file holds). Text, tables, chart data values, and SmartArt node wording come from these (`ppt_to_md` transcribes native charts as Markdown tables and SmartArt nodes as hierarchical bullets). **Do NOT read pipeline sidecars in `sources/` as content**: `*.conversion_profile.json` (conversion audit) and `*_files/image_manifest.json` (asset index) are process metadata — open them only to audit a conversion or resolve assets, never as slide content. Converted-source originals archived in `sources/` (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) are read via their converted `<stem>.md`, not scanned directly in the main pipeline. The `analysis/` chart / table / diagram entries are a **structural digest** for outline decisions (which slides carried charts, tables, or SmartArt; chart types / series names; SmartArt layout and hierarchy) — not a second copy of the content values; do NOT also pull chart values or SmartArt wording from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract while preserving SmartArt unchanged; beautify uses it for native chart / table data and SmartArt relationships while keeping all wording from the Markdown.
**Strategist confirmation stage** (full template: `templates/design_spec_reference.md`):
@@ -641,18 +667,19 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
> ⚠️ Run the three sub-steps **one at a time** — each must complete successfully before the next.
> ❌ **NEVER** combine them into a single code block or shell invocation.
Canonical three-command pipeline (mirrors `references/shared-standards.md` §5):
Canonical three-command pipeline (this step is the workflow authority;
`references/shared-standards.md` §5 points here):
**Step 7.1** — Split speaker notes:
```bash
python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>
```
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / raster image optimization / text flattening / rounded rect to path):
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / raster image optimization / text flattening):
```bash
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
```
Default raster handling for `svg_final/`: images are embedded at the rendered SVG size budget (`--image-scale 2`, `--max-dimension 2560`), opaque PNG photos may be written as JPEG, and transparent assets remain PNG. Use `--no-compress` or a higher `--max-dimension` only for diagnostic / high-fidelity SVG snapshots.
This mandatory step writes self-contained visual-preview SVGs to `svg_final/`. Those files may be opened directly or manually inserted into PowerPoint as SVG pictures. Default raster handling embeds images at the rendered SVG size budget (`--image-scale 2`, `--max-dimension 2560`); opaque PNG photos may be written as JPEG, and transparent assets remain PNG. The existing EMF/WMF exception still applies: Office vector assets stay externally referenced for lossless native-PPTX passthrough, so the native PPTX remains the source of truth for pages that use them. Use `--no-compress` or a higher `--max-dimension` only for diagnostic / high-fidelity SVG previews.
**Step 7.3** — Export PPTX (embeds speaker notes by default):
```bash
@@ -663,9 +690,6 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
# backup/<timestamp>/templates/{native_structure.json,source_template.pptx}
# ← preserve mode only
# backup/<timestamp>/spec_lock.md ← preserve mode only
#
# Add --svg-snapshot to additionally emit the SVG-image preview pptx alongside the native pptx:
# exports/<project_name>_<timestamp>_svg.pptx ← SVG preview pptx (reads svg_final/)
# 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:
@@ -686,11 +710,19 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> The `svg_output/`
> 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 pptx is opt-in via `--svg-snapshot`. Preserve mode also backs up the
> paired native structure/source files and `spec_lock.md` under the same timestamp.
> Live preview already provides the
> SVG visual reference, so it's only needed when you want a self-contained file
> to share. Pass `-s output` or `-s final` to force a single source if you need it.
> 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
> when comparing conversion behavior against the post-processed SVGs; it does
> not change `svg_output/` ownership or establish a supported release route.
> **Supported PPTX boundary** — the only supported generated-PPTX path is
> `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The
> project does not emit an SVG-image PPTX and does not support PowerPoint's
> manual **Convert to Shape** operation on `svg_final/`. Inserted `svg_final/`
> 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
@@ -776,6 +808,21 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> 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.
@@ -801,11 +848,26 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> claiming editability.
**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`.
- `-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.
- `-a <effect>` — per-element entrance animation. **Default `none`** — pages appear as a whole, no auto-firing element builds (the unsolicited cascade reads as the "AI deck" tell). Opt in with `auto` (map effect from group id: chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade; image-like ids `hero` / `figure-` / `image` / `img-` / `kpi` cycle a richer pool — zoom / dissolve / circle / box / diamond / wheel — so multiple images vary across the deck), a specific effect like `fade`, or `mixed` for the legacy 16-effect cycle. Requires top-level `<g id="...">` groups (already required by Executor).
- `--animation-trigger {on-click,with-previous,after-previous}` — Start mode (matches PowerPoint's animation-pane Start dropdown). Default `after-previous` (click-free cascade; pace via `--animation-stagger`). Use `on-click` for presenter-paced reveals, or `with-previous` for all-at-once.
- `--animation-config <path>` — optional object-level sidecar. Default: `<project_path>/animations.json` when present.
- `--auto-advance <seconds>` — kiosk-style auto-play.
- `--auto-advance <seconds>` — kiosk-style auto-play. Click remains enabled, so click or timer may advance the slide.
**Animation compatibility gate**: the default element animation remains `none`.
When animation is enabled, unknown effects/modes/triggers, invalid numeric or
order values, missing slide/group references, and explicit structural layer,
static-role, or static-placeholder targets fail export; they never downgrade
or disappear silently. An explicit
sidecar group may override only the legacy chrome-name heuristic. `random`
resolution is stable for the same effective input; with `--conversion-trace`,
its resolved rows are written to the trace. Generated export performs per-slide semantic
read-back plus package timing/`p:cTn`/`p:spTgt` validation. Narration merges
audio timing into the existing DOM and preserves animation rows. Direct-PPTX
routes preserve source object animation, compare its object-animation fingerprint
before/after allowed edits, and validate structure; they do not author
animation effects. The exact 22 tuples and OOXML rules live in
[`scripts/docs/pptx-animations.md`](scripts/docs/pptx-animations.md).
**Optional custom animations** (only when the user asks to tune animation order/effects/timing for specific objects):
@@ -818,11 +880,11 @@ Run the standalone [`generate-audio`](workflows/generate-audio.md) workflow. The
Do NOT call `notes_to_audio.py` directly without going through the workflow — `--voice` / `--voice-id` is required and the workflow produces the locale/provider-aware recommendation that makes the choice meaningful.
Full effect list, anchor logic, and limits: [`references/animations.md`](references/animations.md).
The compatibility contract covers PowerPoint OOXML; do not promise identical
animation playback in Keynote or other presentation applications.
> ❌ **NEVER** substitute `cp` for `finalize_svg.py` — finalize performs multiple critical processing steps
> ❌ **NEVER** force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). The default auto-split already gives native the high-fidelity source it needs without touching legacy.
> ❌ **NEVER** use `--only` in the standard pipeline. Keep it only for explicit
> one-product diagnostics or compatibility checks.
> ❌ **NEVER** use `-s final` for a release export. It is a diagnostic comparison only; the supported native route reads `svg_output/`.
> **Post-export annotation window**: the preview service from Step 6 typically remains running after export. If the user submitted annotations in the browser (during Executor or after export) and now asks to apply them — they may quote the browser prompt (`Changes saved to svg_output...` / `修改已保存到 svg_output...`), say "apply my annotations" / "应用注解" / equivalent — run [`live-preview`](workflows/live-preview.md) Step 2 to apply and re-export. Annotations submitted during generation are also handled here, not earlier.
@@ -1,17 +1,21 @@
# Page Transitions & Per-Element Animations
PPT Master's exported PPTX supports **page transitions** (slide-to-slide) and **per-element entrance animations** (within a slide). Both are controlled by `svg_to_pptx.py` CLI flags and ship as real OOXML — they animate inside PowerPoint and Keynote, no embedded video.
PPT Master's exported PPTX supports **page transitions** and **per-element
entrance animations** as real PowerPoint OOXML. Other applications may
interpret timing differently; this contract makes no unconditional Keynote guarantee.
## Defaults
## 1. Defaults
| Layer | Default | Why |
|---|---|---|
| Page transition | `fade`, 0.4s | Calm baseline that suits most decks |
| Page transition | CLI: `fade`, 0.4s | Calm baseline that suits most decks; the public Python builder retains its legacy 0.5s default |
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so element entrance is opt-in. Turn it on with `-a auto` (or another effect): effects map from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool |
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` (or `svg_final/`) — no need to rerun the LLM. To turn per-element animation on for the whole deck, pass `-a auto`.
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` — no need to rerun the LLM. `-s final` is reserved for diagnostic comparison and is not a supported release source. To turn per-element animation on for the whole deck, pass `-a auto`.
## Custom Object-Level Animation
---
## 2. Custom Object-Level Animation
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
@@ -28,18 +32,23 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project>
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
```
Minimal sidecar:
Single-slide sidecar excerpt (repeat the complete slide block for every SVG in `svg_output/`):
```json
{
"version": 1,
"defaults": {
"transition": { "effect": "fade", "duration": 0.4 },
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
},
"slides": {
"03_market": {
"transition": { "effect": "fade", "duration": 0.4 },
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" },
"groups": {
"title": { "effect": "fade", "order": 1 },
"chart": { "effect": "wipe", "order": 2, "duration": 0.6 },
"insight": { "effect": "fly", "order": 3, "delay": 0.2 },
"footer": { "effect": "none" }
"insight": { "effect": "fly", "order": 3, "delay": 0.2 }
}
}
}
@@ -53,31 +62,42 @@ Rules:
- `effect: none` removes that group from the entrance sequence.
- `order` changes animation order only; it does not change slide layering.
- `delay` is seconds before that group starts in `after-previous` mode.
- `duration` overrides the per-group entrance duration.
- `duration` overrides the per-group schedule duration. `appear` remains a 1ms visibility flip; its configured duration spaces the next `after-previous` row.
- `--animation none` overrides the sidecar and disables all per-element animation.
- An explicit sidecar group may override the legacy chrome-name heuristic, but it cannot override `data-pptx-layer` or an explicit static role/placeholder marker.
- Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted.
## Page Transitions
---
## 3. Page Transitions
```bash
# Pick a different effect
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t push --transition-duration 0.6
# Disable
# Remove the visual transition
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none
# Auto-advance every 5 seconds (kiosk-style playback)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --auto-advance 5
# Auto-advance with no visual transition
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none --auto-advance 5
```
Available effects: `fade`, `push`, `wipe`, `split`, `strips`, `cover`, `random`.
Flags:
- `-t/--transition` — effect name, or `none` to disable. Default: `fade`.
- `-t/--transition` — effect name, or `none` for no visual transition. Default: `fade`. `none` does not remove an explicitly configured automatic advance.
- `--transition-duration` — seconds, default `0.4`.
- `--auto-advance` — seconds; omit for presenter-controlled advance.
- `--auto-advance` — seconds; click remains enabled, so the slide advances on click or when the timer expires. Omit for presenter-controlled advance.
## Per-Element Animations
**Hard rule — no silent downgrade**: an unknown transition effect or invalid/non-finite duration fails export. It is never replaced by `fade`. Recorded narration keeps the resolved visual transition; `-t none --recorded-narration ...` writes narration-driven advance timing without restoring a visual effect.
---
## 4. Per-Element Animations
Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
@@ -103,14 +123,16 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
--animation-stagger 0.7 --animation-duration 0.5
# All groups animate in unison on slide entry
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger with-previous
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger with-previous
```
22 single effects: `appear`, `fade`, `fly`, `cut`, `zoom`, `wipe`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
These names preserve the established filter / `presetID` / `presetSubtype` tuples documented in [`pptx-animations.md`](../scripts/docs/pptx-animations.md#3-compatibility-contract). `cut` is a legacy public key; compatibility promises its existing tuple, not a semantic interpretation inferred from an external preset-id table.
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track``wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*``fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle``fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note``fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
- `random` — samples from the legacy 16-effect pool.
- `random` — samples from the legacy 16-effect pool. Resolution is seeded from the effective deck input, so the same input produces the same choices; `--conversion-trace` records every resolved effect when diagnostics are enabled.
`appear` is excluded from every variation pool because it has no visible motion.
@@ -124,13 +146,15 @@ Flags:
> Note: `--recorded-narration` rejects `on-click`; use `after-previous` or `with-previous` for video-ready narrated decks.
## Anchor Logic — Top-Level `<g id="...">`
---
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). One group = one click reveal.
## 5. Anchor Logic — Top-Level `<g id="...">`
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). One group produces one animation-pane entrance row; whether that row needs a click depends on the selected Start mode.
Aim for **38 content groups per slide**. This is also the granularity PowerPoint uses for group-select / group-move, so it improves editing ergonomics regardless of animation.
**Chrome groups skip the cascade automatically.** Top-level groups that look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded from the click sequence and appear together with the slide. Detection is done on the `id`: after splitting on `-` and `_`, if any token matches `background` / `bg` / `decoration` / `decorations` / `decor` / `header` / `footer` / `chrome` / `watermark` / `pagenumber` / `pagenum` / `nav` / `logo` / `rule`, the group is treated as chrome. Examples that auto-skip: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Don't strip the `<g>` wrapper to avoid animation — keep it (PowerPoint group-select needs it) and just name it appropriately.
**Chrome groups skip the cascade automatically.** Explicit SVG role and placeholder semantics are authoritative. A group with `data-pptx-layer` or an explicit static role/placeholder marker can never animate. For marker-free legacy SVGs only, top-level groups whose id tokens look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded and appear with the slide. An explicit `animations.json` group entry may override this id-name heuristic, but never an explicit structural marker. Examples that auto-skip by legacy id: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Do not strip the `<g>` wrapper to avoid animation — keep it for PowerPoint group selection and use `effect: none` when the content should remain static.
**Fallback for flat SVGs** (no top-level `<g>` wrappers, only raw `<rect>` / `<text>` / `<path>` at the root):
@@ -139,25 +163,44 @@ Aim for **38 content groups per slide**. This is also the granularity PowerPo
Executors should wrap logical sections in `<g id>` regardless of whether you plan to animate. The Executor reference (`skills/ppt-master/references/shared-standards.md`) requires it.
## Limitations
---
- **Native shapes mode only.** Per-element animation needs editable shape anchors. `--only legacy` produces one image per slide and has no element granularity to animate; that mode is unaffected by `-a/--animation` and only honors `-t/--transition`.
- **Office version drift on element animations.** Effects use the `<p:animEffect filter=...>` path (vs. `presetID` lookup tables) to stay stable across Office versions. Most filters render identically in PowerPoint 2016+; older Office may downgrade some filters to plain Appear.
- **PNG fallback (compat mode) is for visual rendering only.** Transitions and animations live in the slide XML, not in the PNG, so disabling compat mode does not affect either layer.
## 6. Validation and Read-Back
## Quick Reference
Animation configuration is strict. Export fails on an unknown effect, mode, or trigger; a boolean or non-finite duration/delay/stagger; a non-positive duration; a negative delay/stagger; a non-positive or non-integer order; a missing slide/group reference; or any attempt to animate a structural layer. These errors never downgrade to another effect or silently omit a requested target.
Generated export reads each slide's timing tree back and checks row count/order, trigger, shape target, resolved effect tuple, duration, and timeline offset. Package validation then checks root timing placement, unique and valid `p:cTn` ids, and every `p:spTgt` reference. The writer does not emit `p:bldP` for groups or pictures. Direct-PPTX preserve mode tolerates unchanged legacy group/picture `p:bldP` rows from earlier PPT Master exports; new generated packages remain strict.
Narration injection merges audio timing into an existing direct `p:sld/p:timing` DOM and preserves entrance rows. A source timing tree nested in `mc:AlternateContent` or another non-root container fails safely instead of being rewritten or duplicated. Direct-PPTX routes fingerprint source object-animation timing before and after their allowed edits, then run structural package validation; they do not author or normalize animation effects.
---
## 7. Limitations
- **Native DrawingML output only.** Page transitions and per-element animations are authored on the PPTX produced by the project converter from `svg_output/`. `svg_final/` remains a static SVG visual preview, not an animated or alternate PPTX route.
- **PowerPoint OOXML scope.** Effects preserve their established filter / `presetID` / `presetSubtype` tuples and are validated against the serialized PowerPoint package. Rendering in Keynote, LibreOffice, WPS, or other applications is outside the unconditional compatibility guarantee.
- **Manual SVG shape conversion is unsupported.** Inserting an `svg_final/` page as an SVG picture does not establish element animation anchors; use the native PPTX when editable animated shapes are required.
- **Source extension preservation.** Direct-PPTX routes preserve unknown transition `AlternateContent` when configured to keep the source. When advance timing changes, Choice and Fallback receive the same `advClick` / `advTm` values.
---
## 8. Quick Reference
| Goal | Command |
|---|---|
| Disable transitions | `-t none` |
| Remove visual transition | `-t none` |
| Change transition effect | `-t push` (or any from the list above) |
| Slower transition | `--transition-duration 0.8` |
| Auto-play | `--auto-advance 5` |
| Disable element animation | `-a none` |
| Switch to on-click trigger | `--animation-trigger on-click` |
| Switch to on-click trigger | `-a auto --animation-trigger on-click` |
| Use a single effect instead of auto | `--animation fade` |
| All groups animate together | `--animation-trigger with-previous` |
| Slower per-element reveal | `--animation-duration 0.5` |
| Wider gap in after-previous | `--animation-stagger 0.7` |
| All groups animate together | `-a auto --animation-trigger with-previous` |
| Slower per-element reveal | `-a auto --animation-duration 0.5` |
| Wider gap in after-previous | `-a auto --animation-stagger 0.7` |
See also: [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
See also:
- [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
- [`pptx-transitions.md`](../scripts/docs/pptx-transitions.md) for the shared OOXML writer, MCE preservation, and read-back contract.
- [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact effect tuples, timing-tree rules, and animation package validator.
@@ -10,12 +10,12 @@ Global artifact ownership rules for PPT Master projects.
| Artifact | Owner | Role | Read/write contract |
|---|---|---|---|
| `sources/` content-type files | Content contract | Main pipeline source for text, tables, and chart data values | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judges by content; do not replace values with PPTX geometry JSON in the main pipeline |
| `sources/` content-type files | Content contract | Main pipeline source for text, tables, chart data values, and SmartArt node wording | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judges by content; do not replace values with PPTX geometry JSON in the main pipeline |
| `sources/` converted-source originals | Source archive | Imported source files that have a converted content contract (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) and source-adjacent extracted assets | Read via the converted `<stem>.md` in the main pipeline; direct-PPTX workflows read the `.pptx` by route |
| `sources/*.conversion_profile.json`, `sources/*_files/image_manifest.json` | Pipeline sidecar | Conversion audit record / asset index | NOT read as slide content; open only to audit a conversion or resolve assets |
| `analysis/source_profile.json` | Machine fact index | Compact Strategist-facing PPTX intake digest | Main pipeline reads as factual context and recommendation candidates |
| `analysis/<stem>.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed |
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches | Direct PPTX workflows use as native fill/structure contract |
| `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 |
@@ -27,8 +27,8 @@ Global artifact ownership rules for PPT Master projects.
| `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design | Quality checker and native PPTX export read this as the canonical visual/page-layout source; templates and locks do not add missing visible objects at export |
| `notes/total.md` | Speaker-note source | Complete notes before splitting | Step 6 writes; Step 7.1 splits |
| `notes/slide_*.md` | Split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` |
| `svg_final/` | Derived preview/export SVGs | Self-contained post-processed SVGs | Rebuild from `svg_output/` with `finalize_svg.py` |
| `exports/` | Delivery artifacts | Native PPTX and optional SVG snapshot PPTX | Step 7.3 writes final outputs |
| `svg_final/` | Derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Rebuild from `svg_output/` with `finalize_svg.py`; do not use as a supported PPTX source |
| `exports/` | Delivery artifacts | Native DrawingML PPTX and its explicit native-object/narration variants | Step 7.3 writes final outputs from `svg_output/` |
| `backup/<timestamp>/svg_output/` | Frozen author-source archive | Re-export source without re-running LLM | `svg_to_pptx.py` writes a snapshot during export |
| `animations.json` | Optional animation config | Object-level animation sidecar | Created only by explicit animation workflow/request |
@@ -38,16 +38,17 @@ Global artifact ownership rules for PPT Master projects.
| Invariant | Rule |
|---|---|
| Content values | Main pipeline text, tables, and chart values come from content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), not from `slide_library.json`. |
| Content values | Main pipeline text, tables, chart values, and SmartArt node wording come from content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), not from `slide_library.json`. |
| 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 and slot facts for direct PPTX workflows. |
| 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. |
| 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. |
| Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts; do not force them into SVG metadata. |
| Post-processed SVG | `svg_final/` is disposable and must be rebuildable from `svg_output/`. |
| Export source | Native PPTX export reads `svg_output/` by default. SVG snapshot export reads `svg_final/` only when requested. |
| Post-processed SVG | `svg_final/` is disposable, must be rebuilt in Step 7.2, and serves only as a self-contained visual preview / manually insertable SVG picture. |
| Export source | The only supported generated-PPTX route reads `svg_output/` through the project SVG-to-DrawingML converter. A diagnostic `-s final` override does not change ownership or create a supported release route. |
| Shape-conversion boundary | PowerPoint's manual Convert-to-Shape operation on `svg_final/` is outside the project compatibility contract. |
| Confirmation | Final `confirm_ui/result.json` or chat confirmation overrides recommendations. |
**Forbidden - mixed ownership**: Do not copy chart values from Markdown into `analysis/` by hand, do not edit `svg_final/` as the source of a fix, and do not treat `design_spec.md` prose as a replacement for `spec_lock.md`.
@@ -62,6 +63,5 @@ Global artifact ownership rules for PPT Master projects.
| `notes/slide_*.md` | `notes/total.md` | `python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>` |
| `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>` |
| Native PPTX | `svg_output/` plus notes/assets | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` |
| SVG snapshot PPTX | `svg_final/` | `svg_to_pptx.py --svg-snapshot` |
**Default - regenerate derived views**: When a source artifact changes, regenerate the derived artifact at the owning step instead of patching the derived file directly.
@@ -6,6 +6,8 @@
**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 — 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.
> Note: this rule covers page design only. Speaker notes, animations, transitions, narration, and direct native-PPTX workflows retain their separate artifacts and package-level processing.
---
@@ -262,7 +264,7 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
- **Technical specs**: see [shared-standards.md](shared-standards.md) for SVG/PPT constraints
- **Card containers — use the documented patterns**: when a content page needs section cards (4 quadrants, parallel aspects, capability blocks, info cards), use the patterns codified in [`templates/charts/CHART_STYLE_GUIDE.md`](../templates/charts/CHART_STYLE_GUIDE.md) §11 — half-rounded section tab (§11.1), nested card border without stroke (§11.2), card-grid skeletons (§11.3), diagonal dashed connector for cross-quadrant relationships (§11.5), ground-anchor ellipse as a non-filter depth marker (§11.6), bidirectional interaction arrows for paired protocols (§11.7). Do not reinvent the "tinted full-rounded rect + white cover-rect to hide the bottom corners" hack; it survives in older templates but breaks SVG→PPTX color editing. Reference templates: [`labeled_card.svg`](../templates/charts/labeled_card.svg), [`quadrant_text_bullets.svg`](../templates/charts/quadrant_text_bullets.svg), [`kpi_cards.svg`](../templates/charts/kpi_cards.svg), [`matrix_2x2.svg`](../templates/charts/matrix_2x2.svg), [`team_roster.svg`](../templates/charts/team_roster.svg), [`client_server_flow.svg`](../templates/charts/client_server_flow.svg).
- **Reference — prefer semantic shapes over preset stacks (not a constraint)**: when a slide needs to express "ascending / converging / breaking through / stacking" — i.e., a relationship that goes beyond a generic arrow — prefer a single custom `<polygon>` or `<path>` that encodes the semantics geometrically, rather than stacking multiple preset arrows. A converging-tip path or a podium polygon reads faster than three arrows pointing at a label. Examples of this technique appear in many imported corporate decks; see `projects/01_template_import/svg_output/slide_01.svg` shape-158 for a reference (gradient-filled inward-pointing arrow). Do not codify these as templates — they are page-specific; the rule is just "consider polygon before stacking presets."
- **Reference — visual depth through restraint (not a constraint)**: layered depth comes from rhythm (flat vs lifted, dense vs spacious), not from shadows everywhere. Shadow typically suits 2-3 genuinely floating elements per page (cards on photos, primary CTA, overlays); keep peer-grid cards, dividers, body containers flat. Reach for typography weight, spacing, accent bars, subtle tints **before** shadow. Full rules in shared-standards.md §6.
- **Reference — visual depth through restraint (not a constraint)**: layered depth comes from rhythm (flat vs lifted, dense vs spacious), not from shadows everywhere. Shadow typically suits 2-3 genuinely floating elements per page (cards on photos, primary CTA, overlays); keep peer-grid cards, dividers, body containers flat. Reach for typography weight, spacing, accent bars, subtle tints **before** shadow.
### 3.2 Native Object Metadata Marker (MANDATORY on eligible data-chart and text-grid table pages)
@@ -280,7 +282,7 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
- Native chrome: write `title`, `subtitle`, axis titles, or `show_legend: true` only when the fallback visibly renders the same chrome inside the native chart's replacement scope. `title` is the PowerPoint chart title, not an object name; use `name` for page-semantic object naming (e.g. `p03-revenue-chart`). Write explicit `x`/`y`/`width`/`height` read from the drawn plot area; omission is the fallback — the exporter then infers the frame from the drawn fallback geometry.
- Value-axis labels: when the fallback keeps category labels but intentionally omits numeric value-axis tick labels, set `show_value_axis_labels: false`.
- Freeform chart text: transcribe center labels, source notes, and other in-chart annotations as companion `caption` / `note` / `notes` entries with explicit slide-coordinate bounds; do not rely on fallback `<text>` children to survive native export.
- Native chart typography mirrors the SVG fallback. Copy the fallback's shared chart font into `style.font_family` and visible chart text sizes into the matching metadata fields (`title_font_size`, `subtitle_font_size`, `axis_font_size`, `note_font_size`, etc.) when role sizes differ; if omitted, the exporter infers shared font family and base chart text size from visible fallback text inside the native marker. When a visible chart title, subtitle, or axis title needs its own size/color/font, write that field as an object with `text`, `font_size`, `font_family`, and `color`. Use `axis_title_font_size`, `legend_font_size`, or companion per-entry `font_size` only when the fallback visibly uses a separate size. If a fallback role has no explicit size, use the compact three-tier chart defaults from [shared-standards.md](shared-standards.md).
- Native chart typography mirrors the SVG fallback. Copy the fallback's shared chart font into `style.font_family` and visible chart text sizes into the matching metadata fields (`title_font_size`, `subtitle_font_size`, `axis_font_size`, `note_font_size`, etc.) only when role sizes differ; otherwise let the exporter infer them from visible fallback text. When a visible chart title, subtitle, or axis title needs its own size/color/font, write that field as an object with `text`, `font_size`, `font_family`, and `color`. Use `axis_title_font_size`, `legend_font_size`, or companion per-entry `font_size` only when the fallback visibly uses a separate size.
- Native table typography mirrors the SVG fallback. Write `style.font_family` and `style.font_size` from the visible table text; use `header_font_size` or per-cell `font_size` only when the fallback visibly does so. If the fallback has no explicit table font, fall back to the deck body family and locked body size from `spec_lock.md typography`.
- The marker group's transform stays translate/scale only (no rotate / matrix / skew).
- Visual parity is not a goal: the SVG drawing remains the designed visual; the native object is a data-editable counterpart with PowerPoint-default styling that users restyle by hand after export. Never simplify the SVG design to match what a native object could show.
@@ -500,21 +502,21 @@ Auto-split `notes/total.md` into per-page files in `notes/`.
> **Auto-continuation**: After Visual Construction Phase (all SVG pages) and Logic Construction Phase (all notes) are complete, the Executor proceeds directly to the post-processing pipeline.
**Post-processing & Export** (same canonical pipeline as [shared-standards.md §5](shared-standards.md)):
**Post-processing & Export** (canonical workflow: [`SKILL.md` Step 7](../SKILL.md)):
```bash
# 1. Split speaker notes
python3 scripts/total_md_split.py <project_path>
# 2. SVG post-processing (auto-embed icons, images, etc.)
# 2. SVG post-processing (auto-embed icons/images and flatten positioned text)
python3 scripts/finalize_svg.py <project_path>
# Output: svg_final/ self-contained SVG visual previews
# 3. Export PPTX
python3 scripts/svg_to_pptx.py <project_path>
# Output (default-flow mode):
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output)
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
#
# Add --svg-snapshot to additionally emit:
# exports/<project_name>_<timestamp>_svg.pptx ← SVG snapshot pptx (sibling of native pptx)
```
`svg_final/` may be opened directly or manually inserted into PowerPoint as an SVG picture. It is not a second PPTX route. Use `-s final` only for converter diagnostics; release exports use the default `svg_output/` source. Manual Convert-to-Shape behavior is unsupported.
@@ -148,7 +148,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
24. **Custom path crop (blob, arrow, leaf, silhouette)**`<clipPath><path d="…"/></clipPath>`; allows any curved or organic shape. PowerPoint export translates this to `custGeom` and survives roundtrip.
25. **Layered paper-cut stack**multiple image or shape layers each with `clipPath` + a small `<feDropShadow>` offset to fake physical layering depth. Each layer casts a shadow onto the next, producing real-looking craft depth.
25. **Layered paper-cut stack**clip each image layer under the image-only contract in [`shared-standards.md`](shared-standards.md) §1.2; draw vector layers directly in their final geometry. A small conditional shadow on each layer can create physical separation.
26. **Triptych baked into a single wide image** — one wide `<image width=1160 height=334>` whose internal composition already contains 23 scenes. Generate the triptych as one image (not three separate calls) when scene-to-scene consistency matters — the model preserves character identity, lighting continuity, and color grading far more reliably when panels are produced together.
@@ -170,9 +170,9 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
33. **Spotlight mask — clear region surrounded by darkness** — cover the canvas with `<rect>` filled by a `<radialGradient>` whose inner stop is fully transparent and outer stop is opaque dark. Reads as a flashlight beam on the focal area. Use sparingly — it kills everything outside the spotlight.
34. **Gaussian-blur backdrop**`<filter><feGaussianBlur stdDeviation="815"/></filter>` applied to the background image, with sharp content layered on top unblurred. Reads as depth-of-field. Be aware that filters have inconsistent PPT export support — if fidelity matters, bake the blur into the source image instead.
34. **Gaussian-blur backdrop**blur the background in the source image, then layer sharp SVG content above it. Native filter export maps the supported blur graph to a glow/shadow effect; it does not preserve a blurred-image backdrop.
35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Most reliable when baked into the source image at generation time. Runtime SVG duotone via `<feColorMatrix>` + `<feComponentTransfer>` is possible but the filter chain is fragile through PPT export — only attempt if you control the renderer.
35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Bake it into the source image; the native PPT route does not support a runtime duotone filter chain.
36. **Drop shadow under image panel**`<filter><feDropShadow dx dy stdDeviation flood-color flood-opacity/></filter>` applied to the image's container `<rect>` (or to the `<image>` itself). Standard depth lift.
@@ -204,7 +204,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
67. **Image with knock-out / cut-out shape** — overlay a shape filled with the background color or another image, creating the impression of a hole punched through the underlying image.
68. **Text-as-mask over image** — letterforms revealing image through them. SVG-level `<mask>` is forbidden in this project (PPT export breaks). The only reliable way: bake this effect into the image at generation time by prompting for "large lettering revealing the underlying scene through letterforms." Treat as a pre-rendered artistic choice, not a runtime effect.
68. **Text-as-mask over image** — letterforms revealing image through them. Under the canonical SVG compatibility boundary in [`shared-standards.md`](shared-standards.md), realize this pattern as a pre-rendered image rather than a runtime effect. Prompt for "large lettering revealing the underlying scene through letterforms" and treat the result as a fixed artistic choice.
69. **Image rotated at a slight angle for editorial feel**`transform="rotate(angle cx cy)"` on the `<image>` or its container `<g>`; 26 degrees typical. Adds dynamism without breaking layout.
@@ -252,9 +252,10 @@ Combine freely. The "AI-default" failure mode is the opposite: defaulting to bar
## Hard Constraints
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
- `<clipPath>` on `<image>` and transparency encoding (`fill-opacity` / `stop-opacity`, never `rgba()`) — authoritative form in [`shared-standards.md`](shared-standards.md) §1.2 and §2; do not restate or relax here.
- No `<mask>`, no `<feComposite>` for alpha compositing. Alpha-effect routing (gradient overlays, clipPath crops, filter shadows, baked-in source image) is the table in [`shared-standards.md`](shared-standards.md) §1.0.
- `<feDropShadow>` / `<feGaussianBlur>` are accepted but PPT export is inconsistent — bake into the source image when fidelity is critical.
- All project-wide SVG compatibility exceptions and conditional mappings are
owned by [`shared-standards.md`](shared-standards.md). This catalog neither
restates nor relaxes that contract; each pattern records only its
scenario-specific rendering choice.
---
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
## 1. Strategist Confirmation Stage
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 11-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes → XI Tech Constraints.
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 10-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes.
**BLOCKING**: After the read, present professional recommendations for the confirmation fields below and wait for explicit user confirmation.
@@ -826,7 +826,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
> Note: §IX is the only content copy the Executor re-reads after context compression — what you write there is what survives.
### 6.2 Outline Output Specification (Must include 11 chapters)
### 6.2 Outline Output Specification (Must include 10 sections)
| Chapter | Content Requirements |
|---------|---------------------|
@@ -840,7 +840,6 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
| VIII. Image Resource List | Filename, dimensions, ratio, purpose, status, generation description |
| IX. Content Outline | Grouped by chapter; each page includes layout, title, core message (the page's one idea), content blocks (in the selected phrasing mode), visualization type (if applicable) |
| X. Speaker Notes Requirements | File naming rules, content structure description |
| XI. Technical Constraints Reminder | SVG generation rules, PPT compatibility rules |
**Generation steps**:
1. Read reference template: `templates/design_spec_reference.md`
@@ -53,10 +53,12 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
└── Placeholder / Needs-Manual without file → Dashed border + description text
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
5. Post-processing & Export → follow shared-standards.md §5
5. Post-processing & Export → follow [`SKILL.md` Step 7](../SKILL.md)
```
> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into `svg_final/`; export PPTX from `svg_final/`.
> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into the mandatory `svg_final/` visual preview; native PPTX export independently reads `svg_output/`.
**Hard rule — export boundary**: `svg_final/` is a self-contained SVG preview for embeddable raster/SVG assets and may be manually inserted into PowerPoint as an SVG picture. EMF/WMF assets retain the documented external-reference exception for lossless native passthrough. The only supported generated-PPTX route is `svg_output/` through the project SVG-to-DrawingML converter. PowerPoint's manual Convert-to-Shape operation is unsupported.
---
@@ -65,7 +67,7 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
| Method | Pros | Cons | Suitable For |
|--------|------|------|-------------|
| **External reference** | Small file size, fast iteration, easy to replace | Preview requires HTTP server from project root | `svg_output/` development phase |
| **Base64 embedding** | Self-contained file, stable export | Large file size | `svg_final/` delivery phase |
| **Base64 embedding** | Self-contained file, stable direct preview / SVG-picture insertion | Large file size | `svg_final/` preview phase |
---
@@ -106,7 +108,7 @@ python3 -m http.server -d <project_path> 8000
---
## Method 2: Base64 Embedding (Recommended for Delivery Phase)
## Method 2: Base64 Embedding (Recommended for Preview Phase)
### Syntax
@@ -128,7 +130,7 @@ python3 -m http.server -d <project_path> 8000
## Conversion Process
Use the unified pipeline in [shared-standards.md §5](shared-standards.md). `finalize_svg.py` runs before export so image references in `svg_output/` become embedded assets in `svg_final/`.
Use the unified pipeline in [`SKILL.md` Step 7](../SKILL.md). `finalize_svg.py` remains mandatory and embeds image references into the self-contained `svg_final/` preview. The following PPTX command still reads `svg_output/` by default and converts it directly to native DrawingML; it does not consume `svg_final/` in the supported release route.
```bash
python3 scripts/finalize_svg.py <project_path>
@@ -169,7 +171,7 @@ project/
├── sources/ # Source files and their accompanying images
│ └── article_files/
├── svg_output/ # Raw version (external references)
└── svg_final/ # Final version (images embedded)
└── svg_final/ # Derived self-contained visual preview (images embedded)
```
### Rounded Corner / Non-rectangular Image Cropping
@@ -4,15 +4,19 @@
## Core Mission
Generate reusable page templates for the **global template library** based on a finalized template brief, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
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.
> This is a standalone role: only triggered via the `/create-template` workflow. It is **not** the project-level template selection/customization step in the main PPT generation pipeline.
> 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.
## Usage
- **Trigger**: `/create-template` workflow
- **Output location**: `templates/<kind_dir>/<template_name>/` where `<kind_dir>` is `decks` (default — full-PPT replica with identity) or `layouts` (structure-only, no identity segment)
- **Input**: finalized template brief (template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms)
- **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
- **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`.
**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.
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
@@ -94,7 +98,7 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
- 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`; the corresponding index entry (`decks_index.json` or `layouts_index.json`) is generated automatically by `register_template.py` from the directory's actual SVG files
- 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
> Variants reuse the parent type's placeholder set — see §4 (Placeholder Reference) below.
@@ -103,7 +107,7 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
When the brief sets `Replication mode: mirror`, preserve literal page appearance while reconstructing layer ownership:
- 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: `templates/<kind_dir>/<template_id>/<NNN>_<page_type>.svg`, where `<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.
- 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.
@@ -121,7 +125,7 @@ When the brief sets `Replication mode: mirror`, preserve literal page appearance
**Scope rule — personality only.** A template `design_spec.md` describes **what makes this template recognizable**: brand colors, signature decorative motifs, page-by-page visual character, bundled assets. It does **not** restate generic constraints — those live in the canonical references and are already loaded by every downstream role:
- SVG technical constraints, PPT compatibility rules → [`shared-standards.md`](shared-standards.md)
- General SVG required / forbidden / conditional interfaces → [`shared-standards.md`](shared-standards.md)
- Generic layout pattern library, spacing bands, font-size ratio bands → [`templates/design_spec_reference.md`](../templates/design_spec_reference.md) (read by Strategist when authoring the **project** design_spec)
- Canonical placeholder vocabulary → §4 below
- Content methodology (pyramid / SCQA / MECE) → [`strategist.md`](strategist.md)
@@ -130,6 +134,10 @@ Re-declaring any of these in a template `design_spec.md` is noise — Strategist
**Required skeleton:**
The frontmatter is portable across library and project scope. Do not add
`output_scope` or `target_project`; those belong only to the workflow execution
brief.
```markdown
---
template_id: <id>
@@ -189,8 +197,7 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
| Don't write | Source |
|---|---|
| SVG technical constraints / Mandatory rules / Prohibited elements | `shared-standards.md` §1 |
| PPT compatibility rules (`<g opacity>`, inline-styles-only, etc.) | `shared-standards.md` |
| General SVG technical / compatibility rules | `shared-standards.md` |
| Generic layout pattern library (centered card / 三栏 / 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 |
@@ -199,7 +206,10 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
| "Usage Instructions" boilerplate (copy template / select page / …) | `create-template.md` |
| Created Date / Page Count rows | not a library-level field |
When rewriting an existing template that contains the omitted sections, delete them — do not leave a "see XXX" pointer behind. The pointer is what this scope rule replaces.
When rewriting an existing template that contains an omitted generic section,
delete it rather than leaving a pointer. Keep a template-specific boundary only
inside the personality section it qualifies (asset system, motif, image
treatment, or page roster); do not preserve a generic technical-rules heading.
### 2. Inherit Design Specification
@@ -282,7 +292,7 @@ Use clear placeholder markers for replaceable content:
### 4. Placeholder Reference (canonical convention, overridable per template)
This is the **default vocabulary** used across the library. Newly created templates SHOULD prefer these names so projects that consume the library find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`).
This is the **default vocabulary** used across template packages. Newly created templates SHOULD prefer these names so downstream projects find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`).
`svg_quality_checker.py --template-mode` emits **advisory warnings** when a page lacks the conventional placeholder for its type. To silence those warnings — and document the template's actual contract — declare a `placeholders:` map in `design_spec.md` frontmatter:
@@ -314,13 +324,13 @@ placeholders:
| `{{CLOSING_MESSAGE}}` | Closing message | Ending page | Style-specific |
| `{{COPYRIGHT}}` | Copyright | Ending page | Optional |
For TOC pages in **newly created library templates**, use indexed placeholders:
For TOC pages in **newly created templates**, use indexed placeholders:
- `{{TOC_ITEM_1_TITLE}}`, `{{TOC_ITEM_1_DESC}}`
- `{{TOC_ITEM_2_TITLE}}`, `{{TOC_ITEM_2_DESC}}`
- ...
Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` for new templates. Existing templates may contain legacy placeholder variants, but new library assets should converge on the indexed TOC contract.
Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` for new templates. Existing templates may contain legacy placeholder variants, but new output should converge on the indexed TOC contract.
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.
@@ -332,7 +342,7 @@ When rebuilding from imported PPTX references, placeholder insertion takes prior
### File Save Location
Standard mode (default):
Library scope keeps the existing self-contained package. Standard mode (default):
```
templates/<kind_dir>/<template_name>/
@@ -342,10 +352,11 @@ templates/<kind_dir>/<template_name>/
├── 02_toc.svg # Optional
├── 03_content.svg
├── 04_ending.svg
── *.png / *.jpg # Image assets (if any)
── icons/ # Extracted vector assets (if any)
└── *.png / *.jpg # Bitmap assets (if any)
```
Fidelity mode adds variants and extension pages, e.g.:
Fidelity mode adds variants and extension pages in the same package, e.g.:
```
templates/<kind_dir>/<template_name>/
@@ -381,6 +392,26 @@ templates/<kind_dir>/<template_name>/
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.
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.
### Template Preview
After each template is generated, provide a brief summary table listing each template's status.
@@ -401,7 +432,7 @@ If suitable template resources already exist, use them directly instead of gener
2. **Adjust colors**: Modify colors per the project design spec
3. **Customize**: Make project-specific adjustments
This section describes downstream reuse. The `Template_Designer` role itself is responsible for creating or normalizing the reusable library asset first.
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.
**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):
@@ -426,11 +457,13 @@ 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] Replication mode confirmed: `standard` | `fidelity` | `mirror`
- [x] Every page listed in `design_spec.md §V Page Roster` saved to `templates/<layouts|decks>/<template_name>/` (decks for full-PPT replicas, layouts for structure-only)
- [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] 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`
- [ ] **Next step**: Validate assets and register the template via `register_template.py <id> --kind <deck|layout>`
- [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
```
@@ -26,7 +26,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj
## 4. Texture / elevation
- Flat line work, not material elevation. Depth reads from isometric projection and layered line weights, not shadows. Optional subtle corner vignette / accent glow on the dark paper — keep it faint. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
- Flat line work, not material elevation. Depth reads from isometric projection and layered line weights, not shadows. Optional subtle corner vignette / accent glow on the dark paper — keep it faint.
## 5. Paired image-rendering
@@ -21,7 +21,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
- Dark slate field; off-white chalk carries most marks; the deck's colors appear as soft, powdery pastel chalk accents, used sparingly.
- Restrained and powdery — never saturated fills.
> HEX values come from confirmation `e`; this style only governs the chalk-on-slate, powdery-accent discipline — it names no colors. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
> HEX values come from confirmation `e`; this style only governs the chalk-on-slate, powdery-accent discipline — it names no colors.
## 4. Texture / elevation
@@ -26,7 +26,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
## 4. Texture / elevation
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle. (Dark-theme legibility — prefer light stroke / outer glow over black shadow: [`shared-standards.md §6`](../shared-standards.md).)
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle.
## 5. Paired image-rendering
@@ -21,7 +21,7 @@ Bloomberg / Economist news-infographic — publication-grade information density
- A restrained field — light publication paper or dark graphite both fit (Economist vs Bloomberg-terminal); the deck's accent marks risk / key figures, an optional secondary distinguishes a second series; charts use tints of the same family, never a rainbow.
- Numbers are colored to *mean* (up / down / risk / focus), not to decorate.
> HEX values come from confirmation `e`; this style only governs the restrained-field, meaning-coded-data discipline — it names no colors. (Dark-field legibility, if dark: [`shared-standards.md §6`](../shared-standards.md).)
> HEX values come from confirmation `e`; this style only governs the restrained-field, meaning-coded-data discipline — it names no colors.
## 4. Texture / elevation
@@ -25,7 +25,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
## 4. Texture / elevation
- Depth via translucency, layering, bright edge highlights, and soft background glow — not hard drop shadows. Smooth multi-stop gradients are intrinsic here (the one style where generous gradient use is on-brand); keep them luminous, not muddy. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
- Depth via translucency, layering, bright edge highlights, and soft background glow — not hard drop shadows. Smooth multi-stop gradients are intrinsic here (the one style where generous gradient use is on-brand); keep them luminous, not muddy.
## 5. Paired image-rendering
@@ -25,7 +25,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
## 4. Texture / elevation
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet. (Shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet.
## 5. Paired image-rendering
@@ -26,7 +26,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
## 4. Texture / elevation
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat. (Full shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat.
## 5. Paired image-rendering
@@ -12,8 +12,8 @@
# ─────────────────────────────────────────────────────────────
# Batch-convert SVG files to PowerPoint presentations.
# 用于将 SVG 文件批量转换为 PowerPoint 演示文稿
# Uses Office-compatible mode (PNG + SVG dual format) by default, supports all Office versions.
# 默认使用 Office 兼容模式(PNG + SVG 双格式),支持所有 Office 版本
# Converts supported SVG elements to editable native DrawingML shapes.
# 将受支持的 SVG 元素转换为可编辑的原生 DrawingML 形状
python-pptx>=0.6.21
XlsxWriter>=3.0.0
@@ -22,19 +22,6 @@ XlsxWriter>=3.0.0
# notes_to_audio.py 用于在 macOS/Linux/Windows 上生成逐页旁白音频。
edge-tts>=7.2.8
# Office compatibility dependencies (choose one, CairoSVG preferred)
# Office 兼容模式依赖(二选一,优先使用 cairosvg)
# Converts SVG to PNG fallback images for Office LTSC 2021 etc.
# 用于将 SVG 转换为 PNG 后备图片,确保 Office LTSC 2021 等版本正常显示
#
# Option A / 方案 A: CairoSVG (recommended / 推荐, ~25MB, full gradient/filter support)
# pip install cairosvg
# macOS: brew install cairo
#
# Option B / 方案 B: svglib (lightweight / 轻量, ~5MB, some gradients may be lost)
svglib>=1.5.0
reportlab>=4.0.0
# ─────────────────────────────────────────────────────────────
# PDF to Markdown tool / PDF 转 Markdown 工具 (skills/ppt-master/scripts/source_to_md/pdf_to_md.py)
# ─────────────────────────────────────────────────────────────
@@ -76,8 +63,8 @@ openpyxl>=3.1.0
# ─────────────────────────────────────────────────────────────
# Image processing tools / 图片处理工具
# ─────────────────────────────────────────────────────────────
# fix_image_aspect.py - Fix image aspect ratio (prevent stretching during PPT shape conversion)
# fix_image_aspect.py - 修复图片宽高比(防止 PPT 转形状时拉伸)
# fix_image_aspect.py - Normalize image boxes for PowerPoint SVG rendering diagnostics
# fix_image_aspect.py - 为 PowerPoint SVG 渲染诊断归一化图片框
# gemini_watermark_remover.py - Remove watermarks from Gemini-generated images
# gemini_watermark_remover.py - 去除 Gemini 生成图片的水印
Pillow>=9.0.0
@@ -46,6 +46,8 @@ 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) |
| 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) |
| Image tools | `image_gen.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Repo maintenance | `update_repo.py` | README install/update section |
@@ -116,6 +118,8 @@ python3 scripts/svg_to_pptx.py <project_path>
`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: only oversized full source images are reduced to max `2560px`, so later PowerPoint resizing keeps more image detail. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes.
`finalize_svg.py` remains mandatory because it creates the self-contained `svg_final/` visual preview. Those SVGs may be opened directly or inserted into PowerPoint as SVG pictures. The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter; `-s final` is diagnostic-only, and PowerPoint's manual Convert-to-Shape operation is unsupported.
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.
@@ -154,15 +158,16 @@ python3 scripts/update_repo.py --skip-pip
- Keep one user-facing entry point per workflow at the top level of `scripts/`
- Move provider-specific or helper internals into subdirectories
- Prefer the unified entry points `project_manager.py`, `finalize_svg.py`, and `image_gen.py`
- Use `svg_output/` for native export and `svg_final/` for SVG snapshot/preview export
- Use `svg_output/` for the only supported native PPTX export and `svg_final/` for self-contained SVG visual preview / picture insertion
## Related Docs
- [Conversion Tools](./docs/conversion.md)
- [Project Tools](./docs/project.md)
- [SVG Pipeline Tools](./docs/svg-pipeline.md)
- [PPTX Transition Core](./docs/pptx-transitions.md)
- [Image Tools](./docs/image.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [Skill Entry](../SKILL.md)
_Last updated: 2026-07-10_
_Last updated: 2026-07-11_
@@ -33,6 +33,8 @@ from svg_to_pptx.animation_config import ( # noqa: E402
build_group_listing,
load_animation_config,
validate_animation_config,
validate_animation_config_errors,
validate_transition_config,
write_scaffold,
)
@@ -111,10 +113,28 @@ def main(argv: list[str] | None = None) -> int:
if not config:
print('No animations.json found; default animation policy will be used.')
return 0
warnings = validate_animation_config(project_path, config)
if warnings:
for warning in warnings:
print(f'Warning: {warning}', file=sys.stderr)
errors = list(dict.fromkeys(
validate_transition_config(config)
+ validate_animation_config_errors(config)
))
if errors:
for error in errors:
print(f'Error: {error}', file=sys.stderr)
return 1
reference_messages = validate_animation_config(project_path, config)
reference_warnings = [
message for message in reference_messages
if ' has no id and cannot be customized in animations.json' in message
]
reference_errors = [
message for message in reference_messages
if message not in reference_warnings
]
for warning in reference_warnings:
print(f'Warning: {warning}', file=sys.stderr)
if reference_errors:
for error in reference_errors:
print(f'Error: {error}', file=sys.stderr)
return 1
print('Animation config validated successfully.')
return 0
@@ -3,7 +3,7 @@
PPT Master - Beautify Inventory Builder
Mechanically merge a source deck's extracts into one per-slide ledger for the
beautify-pptx workflow: text blocks + tables + charts (from a
beautify-pptx workflow: text blocks + tables + charts + SmartArt structure (from a
`template_fill_pptx.py analyze` slide_library.json) joined with the images
bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic
join only `ignored` and `needs_confirmation` are emitted empty for the agent
@@ -103,12 +103,27 @@ def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]])
}
for c in slide.get("charts", [])
]
diagrams = [
{
"diagram_id": diagram.get("diagram_id"),
"shape_name": diagram.get("shape_name"),
"geometry": diagram.get("geometry"),
"layout": diagram.get("layout", {}),
"root_ids": diagram.get("root_ids", []),
"nodes": diagram.get("nodes", []),
"connections": diagram.get("connections", []),
"status": diagram.get("status"),
"warnings": diagram.get("warnings", []),
}
for diagram in slide.get("diagrams", [])
]
slides_out.append({
"slide_index": idx,
"page_type": slide.get("page_type"),
"text_blocks": text_blocks,
"tables": tables,
"charts": charts,
"diagrams": diagrams,
"images": images_by_slide.get(idx, []),
"ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation
"needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages
@@ -486,64 +486,15 @@ LAYOUT_MARGINS = {
# ============================================================
# SVG Technical Specifications
# SVG Policy Reference
# ============================================================
# Do not mirror element/attribute rules here. The shared standard is the
# authoring authority and the quality checker enforces it.
# Keep the exported key for compatibility with existing config consumers.
SVG_CONSTRAINTS = {
# Forbidden elements - PPT incompatible
'forbidden_elements': [
# Clipping / Masking
# Note: `clipPath` on <image> elements is conditionally allowed — the
# converter maps qualifying clip shapes to DrawingML picture geometry.
# See references/shared-standards.md §1.2. It is NOT listed here
# because this flat list has no per-parent-element semantics; the
# actual validation is in svg_quality_checker._check_forbidden_elements.
'mask',
# Style system
'style',
# Structure / Nesting
'foreignObject',
# Text / Fonts
'textPath',
# Animation / Interaction
'animate',
'animateMotion',
'animateTransform',
'animateColor',
'set',
'script',
# Others
'iframe',
],
# Forbidden attributes
# Note: marker-start / marker-end are NOT banned — they are conditionally
# allowed (see references/shared-standards.md §1.1). The svg_to_pptx
# converter maps qualifying <marker> defs to native DrawingML
# <a:headEnd>/<a:tailEnd>.
'forbidden_attributes': [
'class',
'id',
'onclick', 'onload', 'onmouseover', 'onmouseout',
'onfocus', 'onblur', 'onchange',
],
# Forbidden patterns (regex matching)
'forbidden_patterns': [
r'@font-face', # Web fonts
r'rgba\s*\(', # rgba colors (PPT incompatible)
r'<\?xml-stylesheet\b', # External CSS
r'<link[^>]*rel\s*=\s*["\']stylesheet["\']',
r'@import\s+', # External CSS
r'<g[^>]*\sopacity\s*=', # Group opacity
r'<image[^>]*\sopacity\s*=', # Image opacity
r'\bon\w+\s*=', # Event attributes
r'(?s)(?=.*<symbol)(?=.*<use\b)', # <symbol> + <use> complex usage (order-independent)
],
'recommended_fonts': [
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI'
]
'authority': 'skills/ppt-master/references/shared-standards.md',
'validator': 'skills/ppt-master/scripts/svg_quality_checker.py',
}
@@ -642,19 +593,6 @@ class Config:
"""
return FONT_SIZES.get(size_name, FONT_SIZES['body'])
@staticmethod
def validate_svg_element(element_name: str) -> bool:
"""
Validate whether an SVG element is allowed.
Args:
element_name: Element name
Returns:
Whether the element is allowed
"""
return element_name.lower() not in [e.lower() for e in SVG_CONSTRAINTS['forbidden_elements']]
@staticmethod
def get_project_path(subdir: str = '') -> Path:
"""
@@ -194,6 +194,7 @@ 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
- 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
- writes `<input>.conversion_profile.json` after successful conversion
@@ -218,15 +219,15 @@ python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
Outputs (per source deck, prefixed by file stem):
- `<stem>.identity.json` — canvas size/aspect, theme palette/fonts, observed colors/fonts
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, and page types) under `decks[]`, with prefixed artifact pointers
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify / template-fill workflows stay single-deck and read one chosen deck's `<stem>.*` artifacts.
Usage boundary:
- Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default.
- Beautify promotes selected identity/content fields into locked constraints after confirmation.
- Template-fill uses the slide library as the native PPTX fill contract.
- Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes.
- Template-fill uses the slide library as the native PPTX fill contract; SmartArt is inventory-only and remains unchanged.
## `pptx_to_svg.py`
@@ -0,0 +1,194 @@
# PPTX Animation Core
The shared animation core owns the entrance-effect vocabulary, trigger
semantics, OOXML timing writer, semantic read-back, and package validation for
PowerPoint OOXML. Per-element animation remains opt-in: generated PPTX export
defaults to `none`, exactly as before this validation upgrade.
## 1. Ownership
| Concern | Owner |
|---|---|
| Effect registry, timing writer, and read-back | `scripts/pptx_animations.py` |
| Sidecar parsing and SVG target discovery | `svg_to_pptx/animation_config.py` |
| SVG group-to-shape mapping | `svg_to_pptx/drawingml/converter.py` |
| Generated PPTX resolution and validation | `svg_to_pptx/pptx_package/builder.py` |
| Narration timing merge | `svg_to_pptx/pptx_package/narration.py` |
| Public authoring contract | `references/animations.md` |
| Customization workflow | `workflows/customize-animations.md` |
**Hard rule**: only the generated SVG-to-PPTX route writes object entrance
animations. Direct-PPTX routes preserve source animations and run structural
package validation; they do not resolve or author animation effects.
---
## 2. Domain Model
One resolved animation-pane row contains these fields:
| Field | Meaning |
|---|---|
| Target | Positive PowerPoint shape id written to `p:spTgt@spid` |
| Effect | One exact registry tuple: filter, `presetID`, and `presetSubtype` |
| Trigger | `on-click`, `with-previous`, or `after-previous` |
| Duration | Finite positive schedule duration; filter effects serialize it as behavior duration |
| Delay | Finite non-negative offset used by `after-previous` |
| Order | Positive integer sidecar order; ties retain stable SVG order |
Modes resolve before XML writing:
| Mode | Resolution |
|---|---|
| `auto` | Deterministic semantic mapping from the SVG group id |
| `mixed` | Deterministic legacy cycle |
| `random` | Stable seeded choice from the legacy pool |
| `none` | No entrance sequence |
The same effective input produces the same `random` choices. When enabled,
`--conversion-trace` records each resolved row and effect, so a generated deck
can be audited without replaying the resolver.
---
## 3. Compatibility Contract
The registry preserves these established 22 tuples exactly:
| Key | `p:animEffect@filter` | `presetID` | `presetSubtype` |
|---|---|---:|---:|
| `appear` | none | 1 | 0 |
| `fade` | `fade` | 10 | 0 |
| `fly` | `slide(fromBottom)` | 2 | 4 |
| `cut` | `slide(fromLeft)` | 42 | 8 |
| `zoom` | `image` | 23 | 0 |
| `wipe` | `wipe(left)` | 22 | 1 |
| `split` | `barn(inVertical)` | 16 | 21 |
| `blinds` | `blinds(horizontal)` | 3 | 10 |
| `checkerboard` | `checkerboard(across)` | 5 | 6 |
| `dissolve` | `dissolve` | 9 | 0 |
| `random_bars` | `randombar(horizontal)` | 14 | 10 |
| `peek` | `wipe(down)` | 12 | 4 |
| `wheel` | `wheel(4)` | 21 | 0 |
| `box` | `box(in)` | 4 | 0 |
| `circle` | `circle(in)` | 6 | 0 |
| `diamond` | `diamond(in)` | 8 | 0 |
| `plus` | `plus(in)` | 13 | 0 |
| `strips` | `strips(downRight)` | 18 | 12 |
| `wedge` | `wedge` | 20 | 0 |
| `stretch` | `stretch(across)` | 17 | 0 |
| `expand` | `stretch(across)` | 50 | 0 |
| `swivel` | `wheel(1)` | 19 | 0 |
`cut` is a legacy public key. Compatibility promises the tuple above; it does
not infer a different semantic name from external preset-id tables.
**Hard rule — no downgrade**:
- Keep the 22 established tuples byte-for-byte equivalent in meaning.
- Reject an unknown effect, mode, or trigger; never substitute another value.
- Reject booleans and non-finite, out-of-range, or invalidly ordered values.
- Reject a missing slide, missing group, or structural-layer target.
- Keep the generated-route default at `none`; validation does not opt a deck in.
---
## 4. Target Resolution
Generated object animation targets top-level SVG content groups. Explicit SVG
semantics are authoritative; the group-id chrome heuristic is only a fallback
for marker-free legacy SVGs.
| Target state | Behavior |
|---|---|
| Ordinary content group | Animatable |
| Legacy chrome-like id | Skipped unless explicitly named in `animations.json` |
| Explicit sidecar group override | May override only the legacy chrome-name heuristic |
| `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable |
An explicit sidecar entry cannot turn a Master/Layout/Slide structural layer or
an explicitly marked static page-frame role/placeholder into an animation
target. This boundary preserves PPTX structure even when a legacy id resembles
content.
---
## 5. OOXML Rules
The writer emits one root-level `p:timing` after `p:transition` and before
`p:extLst`. Its animation tree contains a `tmRoot`, one `mainSeq`, unique
`p:cTn@id` values, and `p:spTgt` references to shapes on the same slide.
Trigger mapping:
| Public trigger | Entrance `p:cTn@nodeType` |
|---|---|
| `on-click` | `clickEffect` |
| `with-previous` | `withEffect` |
| `after-previous` | `afterEffect` |
The writer does not emit `p:bldP` for grouped content or pictures. Microsoft
defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or
`p:pic` creates an invalid build reference. Package validation still accepts a
valid source `p:bldP` that targets a text-bearing shape.
Direct-PPTX preserve mode also tolerates an unchanged legacy `p:bldP` that
targets an existing group/picture. Earlier PPT Master exports wrote this form;
the direct routes fingerprint and preserve it instead of blocking those decks.
New generated output never writes it, and generated-package validation remains
strict.
`appear` is the visibility-flip exception: its `p:set` behavior is always 1ms.
The configured positive duration remains the row's scheduling span used when
computing the next `after-previous` offset; read-back verifies the 1ms behavior
and the resulting timeline offset separately.
---
## 6. Validation and Read-Back
Generated export reads every slide back before packaging and compares each
requested row with the serialized result:
- row count and row order;
- trigger and shape target;
- resolved effect key, filter, `presetID`, and `presetSubtype`;
- serialized behavior duration and computed timeline offset (`appear` uses the
1ms exception above).
After packaging, validation scans every slide part for root timing placement,
duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
targets, and unsupported generated effect tuples. A mismatch fails export
before the requested output file replaces an existing deck.
Narration injection parses and merges the slide DOM. It adds audio timing under
the existing `tmRoot` child list, allocates fresh time-node ids, and preserves
the object entrance sequence. It does not replace an existing `p:timing` tree.
The merge accepts only a direct `p:sld/p:timing` source tree; a timing tree
wrapped in `mc:AlternateContent` or another non-root container fails safely
instead of being rewritten or duplicated.
Direct-PPTX routes run the structural package validator with generated-effect
enforcement disabled. This permits preservation of source/extension effects and
legacy group build rows while still rejecting corrupt timing IDs or missing
targets. Template fill and native enhancement fingerprint the source
object-animation tree before and after their allowed edits; any semantic change
fails. These routes have no object-animation write ownership.
---
## 7. Compatibility Scope
The compatibility contract covers PowerPoint OOXML and PowerPoint read-back.
Other presentation applications may interpret timing trees or filter values
differently; the exporter does not make an unconditional Keynote guarantee.
Official references:
- [Microsoft animation-filter implementation notes](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/a96dab70-2e72-4319-928d-0eb4b275ce58)
- [Microsoft `p:bldP` implementation restrictions](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/40d17b6d-30c0-4c10-b042-b2597824a820)
- [Open XML SDK time-node values](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.timenodevalues?view=openxml-3.0.1)
- [Open XML SDK shape target](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.shapetarget?view=openxml-3.0.1)
See [`pptx-transitions.md`](./pptx-transitions.md) for the symmetric page-motion
core, MCE handling, and slide-advance contract.
@@ -0,0 +1,148 @@
# PPTX Transition Core
The shared transition core owns page-transition vocabulary, slide-advance
semantics, OOXML mutation, MCE preservation, package timing enablement, and
read-back validation for every PPTX route.
## 1. Ownership
| Concern | Owner |
|---|---|
| Page transition registry | scripts/pptx_transitions.py |
| In-slide object animation | scripts/pptx_animations.py |
| Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py |
| Template Fill adapter | template_fill_pptx/transitions.py |
| Native Enhance adapter | native_narration_pptx.py |
| Public workflow | references/animations.md |
**Hard rule**: adapters resolve route policy, then call the shared core. They
must not build, replace, or patch a transition with route-local XML or regex.
---
## 2. Domain Model
| Layer | Meaning | OOXML |
|---|---|---|
| Enter | How the current slide appears from the preceding slide | Transition effect child plus duration |
| Advance | How the current slide leaves for the next slide | advClick and advTm |
Enter policy:
| Policy | Behavior |
|---|---|
| preserve | Keep the source visual transition, including unknown extensions |
| replace | Write the requested supported effect |
| none | Write no visual effect |
Advance mode:
| Mode | Behavior |
|---|---|
| preserve | Keep source advClick and advTm |
| click | Click advance only |
| after | Timed advance only |
| both | Click or timed advance, whichever occurs first |
| narration | Timed advance from audio duration plus padding; click disabled |
**Hard rule**: enter=none may coexist with a timed advance. The valid result is
a timing-only p:transition with no visual-effect child.
---
## 3. Compatibility Contract
The Phase 1 registry preserves the established seven effects:
| Effect | Required child and attributes |
|---|---|
| fade | p:fade |
| push | p:push dir=r |
| wipe | p:wipe dir=r |
| split | p:split orient=horz dir=out |
| strips | p:strips dir=rd |
| cover | p:cover dir=r |
| random | p:random |
**Hard rule — no downgrade**:
- Never rename or remove an established effect.
- Never omit its established direction or split attributes.
- Reject an unknown requested effect; never substitute fade.
- Preserve an unknown source effect when the route selects preserve.
- A future PowerPoint extension counts as successful only when the primary
Choice contains the requested effect. A fallback alone is not success.
---
## 4. Route Mapping
| Route | Default enter | Default advance | Compatibility note |
|---|---|---|---|
| Generated PPTX CLI | fade, 0.4s | click | auto-advance maps to both |
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
| Template Fill v1 | fade, 0.5s | click | keep preserves source; legacy advance_after maps to both |
| Native Enhance v1 | Confirmed plan effect | Confirmed timing module | Disabled transitions preserve unless the v1 plan explicitly selected none |
Template Fill and Native Enhance keep their v1 route defaults in Phase 1.
The public `create_pptx_with_native_svg` Python API also retains its legacy
0.5s default; the CLI explicitly passes 0.4s. Changing a default policy is a
separate migration decision.
---
## 5. OOXML Rules
**Slide child order**:
~~~text
p:cSld
p:clrMapOvr
p:transition or transition mc:AlternateContent
p:timing
p:extLst
~~~
One slide may contain at most one logical transition carrier:
- one direct p:transition; or
- one root-level mc:AlternateContent whose Choice/Fallback branches contain
p:transition.
Mutation rules:
| Operation | Direct transition | AlternateContent |
|---|---|---|
| preserve | Leave unchanged | Leave wrapper and branches unchanged |
| advance-only | Patch direct attributes | Patch Choice and Fallback identically |
| replace | Replace the direct carrier | Remove the whole wrapper, then write one carrier |
| none | Remove visual carrier; retain timing-only carrier when needed | Remove the whole wrapper; retain timing-only carrier when needed |
**MCE prefix rule**: Requires and Ignorable values contain textual prefix
names. Serialization must retain bindings for those exact names. Renaming an
effect prefix without updating these attributes corrupts compatibility.
**Package timing rule**: when a route writes advTm, set
ppt/presProps.xml p:presentationPr/p:showPr useTimings=1. Do not write showPr
into ppt/presentation.xml.
---
## 6. Validation and Read-Back
Reject:
- unknown effect names;
- non-finite values, including NaN and Infinity;
- duration less than or equal to zero;
- negative advance or narration padding;
- booleans passed as numeric API values;
- multiple logical transition carriers;
- unresolved MCE Requires or Ignorable prefixes.
Read-back must report the primary Choice effect separately from the fallback.
It must also report carrier type, duration, click mode, and automatic advance
time. Package validation must run after writing, not only before mutation.
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
add a tests directory or test_*.py files.
@@ -4,6 +4,8 @@
These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export.
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.
## Recommended Pipeline
Run these steps in order:
@@ -20,9 +22,11 @@ Unified post-processing entry point. This is the preferred way to run SVG cleanu
It aggregates:
- `embed_icons.py`
- static same-document `<use>` expansion from `svg_to_pptx/use_expander.py`
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
- `flatten_tspan.py`
- `svg_rect_to_path.py`
`svg_final/` remains a required Step 7.2 artifact even though the native exporter reads `svg_output/`. It is the self-contained visual reference and may be manually inserted as an SVG picture.
## `svg_to_pptx.py`
@@ -35,7 +39,9 @@ python3 scripts/svg_to_pptx.py <project_path> --pptx-structure template # expli
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 flat # structure diagnostic
# Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py <template_import_output> --only native -s svg-flat
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
# Post-processed-source comparison diagnostic only (never a release export):
python3 scripts/svg_to_pptx.py <project_path> -s final
python3 scripts/svg_to_pptx.py <project_path> --no-notes
python3 scripts/svg_to_pptx.py <project_path> -t none
python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
@@ -49,17 +55,15 @@ Behavior:
- Default output (default-flow mode, no `-o`):
- `exports/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
- `backup/<timestamp>/svg_output/` — copy of Executor SVG source, always written so the pptx can be rebuilt via `finalize_svg → svg_to_pptx` without re-running the LLM
- `--svg-snapshot` (opt-in) additionally emits:
- `exports/<project_name>_<timestamp>_svg.pptx` — SVG snapshot pptx for visual reference, sibling of the native pptx
- Live preview already serves as the SVG visual reference for day-to-day use; the snapshot pptx is for distribution or frozen-state archival
- Explicit `-o/--output` skips `backup/`; pair with `--svg-snapshot` to also emit the side-by-side `_svg.pptx` next to the chosen path
- `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.
- 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
- Native export reads `svg_output/`; `--svg-snapshot` and legacy export read
`svg_final/`. Use `-s` only as an explicit diagnostic override.
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
page-design source. Templates and locks may guide authoring, but finalize/export
never use them to overlay visible content missing from the SVG. Notes, animation,
@@ -70,11 +74,13 @@ Behavior:
- 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.
- 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.
- 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:
@@ -82,6 +88,7 @@ Behavior:
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from audio duration
- Narration timing is merged into the existing slide timing DOM; object entrance rows and the resolved page transition are preserved rather than regenerated
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
@@ -89,6 +96,7 @@ Behavior:
- 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
- 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
- Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide
@@ -98,15 +106,13 @@ Behavior:
ids (hero/figure-/image/img-/kpi) cycle through a richer pool
(zoom/dissolve/circle/box/diamond/wheel), while unmatched ids cycle through
fade/wipe/fly/zoom.
- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` samples from that same legacy pool
- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` uses a stable seed from the effective deck input, and `--conversion-trace` records each resolved effect when enabled
- `--animation-duration` controls per-element entrance length (default `0.4`); `--animation-stagger` adds gap between elements in `after-previous` mode (default `0.5`)
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`
Performance (legacy `_svg.pptx` PNG fallback, only when `--svg-snapshot` or `--only legacy`):
- SVG→PNG is pre-rendered in a process pool before the main loop. Default workers = `min(cpu, pages, 8)`; override with `--workers N` (set `1` for sequential, `0` is treated as sequential).
- Results are cached at `<project>/.cache/svg_png/` keyed by SVG content hash + size + active renderer (`cairosvg` vs `svglib`). Switching renderers naturally invalidates the cache; nothing to clean by hand.
- `--cache-dir <path>` relocates the cache; `--no-cache` forces re-render without writing/reading the cache (handy when debugging rendering).
- Native mode (`--only native`) is unaffected — that path embeds DrawingML shapes and never touches PNG.
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
- Generated export reads every slide back and verifies animation row order, trigger, shape target, resolved effect tuple, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
- The animation writer does not emit `p:bldP` for groups or pictures. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects
- The full registry, OOXML rules, and compatibility boundary are documented in [`pptx-animations.md`](./pptx-animations.md)
Dependency:
@@ -187,16 +193,6 @@ python3 scripts/svg_finalize/flatten_tspan.py examples/<project>/svg_output
python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg
```
### `svg_rect_to_path.py`
```bash
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path>
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final
python3 scripts/svg_finalize/svg_rect_to_path.py path/to/file.svg
```
Use when rounded corners must survive PowerPoint shape conversion.
### `align_embed_images.py`
```bash
@@ -220,17 +216,13 @@ python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
Replaces `<use data-icon="chunk-filled/name" .../>`, `<use data-icon="tabler-filled/name" .../>` and `<use data-icon="tabler-outline/name" .../>` placeholders with actual SVG path elements. Use for manual icon embedding checks outside `finalize_svg.py`.
## PPT Compatibility Rules
## SVG Compatibility Contract
Use PowerPoint-safe transparency syntax:
The canonical SVG authoring and native-mapping contract lives exclusively in
[`shared-standards.md`](../../references/shared-standards.md). This tool guide
does not repeat accepted syntax, rejected constructs, or conditional limits.
| Avoid | Use instead |
|------|-------------|
| `fill=\"rgba(...)\"` | `fill=\"#hex\"` + `fill-opacity` |
| `<g opacity=\"...\">` | Set opacity on each child |
| `<image opacity=\"...\">` | Overlay with a mask layer |
PowerPoint also has trouble with unsupported filters and direct SVG features
not mapped to DrawingML. Connector arrows may use qualified
`marker-start` / `marker-end`; chunky or exotic arrows should be standalone
`<path>` / `<polygon>` shapes.
`svg_quality_checker.py` validates source SVG before finalization.
`finalize_svg.py` and native export apply the preprocessing required by that
contract, while native conversion fails on unsupported visual elements rather
than silently dropping them.
@@ -15,12 +15,15 @@ python3 scripts/project_manager.py validate <project_path>
1. Check the file path and filename.
2. Confirm naming conventions are consistent.
3. Preview via a local server if browser file loading is inconsistent:
3. Run the mandatory post-processing step when you need a self-contained preview:
```bash
python3 -m http.server --directory <svg_output_path> 8000
python3 scripts/finalize_svg.py <project_path>
python3 -m http.server --directory <project_path>/svg_final 8000
```
`svg_final/` is the visual-preview artifact and may be inserted into PowerPoint as an SVG picture. Fix authored content in `svg_output/`, then regenerate `svg_final/`; do not edit the derived preview as the source.
## Speaker Notes Do Not Split
Check `total.md`:
@@ -44,7 +47,11 @@ python3 scripts/finalize_svg.py <project_path>
python3 scripts/svg_to_pptx.py <project_path>
```
Do not export directly from `svg_output/` when `svg_final/` exists.
Keep all three steps even though they have different consumers: Step 7.2 creates the mandatory `svg_final/` visual preview, while the supported native PPTX exporter reads `svg_output/` directly. Do not pass `-s final` for a release export; that override is diagnostic-only.
## Inserted SVG Does Not Convert Cleanly to Shapes
PowerPoint's manual Convert-to-Shape behavior is unsupported. `svg_final/` is supported only as a visual preview / SVG picture. Use the native PPTX generated from `svg_output/` when editable DrawingML shapes are required.
## Recorded Narration Missing
@@ -162,11 +162,11 @@ class ErrorHelper:
'severity': 'error'
},
'id_attribute_detected': {
'message': 'Forbidden id attribute detected',
'message': 'Forbidden CSS selector usage with id detected',
'solutions': [
'Remove all id attributes',
'Use inline styles instead',
'Avoid relying on selectors for positioning or style reuse'
'Keep IDs for local references or documented semantic/animation groups',
'Remove <style> rules and CSS selectors',
'Use inline presentation attributes instead'
],
'severity': 'error'
},
@@ -180,15 +180,6 @@ class ErrorHelper:
],
'severity': 'error'
},
'symbol_use_detected': {
'message': 'Forbidden <symbol> + <use> complex usage detected',
'solutions': [
'Expand <symbol> into actual SVG code',
'Avoid <symbol> + <use> reuse structures',
'Embed SVG paths directly when icons are needed'
],
'severity': 'error'
},
# Note: <marker> and marker-end are NO LONGER forbidden — they are
# conditionally allowed (see references/shared-standards.md §1.1).
# The converter maps qualifying markers to native DrawingML arrow heads.
@@ -201,33 +192,6 @@ class ErrorHelper:
],
'severity': 'error'
},
'rgba_detected': {
'message': 'Forbidden rgba() color detected',
'solutions': [
'Replace rgba() with hex + opacity notation',
'Example: fill="#FFFFFF" fill-opacity="0.1"',
'Use stroke-opacity for strokes'
],
'severity': 'error'
},
'group_opacity_detected': {
'message': 'Forbidden <g opacity> detected',
'solutions': [
'Remove group-level opacity',
'Set opacity individually on each child element',
'Use fill-opacity / stroke-opacity for control'
],
'severity': 'error'
},
'image_opacity_detected': {
'message': 'Forbidden <image opacity> detected',
'solutions': [
'Remove image opacity attribute',
'Add a <rect> overlay to control transparency',
'Ensure overlay color matches the background'
],
'severity': 'error'
},
'event_attribute_detected': {
'message': 'Forbidden event attribute detected',
'solutions': [
@@ -2,9 +2,12 @@
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)
Processes SVG files from svg_output/ and outputs them to svg_final/.
By default, all processing steps are executed. You can also specify
individual steps via arguments.
Processes SVG files from svg_output/ and produces the visual preview in
svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
to read svg_output/ by default; svg_final/ may be opened directly or inserted
as an SVG image. EMF/WMF assets retain their external-reference exception.
By default, all processing steps are executed. You can also specify individual
steps via arguments.
Architecture note: this module's outputs feed svg_final/ on disk AND its
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
@@ -17,27 +20,26 @@ Usage:
python3 scripts/finalize_svg.py <project_directory>
# Execute only specific steps
python3 scripts/finalize_svg.py <project_directory> --only embed-icons fix-rounded
python3 scripts/finalize_svg.py <project_directory> --only embed-icons align-images
Examples:
python3 scripts/finalize_svg.py projects/my_project
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
Processing options:
embed-icons - Replace <use data-icon="..."/> with actual icon SVG
embed-icons - Expand project icons and static same-document <use>
align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
Replaces the former crop-images + fix-aspect + embed-images
trio. The old names remain accepted as aliases for the
merged step, so existing --only invocations keep working.
flatten-text - Convert <tspan> to independent <text> (for special renderers)
fix-rounded - Convert <rect rx="..."/> to <path> (for PPT shape conversion)
"""
import os
import sys
import shutil
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
@@ -51,6 +53,14 @@ from svg_finalize.align_embed_images import (
count_office_vector_refs_in_svg,
)
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
from svg_to_pptx.geometry_properties import (
GeometryStyleError,
materialize_inline_geometry_in_file,
)
from svg_to_pptx.use_expander import (
UseExpansionError,
expand_local_use_references_in_file,
)
def safe_print(text: str) -> None:
@@ -92,28 +102,6 @@ def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
return False
def process_rounded_rect(svg_file: Path, verbose: bool = False) -> int:
"""Convert rounded rectangles in a single SVG file (in-place modification)"""
try:
from svg_finalize.svg_rect_to_path import process_svg
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose=False)
if count > 0:
with open(svg_file, 'w', encoding='utf-8') as f:
f.write(processed)
if verbose:
safe_print(f" [OK] {svg_file.name}: {count} rounded rectangle(s)")
return count
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return 0
def finalize_project(
project_dir: Path,
options: dict[str, bool],
@@ -167,19 +155,62 @@ def finalize_project(
if not quiet:
print()
# Step 2: Embed icons
# Core normalization: downstream image/rect processors read XML geometry.
geometry_count = 0
for svg_file in svg_final.glob('*.svg'):
try:
geometry_count += materialize_inline_geometry_in_file(svg_file)
except (OSError, ET.ParseError, GeometryStyleError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
)
return False
# Step 2: Expand project icons, then standard same-document use references.
if options.get('embed_icons'):
if not quiet:
safe_print("[1/4] Embedding icons...")
safe_print("[1/3] Expanding icons + local use references...")
icons_count = 0
for svg_file in svg_final.glob('*.svg'):
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir)
count = embed_icons_in_file(
svg_file,
icons_dir,
dry_run=False,
verbose=False,
fallback_dir=icons_fallback_dir,
)
icons_count += count
for svg_file in svg_final.glob('*.svg'):
try:
geometry_count += materialize_inline_geometry_in_file(svg_file)
except (OSError, ET.ParseError, GeometryStyleError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: expanded icon geometry "
f"materialization failed: {exc}"
)
return False
local_use_count = 0
for svg_file in svg_final.glob('*.svg'):
try:
local_use_count += expand_local_use_references_in_file(svg_file)
except (OSError, ET.ParseError, UseExpansionError) as exc:
safe_print(
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
)
return False
if not quiet:
if icons_count > 0:
safe_print(f" {icons_count} icon(s) embedded")
else:
safe_print(" No icons")
if local_use_count > 0:
safe_print(f" {local_use_count} local use reference(s) expanded")
else:
safe_print(" No local use references")
if not quiet and geometry_count:
safe_print(
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
)
# Step 3: Align (slice/meet) and Base64-embed all <image> in one pass.
# Replaces the former crop-images / fix-aspect / embed-images trio: the
@@ -189,7 +220,7 @@ def finalize_project(
# from disk once.
if options.get('align_images'):
if not quiet:
safe_print("[2/4] Aligning + embedding images...")
safe_print("[2/3] Aligning + embedding images...")
img_count = 0
img_errors = 0
office_vector_count = 0
@@ -227,7 +258,7 @@ def finalize_project(
# Step 4: Flatten text
if options.get('flatten_text'):
if not quiet:
safe_print("[3/4] Flattening text...")
safe_print("[3/3] Flattening text...")
flatten_count = 0
for svg_file in svg_final.glob('*.svg'):
if process_flatten_text(svg_file, verbose=False):
@@ -238,20 +269,6 @@ def finalize_project(
else:
safe_print(" No processing needed")
# Step 5: Convert rounded rects to Path
if options.get('fix_rounded'):
if not quiet:
safe_print("[4/4] Converting rounded rects to Path...")
rounded_count = 0
for svg_file in svg_final.glob('*.svg'):
count = process_rounded_rect(svg_file, verbose=False)
rounded_count += count
if not quiet:
if rounded_count > 0:
safe_print(f" {rounded_count} rounded rectangle(s) converted")
else:
safe_print(" No rounded rectangles")
# Done
if not quiet:
print()
@@ -271,14 +288,13 @@ def main() -> None:
epilog='''
Examples:
%(prog)s projects/my_project # Execute all processing (default)
%(prog)s projects/my_project --only embed-icons fix-rounded
%(prog)s projects/my_project --only embed-icons align-images
%(prog)s projects/my_project -q # Quiet mode
Processing options (for --only):
embed-icons Embed icons
embed-icons Expand project icons and static same-document <use>
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
flatten-text Flatten text
fix-rounded Convert rounded rects to Path
Aliases (still accepted):
crop-images, fix-aspect, embed-images all map to align-images
@@ -293,7 +309,7 @@ Aliases (still accepted):
'align-images',
# Backwards-compatible aliases — all three map to align-images now.
'crop-images', 'fix-aspect', 'embed-images',
'flatten-text', 'fix-rounded',
'flatten-text',
],
help=('Execute only specified processing steps (default: all). '
'crop-images / fix-aspect / embed-images are accepted as '
@@ -329,7 +345,6 @@ Aliases (still accepted):
'embed_icons': 'embed-icons' in only,
'align_images': bool(only & _ALIGN_ALIASES),
'flatten_text': 'flatten-text' in only,
'fix_rounded': 'fix-rounded' in only,
}
else:
# Execute all by default
@@ -337,7 +352,6 @@ Aliases (still accepted):
'embed_icons': True,
'align_images': True,
'flatten_text': True,
'fix_rounded': True,
}
if args.max_dimension < 1:
@@ -42,7 +42,19 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_animations import TRANSITIONS, create_transition_xml # noqa: E402
from pptx_animations import ( # noqa: E402
object_animation_fingerprint,
validate_pptx_animation_package,
)
from pptx_transitions import ( # noqa: E402
AdvanceUpdate,
EnterUpdate,
TRANSITIONS,
apply_slide_motion_xml,
set_directory_use_timings,
validate_pptx_transition_package,
validate_seconds,
)
from svg_to_pptx.pptx_package.builder import ( # noqa: E402
_add_default_content_type,
_append_relationship,
@@ -55,7 +67,6 @@ from svg_to_pptx.pptx_package.narration import ( # noqa: E402
IMAGE_REL_TYPE,
MEDIA_REL_TYPE,
NARRATION_EXTENSIONS,
apply_recorded_timing,
inject_narration,
next_shape_id,
probe_audio_duration,
@@ -96,6 +107,20 @@ def _sanitize_slug(value: str) -> str:
return slug or "native_enhance"
def _positive_seconds_arg(value: str) -> float:
try:
return validate_seconds(value, "transition duration", allow_zero=False)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def _non_negative_seconds_arg(value: str) -> float:
try:
return validate_seconds(value, "narration padding", allow_zero=True)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def _read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
@@ -276,39 +301,6 @@ def _add_notes_content_types(content_types: str, note_indices: set[int]) -> str:
return content_types
def _set_transition_only(slide_xml: str, effect: str, duration: float) -> str:
transition_xml = create_transition_xml(effect=effect, duration=duration)
if re.search(r"<p:transition\b[^>]*/>", slide_xml):
return re.sub(r"\s*<p:transition\b[^>]*/>", "\n" + transition_xml, slide_xml, count=1)
if re.search(r"<p:transition\b[^>]*>.*?</p:transition>", slide_xml, re.S):
return re.sub(
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
"\n" + transition_xml,
slide_xml,
count=1,
flags=re.S,
)
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
def _apply_recorded_timing_without_transition(slide_xml: str, advance_after: float) -> str:
adv_ms = max(1, int(advance_after * 1000))
transition_xml = f' <p:transition advTm="{adv_ms}"/>'
slide_xml = re.sub(r"\s*<p:transition\b[^>]*/>", "", slide_xml, count=1)
slide_xml = re.sub(
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
"",
slide_xml,
count=1,
flags=re.S,
)
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
def _apply_notes(extract_dir: Path, slide: SlidePart, note_md: Path) -> None:
notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8"))
if not notes_text:
@@ -346,10 +338,10 @@ def _apply_audio(
slide: SlidePart,
audio_path: Path,
*,
transition: str,
transition_duration: float,
enter: EnterUpdate,
timings_enabled: bool,
narration_padding: float,
) -> None:
) -> bool:
media_dir = extract_dir / "ppt" / "media"
media_dir.mkdir(parents=True, exist_ok=True)
@@ -370,6 +362,7 @@ def _apply_audio(
slide_xml_path = extract_dir / slide.part_name
slide_xml = slide_xml_path.read_text(encoding="utf-8")
source_animation_fingerprint = object_animation_fingerprint(slide_xml)
shape_id = next_shape_id(slide_xml)
slide_xml = inject_narration(
slide_xml,
@@ -380,20 +373,29 @@ def _apply_audio(
poster_rid=poster_rid,
)
duration = probe_audio_duration(audio_path)
if duration is None:
raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}")
advance_after = duration + narration_padding
if transition == "none":
slide_xml = _apply_recorded_timing_without_transition(slide_xml, advance_after)
else:
slide_xml = apply_recorded_timing(
advance = AdvanceUpdate(mode="preserve")
if timings_enabled:
duration = probe_audio_duration(audio_path)
if duration is None:
raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}")
advance = AdvanceUpdate(
mode="narration",
after=duration + narration_padding,
)
wrote_advance = False
if enter.policy != "preserve" or timings_enabled:
slide_xml, wrote_advance = apply_slide_motion_xml(
slide_xml,
advance_after=advance_after,
transition_duration=transition_duration,
transition_effect=transition,
enter=enter,
advance=advance,
)
if object_animation_fingerprint(slide_xml) != source_animation_fingerprint:
raise RuntimeError(
f"Slide {slide.index} object animations changed while adding narration"
)
slide_xml_path.write_text(slide_xml, encoding="utf-8")
return timings_enabled and wrote_advance
def _update_content_types(extract_dir: Path, note_indices: set[int], audio_exts: set[str]) -> None:
@@ -441,6 +443,32 @@ def _enabled_modules(plan: dict) -> set[str]:
return enabled
def _resolve_enter_update(
*,
cli_effect: str | None,
configured_effect: object,
transitions_enabled: bool,
duration: float,
) -> EnterUpdate:
if cli_effect is None and not transitions_enabled:
if configured_effect == "none":
return EnterUpdate(policy="none", effect=None, duration=duration)
return EnterUpdate(policy="preserve", duration=duration)
effect = cli_effect if cli_effect is not None else configured_effect
if not isinstance(effect, str):
raise ValueError(f"transition effect must be a string: {effect!r}")
if effect == "none":
return EnterUpdate(policy="none", effect=None, duration=duration)
if effect not in TRANSITIONS:
valid = ", ".join(sorted(TRANSITIONS))
raise ValueError(
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
)
return EnterUpdate(policy="replace", effect=effect, duration=duration)
def _plan_confirmed(plan: dict) -> bool:
return plan.get("status") == "confirmed"
@@ -661,32 +689,64 @@ def apply_project(args: argparse.Namespace) -> int:
return 1
modules = _enabled_modules(plan)
transitions_cfg = (
plan.get("modules", {}).get("transitions", {})
if isinstance(plan.get("modules"), dict)
else {}
)
timings_cfg = (
plan.get("modules", {}).get("timings", {})
if isinstance(plan.get("modules"), dict)
else {}
)
transition = (
args.transition
or transitions_cfg.get("effect")
or transition_cfg.get("effect")
or "fade"
)
transition_duration = (
args.transition_duration
or transitions_cfg.get("duration")
or float(transition_cfg.get("duration") or 0.5)
)
narration_padding = (
args.narration_padding
if args.narration_padding is not None
else float(timings_cfg.get("narration_padding") or 0.4)
)
modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {}
transitions_cfg = modules_cfg.get("transitions", {})
if not isinstance(transitions_cfg, dict):
transitions_cfg = {}
timings_cfg = modules_cfg.get("timings", {})
if not isinstance(timings_cfg, dict):
timings_cfg = {}
if "effect" in transitions_cfg:
configured_effect = transitions_cfg["effect"]
elif "effect" in transition_cfg:
configured_effect = transition_cfg["effect"]
else:
configured_effect = "fade"
if args.transition_duration is not None:
raw_transition_duration = args.transition_duration
elif "duration" in transitions_cfg:
raw_transition_duration = transitions_cfg["duration"]
elif "duration" in transition_cfg:
raw_transition_duration = transition_cfg["duration"]
else:
raw_transition_duration = 0.5
if args.narration_padding is not None:
raw_narration_padding = args.narration_padding
elif "narration_padding" in timings_cfg:
raw_narration_padding = timings_cfg["narration_padding"]
else:
raw_narration_padding = 0.4
try:
if args.transition is not None or "transitions" in modules:
transition_duration = validate_seconds(
raw_transition_duration,
"transition duration",
allow_zero=False,
)
else:
transition_duration = 0.5
if "timings" in modules:
narration_padding = validate_seconds(
raw_narration_padding,
"narration padding",
allow_zero=True,
)
else:
narration_padding = 0.4
enter_update = _resolve_enter_update(
cli_effect=args.transition,
configured_effect=configured_effect,
transitions_enabled="transitions" in modules,
duration=transition_duration,
)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
apply_transition_without_audio = (
args.apply_transition_without_audio
or bool(transitions_cfg.get("apply_without_audio"))
@@ -710,6 +770,7 @@ def apply_project(args: argparse.Namespace) -> int:
audio_exts: set[str] = set()
audio_count = 0
transition_only_count = 0
wrote_auto_advance = False
for slide in slides:
note = _note_path(notes_dir, slide.index)
if "notes" in modules and note:
@@ -718,33 +779,75 @@ def apply_project(args: argparse.Namespace) -> int:
audio = _audio_path(audio_dir, slide.index)
if "audio" in modules and audio:
_apply_audio(
wrote_auto_advance = _apply_audio(
extract_dir,
slide,
audio,
transition=transition if "transitions" in modules else "none",
transition_duration=transition_duration,
narration_padding=narration_padding if "timings" in modules else 0,
)
enter=enter_update,
timings_enabled="timings" in modules,
narration_padding=narration_padding,
) or wrote_auto_advance
audio_exts.add(audio.suffix.lower())
audio_count += 1
continue
if (
"transitions" in modules
and apply_transition_without_audio
and transition != "none"
apply_transition_without_audio
and enter_update.policy != "preserve"
):
slide_xml_path = extract_dir / slide.part_name
slide_xml = slide_xml_path.read_text(encoding="utf-8")
source_animation_fingerprint = object_animation_fingerprint(
slide_xml
)
slide_xml, _uses_timings = apply_slide_motion_xml(
slide_xml,
enter=enter_update,
advance=AdvanceUpdate(mode="preserve"),
)
if (
object_animation_fingerprint(slide_xml)
!= source_animation_fingerprint
):
raise RuntimeError(
f"Slide {slide.index} object animations changed while "
"updating the transition"
)
slide_xml_path.write_text(
_set_transition_only(slide_xml, transition, transition_duration),
slide_xml,
encoding="utf-8",
)
transition_only_count += 1
_update_content_types(extract_dir, note_indices, audio_exts)
_zip_dir(extract_dir, output_path)
if wrote_auto_advance:
set_directory_use_timings(extract_dir)
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix="native-enhance-output-",
dir=output_path.parent,
) as output_tmp:
candidate_path = Path(output_tmp) / output_path.name
_zip_dir(extract_dir, candidate_path)
try:
validate_pptx_transition_package(
candidate_path,
require_use_timings=wrote_auto_advance,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX transition package validation failed: {exc}"
) from exc
try:
validate_pptx_animation_package(
candidate_path,
require_supported_effects=False,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX animation/timing package validation failed: {exc}"
) from exc
candidate_path.replace(output_path)
print(f"Output: {output_path}", file=sys.stderr)
print(f"Notes applied: {len(note_indices)}", file=sys.stderr)
@@ -813,8 +916,8 @@ def build_parser() -> argparse.ArgumentParser:
init.add_argument("--project-dir", default=None, help="explicit project directory")
init.add_argument("--projects-root", default="projects", help="projects root (default: projects)")
init.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()))
init.add_argument("--transition-duration", type=float, default=0.5)
init.add_argument("--narration-padding", type=float, default=0.4)
init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
init.add_argument(
"--apply-transition-without-audio",
action="store_true",
@@ -825,8 +928,8 @@ def build_parser() -> argparse.ArgumentParser:
plan = subparsers.add_parser("plan", help="draft an enhancement module plan")
plan.add_argument("project_path", help="native enhancement project directory")
plan.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()) + ["none"])
plan.add_argument("--transition-duration", type=float, default=0.5)
plan.add_argument("--narration-padding", type=float, default=0.4)
plan.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
plan.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
plan.add_argument(
"--apply-transition-without-audio",
action="store_true",
@@ -839,8 +942,8 @@ def build_parser() -> argparse.ArgumentParser:
apply.add_argument("-o", "--output", default=None, help="output .pptx path")
apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists")
apply.add_argument("--transition", default=None, choices=sorted(TRANSITIONS.keys()) + ["none"])
apply.add_argument("--transition-duration", type=float, default=None)
apply.add_argument("--narration-padding", type=float, default=None)
apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None)
apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None)
apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan")
apply.add_argument(
"--apply-transition-without-audio",
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,8 @@ PPT Master - PPTX Intake Enrichment
Extract reusable PPTX intake facts into a standard analysis bundle. This is a
read-only companion to `ppt_to_md.py`: Markdown remains the content source,
while this bundle provides canvas, visual identity, slide geometry, tables, and
native chart data for downstream workflows.
while this bundle provides canvas, visual identity, slide geometry, tables,
native chart data, and SmartArt structure for downstream workflows.
Usage:
python3 scripts/pptx_intake.py <source.pptx> -o <output_dir>
@@ -90,6 +90,49 @@ def _table_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
return {"table_count": len(tables), "tables": tables}
def _diagram_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
diagrams: list[dict[str, Any]] = []
text_item_count = 0
unreadable_count = 0
warning_count = 0
slides_with_diagrams: set[int] = set()
for slide in slide_library.get("slides", []):
slide_index = slide.get("slide_index")
for diagram in slide.get("diagrams", []):
node_count = int(diagram.get("node_count") or 0)
text_count = int(diagram.get("text_count") or 0)
text_item_count += text_count
if not diagram.get("text_extracted"):
unreadable_count += 1
if diagram.get("status") != "ok" or diagram.get("warnings"):
warning_count += 1
if isinstance(slide_index, int):
slides_with_diagrams.add(slide_index)
diagrams.append(
{
"slide_index": slide_index,
"diagram_id": diagram.get("diagram_id"),
"layout": diagram.get("layout", {}),
"node_count": node_count,
"text_count": text_count,
"connection_count": int(diagram.get("connection_count") or 0),
"max_depth": int(diagram.get("max_depth") or 0),
"text_extracted": bool(diagram.get("text_extracted")),
"has_persisted_drawing": bool(diagram.get("has_persisted_drawing")),
"status": diagram.get("status"),
"warnings": diagram.get("warnings", []),
}
)
return {
"diagram_count": len(diagrams),
"text_item_count": text_item_count,
"unreadable_count": unreadable_count,
"warning_count": warning_count,
"slides_with_diagrams": sorted(slides_with_diagrams),
"diagrams": diagrams,
}
def build_source_profile(
pptx_path: Path,
identity: dict[str, Any],
@@ -118,7 +161,8 @@ def build_source_profile(
"into locked constraints after user confirmation."
),
"template_fill": (
"Use slide slots, tables, charts, and geometry as the native PPTX fill contract."
"Use slide slots, tables, charts, diagrams, and geometry as the native PPTX "
"fill contract; diagrams are inventory-only and remain unchanged."
),
},
"artifacts": {
@@ -142,12 +186,14 @@ def build_source_profile(
"slide_index": slide.get("slide_index"),
"page_type": slide.get("page_type"),
"slot_count": len(slide.get("slots", [])),
"diagram_count": len(slide.get("diagrams", [])),
}
for slide in slide_library.get("slides", [])
],
},
"tables": _table_summary(slide_library),
"charts": _chart_summary(slide_library),
"diagrams": _diagram_summary(slide_library),
}
@@ -104,6 +104,7 @@ def convert_blip_fill(
filename, img_bytes = _normalize_office_media(filename, img_bytes)
filename, img_bytes = _apply_blip_image_effects(filename, img_bytes, blip)
href = _build_href(filename, img_bytes, media_subdir, embed_inline)
opacity_attr = _blip_opacity_attr(blip)
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
src_rect = blip_fill_elem.find("a:srcRect", NS)
@@ -120,7 +121,7 @@ def convert_blip_fill(
svg = (
f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
f'preserveAspectRatio="none"/>'
f'preserveAspectRatio="none"{opacity_attr}/>'
)
else:
# Crop expressed as a unit-rectangle viewBox on a nested <svg>.
@@ -132,7 +133,7 @@ def convert_blip_fill(
f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
f'preserveAspectRatio="none">'
f'<image href="{href}" x="0" y="0" width="1" height="1" '
f'preserveAspectRatio="none"/>'
f'preserveAspectRatio="none"{opacity_attr}/>'
f"</svg>"
)
@@ -169,6 +170,20 @@ def convert_picture(
# Helpers
# ---------------------------------------------------------------------------
def _blip_opacity_attr(blip: ET.Element) -> str:
"""Translate DrawingML fixed image alpha to an SVG opacity attribute."""
alpha = blip.find("a:alphaModFix", NS)
if alpha is None:
return ""
try:
opacity = max(0.0, min(1.0, float(alpha.attrib.get("amt", "100000")) / 100000.0))
except ValueError:
return ""
if opacity >= 1.0:
return ""
return f' opacity="{fmt_num(opacity, 5)}"'
_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
File diff suppressed because it is too large Load Diff
@@ -167,7 +167,7 @@ class ProjectManager:
f"- Created: {date_str}\n\n"
"## Directories\n\n"
"- `svg_output/`: raw SVG output\n"
"- `svg_final/`: finalized SVG output\n"
"- `svg_final/`: self-contained SVG visual preview; may be inserted manually as an SVG image, but PowerPoint Convert to Shape is unsupported\n"
"- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n"
"- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
"- `notes/`: speaker notes\n"
@@ -175,7 +175,7 @@ class ProjectManager:
"- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
"- `sources/`: source materials and normalized markdown\n"
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added with `--svg-snapshot`, `_native_charts.pptx` name with `--native-objects`, `_narrated.pptx` name when narration audio is embedded\n"
"- `exports/`: native DrawingML pptx (timestamped); `_native_charts.pptx` name with `--native-objects`, `_narrated.pptx` name when narration audio is embedded\n"
"- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
),
encoding="utf-8",
@@ -2,8 +2,8 @@
"""
PowerPoint to Markdown Converter
Extracts slide text, tables, speaker notes, and embedded pictures from
Open XML PowerPoint files into Markdown.
Extracts slide text, tables, SmartArt node structure, speaker notes, and
embedded pictures from Open XML PowerPoint files into Markdown.
Primary use case: PPTX source decks -> Markdown for PPT generation input.
@@ -29,10 +29,12 @@ import json
import re
import shutil
import sys
import zipfile
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from urllib.parse import quote
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
@@ -41,6 +43,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
from template_fill_pptx.diagram_read import ( # noqa: E402
read_smartart_diagrams,
smartart_to_markdown,
)
from pptx import Presentation
from pptx.enum.action import PP_ACTION
@@ -721,6 +727,39 @@ def convert_presentation_to_markdown(
_reset_generated_asset_dir(asset_dir)
presentation = Presentation(str(input_file))
conversion_warnings: list[str] = []
diagrams_by_slide: dict[int, list[dict[str, object]]] = {}
diagram_scan_failures: dict[int, str] = {}
try:
with zipfile.ZipFile(input_file) as package:
for slide_index, slide in enumerate(presentation.slides, 1):
slide_part = str(slide.part.partname).lstrip("/")
try:
diagrams = read_smartart_diagrams(package, slide_part, slide_index)
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
diagrams_by_slide[slide_index] = []
diagram_scan_failures[slide_index] = str(exc)
conversion_warnings.append(
f"Slide {slide_index}: SmartArt scan failed: {exc}"
)
continue
diagrams_by_slide[slide_index] = diagrams
for diagram in diagrams:
issues = [str(item) for item in diagram.get("warnings", []) if item]
if diagram.get("status") != "ok":
issues.insert(0, f"status={diagram.get('status')}")
if not issues:
continue
conversion_warnings.append(
f"Slide {slide_index}, {diagram.get('shape_name') or diagram.get('diagram_id')}: "
f"SmartArt content {'; '.join(issues)}"
)
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
conversion_warnings.append(f"SmartArt package scan failed: {exc}")
for slide_index in range(1, len(presentation.slides) + 1):
diagrams_by_slide.setdefault(slide_index, [])
diagram_scan_failures.setdefault(slide_index, str(exc))
lines = [
f"# {input_file.stem}",
"",
@@ -741,6 +780,13 @@ def convert_presentation_to_markdown(
lines.append("")
blocks = []
slide_diagrams = diagrams_by_slide.get(slide_index, [])
diagrams_by_shape_id = {
str(diagram.get("shape_id")): diagram
for diagram in slide_diagrams
if diagram.get("shape_id") is not None
}
emitted_diagram_ids: set[str] = set()
for item in iter_leaf_shapes(slide.shapes):
shape = item.shape
@@ -750,6 +796,13 @@ def convert_presentation_to_markdown(
blocks.append(table_md)
continue
shape_id = str(getattr(shape, "shape_id", ""))
diagram = diagrams_by_shape_id.get(shape_id)
if diagram is not None:
blocks.append(smartart_to_markdown(diagram))
emitted_diagram_ids.add(str(diagram.get("diagram_id")))
continue
is_picture_shape = shape.shape_type in {
MSO_SHAPE_TYPE.PICTURE,
MSO_SHAPE_TYPE.LINKED_PICTURE,
@@ -795,6 +848,15 @@ def convert_presentation_to_markdown(
except (ValueError, AttributeError, KeyError):
blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}")
for diagram in slide_diagrams:
if str(diagram.get("diagram_id")) in emitted_diagram_ids:
continue
blocks.append(smartart_to_markdown(diagram))
if slide_index in diagram_scan_failures:
blocks.append(
f"> [SmartArt scan unavailable: {diagram_scan_failures[slide_index]}]"
)
if blocks:
lines.append("\n\n".join(blocks))
lines.append("")
@@ -822,6 +884,7 @@ def convert_presentation_to_markdown(
converter="ppt_to_md.py",
conversion_type=suffix.lstrip("."),
asset_dir=asset_dir,
warnings=conversion_warnings,
)
print(f"[OK] Saved Markdown to: {out_file}")
@@ -34,7 +34,7 @@ import urllib.request
import webbrowser
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from typing import Iterable, Optional
from flask import Flask, jsonify, request, send_from_directory
@@ -89,6 +89,11 @@ from embed_icons import ( # noqa: E402
extract_paths_from_icon,
generate_icon_group,
)
from svg_to_pptx.geometry_properties import ( # noqa: E402
GeometryStyleError,
INLINE_GEOMETRY_PROPERTIES,
materialize_inline_geometry_properties,
)
_ICONS_DIR = _SCRIPTS_DIR.parent.parent / 'templates' / 'icons'
_USE_ICON_PATTERN = re.compile(r'<use\s+[^>]*data-icon="[^"]*"[^>]*/>')
@@ -184,6 +189,47 @@ def _inline_icons(content: str) -> tuple[str, list[dict]]:
return new_content, warnings
def _strip_edited_inline_geometry(
elem: ET.Element,
attr_names: Iterable[str],
) -> None:
"""Remove style declarations superseded by edited geometry attributes."""
style = elem.get('style')
if not style:
return
tag = elem.tag.rsplit('}', 1)[-1] if '}' in elem.tag else elem.tag
supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
edited = {
str(name).lower()
for name in attr_names
if str(name).lower() in supported
}
if not edited:
return
retained = []
changed = False
for raw_declaration in style.split(';'):
declaration = raw_declaration.strip()
if not declaration:
continue
if ':' not in declaration:
retained.append(declaration)
continue
raw_name, _raw_value = declaration.split(':', 1)
if raw_name.strip().lower() in edited:
changed = True
continue
retained.append(declaration)
if not changed:
return
if retained:
elem.set('style', '; '.join(retained))
else:
elem.attrib.pop('style', None)
# ---------------------------------------------------------------------------
# Staged-edit value validation (POST /api/slide/<name>/edit).
# The browser may expose raw element attributes, so validation protects only
@@ -290,6 +336,10 @@ def _apply_edit_record(root: ET.Element, record: dict) -> tuple[bool, Optional[s
return ok, reason
attrs = record.get('attrs')
if attrs:
target = _find_by_id(root, element_id)
if target is None:
return False, 'not-found'
_strip_edited_inline_geometry(target, attrs.keys())
ok, reason = set_attributes(root, element_id, attrs)
if not ok:
return ok, reason
@@ -578,6 +628,12 @@ def create_app(
logger.warning('slide parse failed: %s: %s', name, exc)
return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
try:
materialize_inline_geometry_properties(root)
except GeometryStyleError as exc:
logger.warning('slide geometry materialization failed: %s: %s', name, exc)
return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
assign_temp_ids(root)
if pending_edits:
ok, reason = _apply_edit_records(root, pending_edits)
@@ -712,6 +768,11 @@ def create_app(
except ET.ParseError as exc:
return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
try:
materialize_inline_geometry_properties(root)
except GeometryStyleError as exc:
return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
assign_temp_ids(root)
pending = app.config['PENDING_EDITS'].get(name) or []
ok, reason = _apply_edit_records(root, pending)
@@ -741,6 +802,7 @@ def create_app(
staged['text'] = new_text
if attrs:
old_attrs = {k: target.get(k) for k in attrs}
_strip_edited_inline_geometry(target, attrs.keys())
ok, reason = set_attributes(root, element_id, attrs)
if not ok:
return jsonify({'error': f'Attribute edit failed: {reason}'}), (
@@ -901,7 +901,48 @@
applyElementAttrs(el, sa);
}
var INLINE_GEOMETRY_STYLE_PROPERTIES = {
rect: new Set(["x", "y", "width", "height", "rx", "ry"]),
circle: new Set(["cx", "cy", "r"]),
ellipse: new Set(["cx", "cy", "rx", "ry"]),
image: new Set(["x", "y", "width", "height"]),
svg: new Set(["x", "y", "width", "height"]),
use: new Set(["x", "y", "width", "height"])
};
function stripEditedInlineGeometry(el, attrs) {
var style = el.getAttribute("style");
var supported = INLINE_GEOMETRY_STYLE_PROPERTIES[localName(el)];
if (!style || !supported) return;
var edited = new Set(Object.keys(attrs || {}).map(function (key) {
return String(key).toLowerCase();
}).filter(function (key) { return supported.has(key); }));
if (edited.size === 0) return;
var retained = [];
var changed = false;
style.split(";").forEach(function (rawDeclaration) {
var declaration = rawDeclaration.trim();
if (!declaration) return;
var colon = declaration.indexOf(":");
if (colon < 0) {
retained.push(declaration);
return;
}
var name = declaration.slice(0, colon).trim().toLowerCase();
if (edited.has(name)) {
changed = true;
return;
}
retained.push(declaration);
});
if (!changed) return;
if (retained.length > 0) el.setAttribute("style", retained.join("; "));
else el.removeAttribute("style");
}
function applyElementAttrs(el, attrs) {
stripEditedInlineGeometry(el, attrs);
if (localName(el) === "g" && el.hasAttribute("data-icon") &&
attrs.x !== undefined && attrs.y !== undefined) {
// The preview expands <use data-icon> into a <g>, but disk still owns
@@ -2620,7 +2661,7 @@
var attrs = {};
attrs[key] = value;
stageEditRequest(eid, { attrs: attrs })
.then(function () { el.setAttribute(key, value); })
.then(function () { applyElementAttrs(el, attrs); })
.catch(function (err) { showError(t("err_edit") + err.message); });
}
@@ -2856,7 +2897,7 @@
var attrs = {};
attrs[key] = value;
return stageEditRequest(el.id, { attrs: attrs }).then(function () {
el.setAttribute(key, value);
applyElementAttrs(el, attrs);
});
});
Promise.all(jobs)
@@ -9,12 +9,12 @@ Replaces the previous three independent finalize_svg steps:
reference points to a pre-cropped asset.
fix-aspect for each <image>, read the source bitmap dimensions and
adjust x/y/width/height so the rendered box matches the
image aspect ratio (PowerPoint's "Convert to Shape"
ignores preserveAspectRatio and stretches otherwise).
embed-images Base64-inline every external image reference so the
legacy/preview pptx (which packages the SVG verbatim)
can resolve them pptx-internal SVG cannot follow
``../images/`` relative URIs.
image aspect ratio in PowerPoint SVG rendering paths that
do not honor preserveAspectRatio consistently.
embed-images Base64-inline every embeddable external image reference
so ``svg_final/`` remains portable when opened or manually
inserted as an SVG image. EMF/WMF keep the documented
external-reference exception.
Why merge: each step independently parsed + serialized the SVG, each step
re-read the same bitmap from disk, and the two spatial transforms (crop and
@@ -3,11 +3,12 @@
SVG Image Aspect Ratio Fix Tool
Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
This prevents images from being stretched when PowerPoint converts SVG to editable shapes.
This prevents images from being stretched by PowerPoint SVG rendering paths that do not
honor preserveAspectRatio consistently.
Principle:
When PowerPoint converts SVG to editable shapes, it ignores the preserveAspectRatio attribute
and directly stretches the image to fill the area specified by width/height.
Some PowerPoint SVG rendering paths ignore the preserveAspectRatio attribute and directly
stretch the image to fill the area specified by width/height.
This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
<image> elements so that images are centered and maintain their original aspect ratio.
@@ -331,7 +332,7 @@ def fix_image_aspect_in_svg(svg_path: str, dry_run: bool = False, verbose: bool
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='Fix image aspect ratios in SVG to prevent stretching when PowerPoint converts to shapes',
description='Normalize SVG image boxes for PowerPoint SVG rendering diagnostics',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
PPT Master - SVG Rounded Rectangle to Path Tool
PPT Master - Legacy SVG Rounded Rectangle Diagnostic
Solves the issue of rounded corners being lost when using "Convert to Shape" in PowerPoint:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Historical diagnostic that converts <rect> elements with rx/ry to equivalent
<path> elements. It is not part of finalize_svg.py or the supported export
workflow; PowerPoint's manual "Convert to Shape" behavior is not supported.
Usage:
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
@@ -235,7 +236,7 @@ def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Pat
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - SVG Rounded Rectangle to Path Tool',
description='PPT Master - Legacy SVG Rounded Rectangle Diagnostic',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
@@ -245,7 +246,7 @@ Examples:
What it does:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Processed SVGs preserve rounded corners when using "Convert to Shape" in PowerPoint.
Legacy diagnostic only; the standard pipeline preserves SVG rounded rectangles.
'''
)
@@ -269,7 +270,7 @@ What it does:
quiet = args.quiet
if not quiet:
print("PPT Master - SVG Rounded Rectangle to Path Tool")
print("PPT Master - Legacy SVG Rounded Rectangle Diagnostic")
print("=" * 50)
total_converted = 0
@@ -10,6 +10,7 @@ Usage:
python3 scripts/svg_quality_checker.py --all examples
"""
import copy
import sys
import re
import json
@@ -25,11 +26,9 @@ configure_utf8_stdio()
try:
from project_utils import CANVAS_FORMATS
from error_helper import ErrorHelper
except ImportError:
print("Warning: Unable to import dependency modules")
print("Warning: Unable to import project_utils")
CANVAS_FORMATS = {}
ErrorHelper = None
try:
from update_spec import parse_lock as _parse_spec_lock
@@ -40,17 +39,35 @@ try:
from svg_to_pptx.animation_config import (
load_animation_config as _load_animation_config,
validate_animation_config as _validate_animation_config,
validate_animation_config_errors as _validate_animation_config_errors,
validate_transition_config as _validate_transition_config,
)
except ImportError:
except ImportError as exc:
_load_animation_config = None
_validate_animation_config = None
_validate_animation_config_errors = None
_validate_transition_config = None
_animation_config_import_error = str(exc)
else:
_animation_config_import_error = None
try:
from svg_to_pptx.drawingml.utils import (
parse_font_family as _parse_export_font_family,
parse_inline_style as _parse_inline_style,
parse_svg_color as _parse_export_color,
)
except ImportError:
_parse_export_font_family = None
_parse_inline_style = None
_parse_export_color = None
try:
from svg_to_pptx.drawingml.converter import (
collect_unsupported_visuals as _collect_unsupported_visuals,
)
except ImportError:
_collect_unsupported_visuals = None
try:
from svg_to_pptx.native_objects import (
@@ -85,6 +102,26 @@ except ImportError:
})
_validate_semantic_markers = None
try:
from svg_to_pptx.geometry_properties import (
materialize_inline_geometry_properties as _materialize_inline_geometry_properties,
validate_inline_geometry_properties as _validate_inline_geometry_properties,
)
except ImportError:
_materialize_inline_geometry_properties = None
_validate_inline_geometry_properties = None
try:
from svg_to_pptx.use_expander import (
UseExpansionError as _UseExpansionError,
expand_local_use_references as _expand_local_use_references,
validate_local_use_references as _validate_local_use_references,
)
except ImportError:
_UseExpansionError = None
_expand_local_use_references = None
_validate_local_use_references = None
try:
from svg_to_pptx.pptx_package.template_structure import (
TemplateStructureError as _TemplateStructureError,
@@ -133,9 +170,37 @@ except ImportError:
_unresolved_external_image_reference_path = None
HEX_VALUE_RE = re.compile(r"#[0-9A-Fa-f]{3,8}")
HEX_VALUE_RE = re.compile(
r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
)
_BARE_HEX_VALUE_RE = re.compile(
r"(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
)
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
_SUPPORTED_FILTER_PRIMITIVES = frozenset({
'feDropShadow',
'feGaussianBlur',
'feOffset',
'feFlood',
'feComposite',
'feMerge',
'feMergeNode',
'feComponentTransfer',
'feFuncA',
})
_FILTER_EFFECT_PRIMITIVES = frozenset({'feDropShadow', 'feGaussianBlur'})
def _normalize_hex_rgb(value: str) -> str | None:
"""Normalize 3/4/6/8-digit HEX to alpha-free ``RRGGBB``."""
if not HEX_VALUE_RE.fullmatch(value):
return None
color = value[1:]
if len(color) in {3, 4}:
color = ''.join(channel * 2 for channel in color)
return color[:6].upper()
# Fonts that survive direct PPTX typeface assignment on a typical Windows /
# macOS viewer without requiring a custom install. Keep this aligned with
@@ -397,6 +462,12 @@ class SVGQualityChecker:
# 2. Check forbidden elements
self._check_forbidden_elements(content, root, result)
# 2b. Validate the supported shadow/glow filter interface.
self._check_filter_effects(root, result)
# 2c. Reject gradient inheritance and transform semantics.
self._check_gradient_interfaces(root, result)
# 3. Check font-size values
self._check_font_size_values(content, result)
@@ -412,6 +483,9 @@ class SVGQualityChecker:
# 7. Check icon placeholders resolve before post-processing.
self._check_icon_placeholders(root, svg_path, result)
# 7b. Reject visual elements the native converter cannot dispatch.
self._check_unsupported_visual_elements(root, result)
# 8. Check object-level animation anchor quality.
self._check_animation_group_ids(root, result)
@@ -579,15 +653,35 @@ class SVGQualityChecker:
result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
if re.search(r'@import\s+', content_lower):
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
if _validate_inline_geometry_properties is None:
result['warnings'].append(
"Unable to import inline geometry validator; "
"native export will still validate geometry styles."
)
else:
geometry_errors = _validate_inline_geometry_properties(root)
for error in geometry_errors:
result['errors'].append(f"Invalid inline geometry property: {error}")
if not geometry_errors:
_materialize_inline_geometry_properties(root)
# Structure / nesting
if 'foreignobject' in local_names:
result['errors'].append(
"Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
has_symbol = 'symbol' in local_names
has_use = 'use' in local_names
if has_symbol and has_use:
result['errors'].append("Detected forbidden <symbol> + <use> complex usage (use basic shapes or simple <use> instead)")
has_generic_use = any(
_local_name(elem).lower() == 'use' and elem.get('data-icon') is None
for elem in elems
)
if has_generic_use:
if _validate_local_use_references is None:
result['warnings'].append(
"Detected local <use> references, but the shared validator "
"could not be imported; native export will still validate them."
)
else:
for error in _validate_local_use_references(root):
result['errors'].append(f"Invalid local <use> reference: {error}")
# marker-start / marker-end are conditionally allowed (see shared-standards.md §1.1).
# The converter maps qualifying <marker> defs to native DrawingML <a:headEnd>/<a:tailEnd>.
# We only warn when a marker is used without an obvious <defs> definition in the same file.
@@ -616,31 +710,260 @@ class SVGQualityChecker:
# Other discouraged elements
if 'iframe' in local_names:
result['errors'].append("Detected <iframe> element (should not appear in SVG)")
# Paint grammar: rgba()/hsl()/alpha-hex all render in browser preview
# but come back as None from parse_hex_color, so the exporter writes
# <a:noFill/> — the fill silently disappears in PPTX. Named colors and
# rgb() export correctly and are deliberately not flagged.
# Paint-server references must match the exact definitions consumed by
# drawingml.converter.collect_defs: direct children of <defs> only.
defs_by_id = {}
for defs_elem in elems:
if _local_name(defs_elem).lower() != 'defs':
continue
for child in defs_elem:
child_id = child.get('id')
if child_id:
defs_by_id[child_id] = child
pattern_descendant_ids = {
id(descendant)
for pattern in elems
if _local_name(pattern).lower() == 'pattern'
for descendant in pattern.iter()
if descendant is not pattern
}
fill_shape_tags = {'rect', 'circle', 'ellipse', 'path', 'polygon', 'polyline'}
stroke_shape_tags = fill_shape_tags | {'line'}
paint_reference_errors = set()
for elem in elems:
style_values = (
_parse_inline_style(elem.get('style'))
if _parse_inline_style is not None else {}
)
for attr in ('fill', 'stroke'):
value = style_values.get(attr) or elem.get(attr)
match = re.fullmatch(r'url\(#([^)]+)\)', (value or '').strip())
if match is None:
continue
ref_id = match.group(1)
target = defs_by_id.get(ref_id)
elem_tag = _local_name(elem)
elem_tag_lower = elem_tag.lower()
if target is None:
paint_reference_errors.add(
f"<{elem_tag}> {attr}=url(#{ref_id}) has no matching "
"direct <defs> definition"
)
continue
has_text_descendant = any(
_local_name(descendant).lower() in {'text', 'tspan'}
for descendant in elem.iter()
if descendant is not elem
)
if id(elem) in pattern_descendant_ids:
allowed_tags = ()
elif attr == 'fill' and elem_tag_lower in fill_shape_tags:
allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
elif attr == 'stroke' and elem_tag_lower in stroke_shape_tags:
allowed_tags = ('lineargradient', 'radialgradient')
elif attr == 'fill' and elem_tag_lower in {'text', 'tspan'}:
allowed_tags = ('lineargradient', 'radialgradient')
elif attr == 'fill' and elem_tag_lower == 'g':
allowed_tags = (
('lineargradient', 'radialgradient')
if has_text_descendant
else ('lineargradient', 'radialgradient', 'pattern')
)
elif attr == 'stroke' and elem_tag_lower == 'g' and not has_text_descendant:
allowed_tags = ('lineargradient', 'radialgradient')
else:
allowed_tags = ()
target_tag = _local_name(target).lower()
if not allowed_tags:
paint_reference_errors.add(
f"<{elem_tag}> {attr}=url(#{ref_id}) is not supported "
"by native PPTX conversion in this context"
)
continue
if target_tag not in allowed_tags:
tag_labels = {
'lineargradient': 'linearGradient',
'radialgradient': 'radialGradient',
'pattern': 'pattern',
}
expected = '/'.join(
tag_labels[tag] for tag in allowed_tags
)
paint_reference_errors.add(
f"<{elem_tag}> {attr}=url(#{ref_id}) resolves to "
f"<{_local_name(target)}>; expected {expected}"
)
result['errors'].extend(sorted(paint_reference_errors))
# Paint grammar: use the exporter's parser so authoring validation and
# native conversion accept the same CSS color subset.
paint_values = [
value
for attr in ('fill', 'stroke', 'stop-color')
(attr, value)
for attr in (
'fill', 'stroke', 'stop-color', 'flood-color',
'data-pptx-fg', 'data-pptx-bg',
)
for value in self._svg_property_values(content, attr)
]
if any('rgba' in value.lower() for value in paint_values):
result['errors'].append("Detected forbidden rgba() color (use fill-opacity/stroke-opacity instead)")
if any('hsl' in value.lower() for value in paint_values):
result['errors'].append(
"Detected hsl()/hsla() color (not exported to PPTX — fills become "
"invisible; use 6-digit HEX instead)")
alpha_hex_re = re.compile(r'^#[0-9A-Fa-f]{4}$|^#[0-9A-Fa-f]{8}$')
if any(alpha_hex_re.match(value.strip()) for value in paint_values):
result['errors'].append(
"Detected alpha-channel HEX color (#RGBA/#RRGGBBAA is not exported "
"to PPTX — fills become invisible; use 6-digit HEX plus "
"fill-opacity/stroke-opacity)")
if any(_local_name(elem).lower() == 'g' and elem.get('opacity') for elem in elems):
result['errors'].append("Detected forbidden <g opacity> (set opacity on each child element individually)")
if any(_local_name(elem).lower() == 'image' and elem.get('opacity') for elem in elems):
result['errors'].append("Detected forbidden <image opacity> (use overlay mask approach)")
if _parse_export_color is None:
result['warnings'].append(
"Unable to import svg_to_pptx color parser; skipped paint syntax check"
)
else:
invalid_paints = set()
for attr, value in paint_values:
normalized = value.strip()
if attr in {'fill', 'stroke'} and (
normalized.lower() == 'none'
or re.fullmatch(r'url\(#[^)]+\)', normalized)
):
continue
if _BARE_HEX_VALUE_RE.fullmatch(normalized):
invalid_paints.add(value)
continue
color, _alpha = _parse_export_color(normalized)
if color is None:
invalid_paints.add(value)
if invalid_paints:
shown = ', '.join(sorted(invalid_paints)[:5])
more = len(invalid_paints) - 5
suffix = f" (+{more} more)" if more > 0 else ""
result['errors'].append(
"Unsupported SVG paint value(s) for PPTX export: "
f"{shown}{suffix}. Use a supported named color, rgb()/rgba(), "
"hsl()/hsla(), or #RGB/#RGBA/#RRGGBB/#RRGGBBAA."
)
for elem in elems:
tag = _local_name(elem).lower()
if tag not in {'g', 'image'}:
continue
raw_opacity = elem.get('opacity')
if raw_opacity is None:
style_match = re.search(
r'(?:^|;)\s*opacity\s*:\s*([^;]+)',
elem.get('style', ''),
flags=re.IGNORECASE,
)
raw_opacity = style_match.group(1).strip() if style_match else None
if raw_opacity is None:
continue
try:
opacity = float(raw_opacity)
except ValueError:
opacity = -1.0
if not 0.0 <= opacity <= 1.0:
result['errors'].append(
f"<{tag} opacity> must be a numeric value from 0 to 1, got {raw_opacity!r}"
)
if tag == 'g' and opacity < 1.0 and any(
descendant.get('data-pptx-native')
for descendant in elem.iter()
):
result['warnings'].append(
"<g opacity> around data-pptx-native content uses the SVG "
"fallback; --native-objects export rejects that combination"
)
def _check_filter_effects(self, root: ET.Element, result: Dict) -> None:
"""Validate filters against the native shadow/glow approximation."""
elems = list(root.iter())
direct_filters = []
filters_by_id = {}
for defs_elem in elems:
if _local_name(defs_elem) != 'defs':
continue
for child in defs_elem:
if _local_name(child) != 'filter':
continue
direct_filters.append(child)
filter_id = child.get('id')
if filter_id:
filters_by_id[filter_id] = child
issues = set()
for elem in elems:
tag = _local_name(elem)
style_values = (
_parse_inline_style(elem.get('style'))
if _parse_inline_style is not None else {}
)
if style_values.get('filter'):
issues.add(
f"<{tag}> filter must use a direct filter=\"url(#id)\" "
"attribute; inline style filters are not supported"
)
raw_filter = elem.get('filter')
if raw_filter is None:
continue
match = re.fullmatch(r'url\(#([^)]+)\)', raw_filter.strip())
if match is None:
issues.add(
f"<{tag}> filter must be an exact local url(#id) reference; "
f"got {raw_filter!r}"
)
continue
filter_id = match.group(1)
if filter_id not in filters_by_id:
issues.add(
f"<{tag}> filter=url(#{filter_id}) has no matching direct "
f"<defs><filter id=\"{filter_id}\"> definition"
)
for filter_elem in direct_filters:
filter_id = filter_elem.get('id')
label = f"filter #{filter_id}" if filter_id else '<filter> without id'
primitives = [
_local_name(descendant)
for descendant in filter_elem.iter()
if descendant is not filter_elem
]
unsupported = sorted(
set(primitives) - _SUPPORTED_FILTER_PRIMITIVES
)
if unsupported:
issues.add(
f"{label} uses unsupported filter primitive(s): "
f"{', '.join(unsupported)}"
)
if not _FILTER_EFFECT_PRIMITIVES.intersection(primitives):
issues.add(
f"{label} must contain feDropShadow or feGaussianBlur"
)
if any(
_local_name(descendant) == 'feFuncA'
and descendant.get('type') != 'linear'
for descendant in filter_elem.iter()
):
issues.add(f"{label} requires feFuncA type=\"linear\"")
result['errors'].extend(sorted(issues))
def _check_gradient_interfaces(self, root: ET.Element, result: Dict) -> None:
"""Reject gradient inheritance, transforms, and spread modes."""
issues = set()
for gradient in root.iter():
tag = _local_name(gradient)
if tag not in {'linearGradient', 'radialGradient'}:
continue
gradient_id = gradient.get('id')
label = f"<{tag} id=\"{gradient_id}\">" if gradient_id else f'<{tag}>'
attribute_names = {
name.rsplit('}', 1)[-1]
for name in gradient.attrib
}
if 'href' in attribute_names:
issues.add(
f"{label} cannot inherit from href/xlink:href; "
"define gradient stops directly"
)
if 'gradientTransform' in attribute_names:
issues.add(f"{label} cannot use gradientTransform")
if 'spreadMethod' in attribute_names:
issues.add(f"{label} cannot use spreadMethod")
result['errors'].extend(sorted(issues))
def _check_font_size_values(self, content: str, result: Dict):
"""Require font-size values to be unitless numeric SVG px values."""
@@ -900,6 +1223,47 @@ class SVGQualityChecker:
f"{fallback_msg})"
)
def _check_unsupported_visual_elements(
self,
root: ET.Element,
result: Dict,
) -> None:
"""Reject authored visual elements with no native converter dispatch."""
if _collect_unsupported_visuals is None:
result['errors'].append(
"Unable to import native visual-element preflight; "
"cannot verify SVG element support"
)
return
if _expand_local_use_references is None or _UseExpansionError is None:
result['errors'].append(
"Unable to import local <use> expansion; "
"cannot verify SVG element support"
)
return
expanded_root = copy.deepcopy(root)
try:
_expand_local_use_references(expanded_root)
except _UseExpansionError:
# _check_forbidden_elements already reports the actionable
# local-reference validation error.
return
unsupported = _collect_unsupported_visuals(
expanded_root,
allow_data_icon_use=True,
)
if not unsupported:
return
preview = '; '.join(unsupported[:8])
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
result['errors'].append(
f"Unsupported visual SVG element(s) for native PPTX export: "
f"{preview}{suffix}"
)
def _check_animation_group_ids(self, root: ET.Element, result: Dict):
"""Warn when visible top-level groups cannot be customized."""
non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
@@ -937,9 +1301,8 @@ class SVGQualityChecker:
`dkUpDiag`). Two failure modes worth catching pre-export:
1. Missing annotation converter silently falls back to `ltUpDiag`
(diagonal stripes) and picks `bg = #FFFFFF` when the pattern has
no child <rect>, turning a hand-authored grid into white-on-stripes
in PPTX.
(diagonal stripes). Color metadata or child paints still resolve,
with white as the final background fallback.
2. Invalid preset name PPTX schema rejects the file; PowerPoint
opens it with "needs to be repaired". OOXML
`ST_PresetPatternVal` is a closed enum only the names in
@@ -954,9 +1317,9 @@ class SVGQualityChecker:
result['warnings'].append(
f"<pattern id=\"{pat_id}\"> has no data-pptx-pattern attribute — "
"PPTX export will fall back to `ltUpDiag` (diagonal stripes), "
"not your custom geometry. Add data-pptx-pattern=\"lgGrid\" / "
"\"smGrid\" / etc. plus a <rect fill=\"<bg>\"/> child so the "
"preset and bg color match your design."
"not your custom geometry. Add a valid data-pptx-pattern; "
"set data-pptx-fg/data-pptx-bg or matching child paints "
"when explicit pattern colors are required."
)
continue
if prst not in self._OOXML_PATTERN_PRESETS:
@@ -966,8 +1329,8 @@ class SVGQualityChecker:
"will fail schema validation ('needs to be repaired'). "
"Use one of: smGrid / lgGrid / dotGrid (grids), "
"ltUpDiag / dkUpDiag / cross / diagCross / weave / plaid / "
"horzBrick (others); full enum in svg_quality_checker.py "
"_OOXML_PATTERN_PRESETS."
"horzBrick (others); see references/shared-standards.md §7 "
"for the full authoring enum."
)
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
@@ -1132,7 +1495,8 @@ class SVGQualityChecker:
def _check_spec_lock_drift(self, content: str, svg_path: Path, result: Dict):
"""Detect values used in the SVG that fall outside spec_lock.md.
Covers colors (fill / stroke / stop-color), font-family, and font-size.
Covers colors (fill / stroke / stop-color / flood-color / pattern
metadata), font-family, and font-size.
Emits per-file warnings summarising the drift counts; exact drifting
values are accumulated in self._drift_summary for the end-of-run
aggregation. When spec_lock.md is missing, silently skip (consistent
@@ -1145,8 +1509,14 @@ class SVGQualityChecker:
# Build allow-sets from the lock
allowed_colors = set()
for v in lock.get('colors', {}).values():
if HEX_VALUE_RE.fullmatch(v):
allowed_colors.add(v.upper())
if _parse_export_color is not None:
color, _alpha = _parse_export_color(v)
if color:
allowed_colors.add(color)
else:
color = _normalize_hex_rgb(v)
if color:
allowed_colors.add(color)
typo = lock.get('typography', {})
numeric_size_re = re.compile(r'^(?:\d+(?:\.\d+)?|\.\d+)$')
@@ -1198,13 +1568,28 @@ class SVGQualityChecker:
# Scan SVG for used values
color_drifts = set()
for attr in ('fill', 'stroke', 'stop-color'):
for attr in (
'fill', 'stroke', 'stop-color', 'flood-color',
'data-pptx-fg', 'data-pptx-bg',
):
for raw_value in self._svg_property_values(content, attr):
if not HEX_VALUE_RE.fullmatch(raw_value):
normalized = raw_value.strip()
if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
r'url\(#[^)]+\)', normalized
):
continue
val = raw_value.upper()
if _BARE_HEX_VALUE_RE.fullmatch(normalized):
continue
if _parse_export_color is not None:
val, _alpha = _parse_export_color(normalized)
if val is None:
continue
else:
val = _normalize_hex_rgb(normalized)
if val is None:
continue
if val not in allowed_colors:
color_drifts.add(val)
color_drifts.add(f'#{val}')
font_drifts = set()
for val in self._font_family_values(content):
@@ -1985,9 +2370,21 @@ class SVGQualityChecker:
def _check_animation_config_contract(self, dir_path: Path) -> None:
"""Project-level animations.json reference checks."""
if _load_animation_config is None or _validate_animation_config is None:
return
project_path = self._resolve_project_path(dir_path)
config_path = project_path / 'animations.json'
if (
_load_animation_config is None
or _validate_animation_config is None
or _validate_animation_config_errors is None
or _validate_transition_config is None
):
if config_path.is_file():
detail = _animation_config_import_error or 'unknown import error'
self._animation_issues.append((
'error',
f'animations.json validation is unavailable: {detail}',
))
return
try:
config = _load_animation_config(project_path)
except Exception as exc:
@@ -1995,8 +2392,19 @@ class SVGQualityChecker:
return
if not config:
return
for warning in _validate_animation_config(project_path, config):
self._animation_issues.append(('warning', warning))
fatal_errors = list(dict.fromkeys(
_validate_transition_config(config)
+ _validate_animation_config_errors(config)
))
for error in fatal_errors:
self._animation_issues.append(('error', error))
for message in _validate_animation_config(project_path, config):
severity = (
'warning'
if ' has no id and cannot be customized in animations.json' in message
else 'error'
)
self._animation_issues.append((severity, message))
def _check_template_contract(self, dir_path: Path,
svg_files: List[Path]) -> None:
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""PPT Master - SVG to PPTX Tool (thin wrapper).
Delegates to the svg_to_pptx package. Kept for CLI backward compatibility:
Delegates to the svg_to_pptx package. ``-s final`` remains a native-export
diagnostic override; the standard pipeline reads ``svg_output/``:
python3 scripts/svg_to_pptx.py <project_path> -s final
"""
@@ -9,15 +9,19 @@ from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from pptx_animations import (
ANIMATIONS,
ANIMATION_MODES,
ANIMATION_TRIGGERS,
animation_seconds_to_milliseconds,
normalize_animation_effect,
normalize_animation_trigger,
)
from pptx_transitions import TRANSITIONS, validate_seconds
from .drawingml.utils import SVG_NS
from .semantic_markers import is_static_page_frame
try:
from pptx_animations import ANIMATIONS, TRANSITIONS
except ImportError:
ANIMATIONS = {}
TRANSITIONS = {}
_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
_CHROME_ID_TOKENS = frozenset({
@@ -38,6 +42,7 @@ class GroupTarget:
group_id: str
order: int
chrome: bool = False
structurally_static: bool = False
def _tag_name(elem: ET.Element) -> str:
@@ -77,10 +82,16 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
role = child.get('data-pptx-role')
placeholder = child.get('data-pptx-placeholder')
has_explicit_semantics = role is not None or placeholder is not None
if child.get('data-pptx-layer') is not None:
has_structural_layer = child.get('data-pptx-layer') is not None
semantic_static = (
has_explicit_semantics
and is_static_page_frame(role, placeholder)
)
structurally_static = has_structural_layer or semantic_static
if has_structural_layer:
chrome = True
elif has_explicit_semantics:
chrome = is_static_page_frame(role, placeholder)
chrome = semantic_static
else:
chrome = is_chrome_id(group_id)
targets.append(
@@ -89,6 +100,7 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
group_id=group_id,
order=visual_index,
chrome=chrome,
structurally_static=structurally_static,
)
)
@@ -135,20 +147,277 @@ def load_animation_config(project_path: Path, config_path: str | None = None) ->
return data
def _valid_animation_effect(effect: str) -> bool:
return effect == 'none' or effect in ANIMATIONS or effect in ('auto', 'mixed', 'random')
def _valid_transition_effect(effect: str) -> bool:
return effect == 'none' or effect in TRANSITIONS
def _animation_effect_error(effect: object, label: str) -> str | None:
if not isinstance(effect, str):
return f'animations.json {label} animation effect must be a string'
try:
normalize_animation_effect(effect)
except ValueError:
valid = ', '.join((*ANIMATIONS, *ANIMATION_MODES, 'none'))
return (
f'animations.json {label} has unknown animation effect: {effect}; '
f'valid effects: {valid}'
)
return None
def _animation_trigger_error(trigger: object, label: str) -> str | None:
if not isinstance(trigger, str):
return f'animations.json {label} animation trigger must be a string'
try:
normalize_animation_trigger(trigger)
except ValueError:
valid = ', '.join(ANIMATION_TRIGGERS)
return (
f'animations.json {label} has unknown animation trigger: {trigger}; '
f'valid triggers: {valid}'
)
return None
def _unknown_field_errors(
value: dict[str, Any],
allowed: frozenset[str],
label: str,
) -> list[str]:
return [
f'animations.json {label} has unknown field: {field}'
for field in sorted(set(value) - allowed)
]
def validate_transition_config(config: dict[str, Any]) -> list[str]:
"""Return fatal transition-sidecar errors that must block export."""
errors: list[str] = []
defaults = config.get('defaults', {})
default_effect = 'fade'
if not isinstance(defaults, dict):
errors.append('animations.json field "defaults" must be an object')
else:
errors.extend(
_transition_scope_errors(
defaults,
'defaults',
inherited_effect='fade',
)
)
transition_defaults = defaults.get('transition', {})
if isinstance(transition_defaults, dict):
value = transition_defaults.get('effect', default_effect)
if isinstance(value, str) and _valid_transition_effect(value):
default_effect = value
slides = config.get('slides', {})
if not isinstance(slides, dict):
errors.append('animations.json field "slides" must be an object')
return errors
for slide_name, slide_cfg in slides.items():
if not isinstance(slide_cfg, dict):
errors.append(f'animations.json slide "{slide_name}" must be an object')
continue
errors.extend(
_transition_scope_errors(
slide_cfg,
f'slide "{slide_name}"',
inherited_effect=default_effect,
)
)
return errors
def _transition_scope_errors(
scope: dict[str, Any],
label: str,
*,
inherited_effect: str,
) -> list[str]:
if 'transition' not in scope:
return []
transition = scope['transition']
if not isinstance(transition, dict):
return [f'animations.json {label} field "transition" must be an object']
errors = _unknown_field_errors(
transition,
frozenset({'effect', 'duration', 'auto_advance'}),
f'{label} transition',
)
if 'effect' in transition:
effect = transition['effect']
if not isinstance(effect, str):
errors.append(
f'animations.json {label} transition effect must be a string'
)
elif not _valid_transition_effect(effect):
errors.append(
f'animations.json {label} has unknown transition effect: {effect}'
)
duration_allows_zero = transition.get('effect', inherited_effect) == 'none'
for field, allow_zero in (
('duration', duration_allows_zero),
('auto_advance', True),
):
if field not in transition:
continue
try:
validate_seconds(
transition[field],
f'animations.json {label} transition {field}',
allow_zero=allow_zero,
)
except ValueError as exc:
errors.append(str(exc))
return errors
def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
"""Return fatal object-animation errors that must block export."""
errors = _unknown_field_errors(
config,
frozenset({'version', 'defaults', 'slides'}),
'top level',
)
defaults = config.get('defaults', {})
if not isinstance(defaults, dict):
errors.append('animations.json field "defaults" must be an object')
else:
errors.extend(
_unknown_field_errors(
defaults,
frozenset({'transition', 'animation'}),
'defaults',
)
)
errors.extend(_animation_scope_errors(defaults, 'defaults'))
slides = config.get('slides', {})
if not isinstance(slides, dict):
errors.append('animations.json field "slides" must be an object')
return list(dict.fromkeys(errors))
for slide_name, slide_cfg in slides.items():
if not isinstance(slide_cfg, dict):
errors.append(f'animations.json slide "{slide_name}" must be an object')
continue
errors.extend(
_unknown_field_errors(
slide_cfg,
frozenset({'transition', 'animation', 'groups'}),
f'slide "{slide_name}"',
)
)
errors.extend(
_animation_scope_errors(slide_cfg, f'slide "{slide_name}"')
)
errors.extend(_animation_group_errors(slide_name, slide_cfg))
return list(dict.fromkeys(errors))
def _animation_scope_errors(scope: dict[str, Any], label: str) -> list[str]:
if 'animation' not in scope:
return []
animation = scope['animation']
if not isinstance(animation, dict):
return [f'animations.json {label} field "animation" must be an object']
errors = _unknown_field_errors(
animation,
frozenset({'effect', 'duration', 'stagger', 'trigger'}),
f'{label} animation',
)
if 'effect' in animation:
effect_error = _animation_effect_error(animation['effect'], label)
if effect_error:
errors.append(effect_error)
for field, allow_zero in (('duration', False), ('stagger', True)):
if field not in animation:
continue
try:
animation_seconds_to_milliseconds(
animation[field],
f'animations.json {label} animation {field}',
allow_zero=allow_zero,
)
except ValueError as exc:
errors.append(str(exc))
if 'trigger' in animation:
trigger_error = _animation_trigger_error(animation['trigger'], label)
if trigger_error:
errors.append(trigger_error)
return errors
def _animation_group_errors(
slide_name: object,
slide_cfg: dict[str, Any],
) -> list[str]:
if 'groups' not in slide_cfg:
return []
groups = slide_cfg['groups']
if not isinstance(groups, dict):
return [
f'animations.json slide "{slide_name}" field "groups" must be an object'
]
errors: list[str] = []
for group_id, group_cfg in groups.items():
label = f'group "{slide_name}/{group_id}"'
if not isinstance(group_cfg, dict):
errors.append(f'animations.json {label} must be an object')
continue
errors.extend(
_unknown_field_errors(
group_cfg,
frozenset({'effect', 'duration', 'delay', 'order'}),
label,
)
)
if 'effect' in group_cfg:
effect_error = _animation_effect_error(group_cfg['effect'], label)
if effect_error:
errors.append(effect_error)
for field, allow_zero in (('duration', False), ('delay', True)):
if field not in group_cfg:
continue
try:
animation_seconds_to_milliseconds(
group_cfg[field],
f'animations.json {label} animation {field}',
allow_zero=allow_zero,
)
except ValueError as exc:
errors.append(str(exc))
if 'order' in group_cfg:
order = group_cfg['order']
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
errors.append(
f'animations.json {label} animation order must be a positive integer: '
f'{order!r}'
)
return errors
def validate_animation_config(
project_path: Path,
config: dict[str, Any] | None = None,
config_path: str | None = None,
) -> list[str]:
"""Validate sidecar references against current ``svg_output``."""
"""Return sidecar-reference diagnostics for ``svg_output``.
Fatal field/type/value checks are owned by
:func:`validate_animation_config_errors`. Anonymous groups are warnings;
missing slides/groups and structural targets are fatal at export call sites.
"""
if config is None:
config = load_animation_config(project_path, config_path)
if not config:
@@ -161,54 +430,39 @@ def validate_animation_config(
known_slides = set(targets_by_slide)
slides = config.get('slides', {})
if slides and not isinstance(slides, dict):
return ['animations.json field "slides" must be an object']
if not isinstance(slides, dict):
return list(dict.fromkeys(warnings))
for slide_name in sorted(known_slides - set(slides)):
warnings.append(f'animations.json omits slide: {slide_name}')
defaults = config.get('defaults', {})
if isinstance(defaults, dict):
_validate_scope_effects(defaults, 'defaults', warnings)
for slide_name, slide_cfg in (slides or {}).items():
for slide_name, slide_cfg in slides.items():
if slide_name not in known_slides:
warnings.append(f'animations.json references missing slide: {slide_name}')
continue
if not isinstance(slide_cfg, dict):
warnings.append(f'animations.json slide "{slide_name}" must be an object')
continue
_validate_scope_effects(slide_cfg, f'slide "{slide_name}"', warnings)
known_groups = {target.group_id for target in targets_by_slide[slide_name]}
known_groups = {
target.group_id: target
for target in targets_by_slide.get(slide_name, [])
}
groups = slide_cfg.get('groups', {})
if groups and not isinstance(groups, dict):
warnings.append(f'animations.json slide "{slide_name}" field "groups" must be an object')
if not isinstance(groups, dict):
continue
for group_id, group_cfg in (groups or {}).items():
for group_id, group_cfg in groups.items():
if group_id not in known_groups:
warnings.append(
f'animations.json references missing group: {slide_name}/{group_id}'
)
if not isinstance(group_cfg, dict):
warnings.append(f'animations.json group "{slide_name}/{group_id}" must be an object')
continue
effect = group_cfg.get('effect')
if effect is not None and not _valid_animation_effect(str(effect)):
target = known_groups[group_id]
effect = group_cfg.get('effect') if isinstance(group_cfg, dict) else None
if target.structurally_static and effect != 'none':
warnings.append(
f'animations.json group "{slide_name}/{group_id}" has unknown effect: {effect}'
'animations.json references non-animatable structural group: '
f'{slide_name}/{group_id}'
)
return warnings
def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[str]) -> None:
transition = scope.get('transition', {})
if isinstance(transition, dict):
effect = transition.get('effect')
if effect is not None and not _valid_transition_effect(str(effect)):
warnings.append(f'animations.json {label} has unknown transition effect: {effect}')
animation = scope.get('animation', {})
if isinstance(animation, dict):
effect = animation.get('effect')
if effect is not None and not _valid_animation_effect(str(effect)):
warnings.append(f'animations.json {label} has unknown animation effect: {effect}')
return list(dict.fromkeys(warnings))
def build_scaffold(project_path: Path) -> dict[str, Any]:
@@ -221,6 +475,13 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
to remind the editor that deck-wide overrides exist and most pages should
inherit them.
"""
transition_defaults = {'effect': 'fade', 'duration': 0.4}
animation_defaults = {
'effect': 'auto',
'duration': 0.4,
'stagger': 0.5,
'trigger': 'after-previous',
}
targets_by_slide, _anonymous = scan_project_targets(project_path)
slides: dict[str, Any] = {}
for slide_name, targets in targets_by_slide.items():
@@ -229,17 +490,16 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
if target.chrome:
continue
groups[target.group_id] = {}
slides[slide_name] = {'groups': groups}
slides[slide_name] = {
'transition': dict(transition_defaults),
'animation': dict(animation_defaults),
'groups': groups,
}
return {
'version': 1,
'defaults': {
'transition': {'effect': 'fade', 'duration': 0.4},
'animation': {
'effect': 'auto',
'duration': 0.4,
'stagger': 0.5,
'trigger': 'after-previous',
},
'transition': transition_defaults,
'animation': animation_defaults,
},
'slides': slides,
}
@@ -50,11 +50,18 @@ class ConvertContext:
rel_id_counter: int = 2 # rId1 reserved for slideLayout
svg_dir: Path | None = None
inherited_styles: dict[str, str] = field(default_factory=dict)
# SVG group opacity is post-compositing, not an inherited presentation
# property. DrawingML has no equivalent group alpha, so native export
# approximates it by multiplying this value into each descendant object.
opacity_multiplier: float = 1.0
# Recursion depth — only the depth==0 (root) context records anim targets.
depth: int = 0
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
# Used by the PPTX builder to emit per-element entrance timing.
anim_targets: list = field(default_factory=list)
# Explicit sidecar group ids may override the legacy chrome-name heuristic.
# Explicit structural layer/role/placeholder markers remain non-animatable.
animation_group_overrides: frozenset[str] = frozenset()
# Default-on flag: merge mergeable paragraph blocks into one editable
# text frame with multiple <a:p>. Disable it for strict line fidelity.
merge_paragraphs: bool = True
@@ -99,6 +106,7 @@ class ConvertContext:
transform_matrix: AffineMatrix | None = None,
filter_id: str | None = None,
style_overrides: dict[str, str] | None = None,
opacity_multiplier: float = 1.0,
) -> ConvertContext:
"""Create a child context with accumulated translate / scale / styles.
@@ -111,6 +119,7 @@ class ConvertContext:
converters that can faithfully map it to DrawingML.
filter_id: Override filter ID.
style_overrides: Style attribute overrides from child element.
opacity_multiplier: Local group opacity to multiply into descendants.
"""
local_matrix = transform_matrix or IDENTITY_MATRIX
# When first crossing from scalar to matrix mode, fold accumulated
@@ -138,24 +147,10 @@ class ConvertContext:
)
merged = dict(self.inherited_styles)
if style_overrides:
# Opacity is multiplicative, not a simple override
_OPACITY_KEYS = ('opacity', 'fill-opacity', 'stroke-opacity')
for op_key in _OPACITY_KEYS:
if op_key in style_overrides and op_key in merged:
try:
merged[op_key] = str(
float(merged[op_key]) * float(style_overrides[op_key])
)
except ValueError:
merged[op_key] = style_overrides[op_key]
elif op_key in style_overrides:
merged[op_key] = style_overrides[op_key]
merged.update(style_overrides)
for k, v in style_overrides.items():
if k not in _OPACITY_KEYS:
merged[k] = v
local_opacity = max(0.0, min(1.0, opacity_multiplier))
return ConvertContext(
defs=self.defs,
@@ -177,9 +172,11 @@ class ConvertContext:
rel_id_counter=self.rel_id_counter,
svg_dir=self.svg_dir,
inherited_styles=merged,
opacity_multiplier=self.opacity_multiplier * local_opacity,
depth=self.depth + 1,
# anim_targets is intentionally a fresh list on the child;
# only the root-level context's list is read by the builder.
animation_group_overrides=self.animation_group_overrides,
merge_paragraphs=self.merge_paragraphs,
native_objects_enabled=self.native_objects_enabled,
image_optimize=self.image_optimize,
@@ -19,7 +19,8 @@ from .utils import (
parse_svg_length,
)
from .styles import (
build_effect_xml, build_fill_xml, get_fill_opacity, get_stroke_opacity,
build_effect_xml, build_fill_xml,
get_element_opacity, get_fill_opacity, get_stroke_opacity,
)
from .elements import (
convert_rect, convert_circle, convert_ellipse,
@@ -154,6 +155,9 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
filter_id = resolve_url_id(elem.get('filter', ''))
style_overrides = _extract_inheritable_styles(elem)
local_opacity = get_element_opacity(elem)
if local_opacity is None:
local_opacity = 1.0
elem_id = elem.get('id')
semantic_role = elem.get('data-pptx-role')
@@ -169,8 +173,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
should_animate_group = (
ctx.depth == 0
and elem_id
and not is_chrome
and not elem.get('data-pptx-layer')
and (
not is_chrome
or (
not has_explicit_semantics
and elem_id in ctx.animation_group_overrides
)
)
and elem.get('data-pptx-layer') is None
)
visual_children = [
child for child in elem
@@ -194,12 +204,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
transform_matrix=parse_transform_matrix(transform),
filter_id=filter_id,
style_overrides=style_overrides,
opacity_multiplier=local_opacity,
)
elif rotate_pivot is not None:
child_ctx = ctx.child(
0, 0, 1.0, 1.0,
filter_id=filter_id,
style_overrides=style_overrides,
opacity_multiplier=local_opacity,
)
else:
child_ctx = ctx.child(
@@ -209,6 +221,13 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
sy,
filter_id=filter_id,
style_overrides=style_overrides,
opacity_multiplier=local_opacity,
)
if native_subtree_active and child_ctx.opacity_multiplier < 1.0:
raise SvgNativeConversionError(
"Group opacity cannot be applied to data-pptx-native table/chart "
"objects; export without --native-objects to use the SVG fallback"
)
if child_ctx.native_objects_enabled:
@@ -298,7 +317,10 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
group_effect = ''
if filter_id and filter_id in ctx.defs:
group_effect = build_effect_xml(ctx.defs[filter_id])
group_effect = build_effect_xml(
ctx.defs[filter_id],
child_ctx.opacity_multiplier,
)
rot_emu = 0 if matrix_supported else int(angle_deg * 60000)
rot_attr = f' rot="{rot_emu}"' if rot_emu else ''
@@ -466,7 +488,11 @@ def _extract_background_candidate(
if child.get('transform') or child.get('filter') or child.get('clip-path'):
return '', None
style_overrides = _extract_inheritable_styles(child)
child_ctx = ctx.child(style_overrides=style_overrides)
local_opacity = get_element_opacity(child)
child_ctx = ctx.child(
style_overrides=style_overrides,
opacity_multiplier=1.0 if local_opacity is None else local_opacity,
)
visual_children = [
grandchild for grandchild in child
if grandchild.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
@@ -561,25 +587,50 @@ def _local_tag(elem: ET.Element) -> str:
return elem.tag.split('}', 1)[-1] if isinstance(elem.tag, str) and '}' in elem.tag else str(elem.tag)
def _collect_unsupported_visuals(root: ET.Element) -> list[str]:
def collect_unsupported_visuals(
root: ET.Element,
*,
allow_data_icon_use: bool = False,
) -> list[str]:
"""Return visual element paths that the native converter cannot dispatch."""
issues: list[str] = []
def walk(elem: ET.Element, path: str, in_defs: bool = False) -> None:
def walk(
elem: ET.Element,
path: str,
in_defs: bool = False,
parent_tag: str | None = None,
) -> None:
tag = _local_tag(elem)
current = f'{path}/{tag}'
if in_defs:
return
if tag in _NON_VISUAL_TAGS:
return
is_supported_visual_child = (
tag in _SUPPORTED_VISUAL_CHILD_TAGS
and parent_tag in {'text', 'tspan'}
)
is_data_icon_placeholder = (
allow_data_icon_use
and tag == 'use'
and elem.get('data-icon') is not None
)
if (tag not in _CONVERTERS
and tag not in _NON_VISUAL_TAGS
and tag not in _SUPPORTED_VISUAL_CHILD_TAGS):
and not is_supported_visual_child
and not is_data_icon_placeholder):
issues.append(current)
for idx, child in enumerate(list(elem), start=1):
walk(child, f'{current}[{idx}]', in_defs=(tag == 'defs'))
walk(
child,
f'{current}[{idx}]',
in_defs=(tag == 'defs'),
parent_tag=tag,
)
for idx, child in enumerate(list(root), start=1):
walk(child, f'/svg[{idx}]')
walk(child, f'/svg[{idx}]', parent_tag='svg')
return issues
@@ -594,6 +645,7 @@ def convert_svg_to_slide_shapes(
image_scale: float = 2.0,
image_quality: int = 85,
native_objects: bool = False,
animation_group_overrides: frozenset[str] | None = None,
theme_font_spec: ThemeFontSpec | None = None,
theme_color_spec: ThemeColorSpec | None = None,
trace_out: list[dict[str, Any]] | None = None,
@@ -623,6 +675,9 @@ def convert_svg_to_slide_shapes(
image_quality: JPEG quality used for opaque optimized rasters.
native_objects: Convert explicit ``data-pptx-native`` table/chart
markers to native PowerPoint objects. Default off.
animation_group_overrides: Explicit top-level SVG group ids from
``animations.json`` that override the legacy chrome-name fallback.
Explicit structural layer/role/placeholder markers remain excluded.
theme_font_spec: Optional major/minor theme-font contract. Matching SVG
families emit DrawingML theme tokens instead of fixed typefaces.
theme_color_spec: Optional context-aware theme-color contract. Exact
@@ -646,24 +701,84 @@ def convert_svg_to_slide_shapes(
"""
tree = ET.parse(str(svg_path))
root = tree.getroot()
viewport_width, viewport_height = _root_viewport_size(root)
trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
trace_steps: list[dict[str, Any]] = []
# Expand <use data-icon="..."/> placeholders in-memory so this dispatcher
# can consume svg_output/ directly. Standard renderers and this converter
# both ignore data-icon, so without expansion icons would silently drop.
# The on-disk finalize_svg pipeline does the same expansion for svg_final/;
# running this here makes the two pipelines behaviourally aligned.
from ..geometry_properties import (
GeometryStyleError,
materialize_inline_geometry_properties,
)
try:
geometry_count = materialize_inline_geometry_properties(root)
except GeometryStyleError as exc:
raise SvgNativeConversionError(
f'{svg_path.name}: inline geometry materialization failed: {exc}'
) from exc
geometry_trace = None
if geometry_count:
geometry_trace = {
'action': 'materialize-inline-geometry',
'count': geometry_count,
}
trace_steps.append(geometry_trace)
if verbose:
print(f' Materialized {geometry_count} inline geometry declaration(s)')
viewport_width, viewport_height = _root_viewport_size(root)
# Expand project icon placeholders and static same-document <use>
# references before unsupported-element preflight.
from ..use_expander import (
UseExpansionError,
expand_local_use_references,
expand_use_data_icons,
)
icons_dir, icons_fallback_dir = icon_search_dirs_for_svg(svg_path)
if icons_dir.exists():
from ..use_expander import expand_use_data_icons
expanded = expand_use_data_icons(root, icons_dir, icons_fallback_dir)
if expanded:
trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded})
if verbose and expanded:
print(f' Expanded {expanded} <use data-icon="..."/> placeholder(s)')
try:
injected_geometry_count = materialize_inline_geometry_properties(root)
except GeometryStyleError as exc:
raise SvgNativeConversionError(
f'{svg_path.name}: expanded icon geometry materialization failed: {exc}'
) from exc
if injected_geometry_count:
geometry_count += injected_geometry_count
if geometry_trace is None:
geometry_trace = {
'action': 'materialize-inline-geometry',
'count': geometry_count,
}
trace_steps.append(geometry_trace)
else:
geometry_trace['count'] = geometry_count
if verbose:
print(
f' Materialized {injected_geometry_count} inline geometry '
'declaration(s) from expanded icons'
)
try:
expanded_local = expand_local_use_references(root)
except UseExpansionError as exc:
raise SvgNativeConversionError(
f'{svg_path.name}: local <use> expansion failed: {exc}'
) from exc
if expanded_local:
trace_steps.append({
'action': 'expand-local-use-references',
'count': expanded_local,
})
if verbose:
print(f' Expanded {expanded_local} local <use href="#..."/> instance(s)')
# Flatten positional <tspan> (those with x/y/non-zero dy) into independent
# <text> elements. DrawingML runs cannot reposition mid-paragraph, so a
# dy-stacked block of tspans would otherwise collapse onto one baseline,
@@ -682,7 +797,7 @@ def convert_svg_to_slide_shapes(
if verbose:
print(' Flattened positional <tspan> into independent <text>')
unsupported = _collect_unsupported_visuals(root)
unsupported = collect_unsupported_visuals(root)
if unsupported:
preview = '; '.join(unsupported[:8])
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
@@ -704,6 +819,7 @@ def convert_svg_to_slide_shapes(
image_scale=image_scale,
image_quality=image_quality,
native_objects_enabled=native_objects,
animation_group_overrides=animation_group_overrides or frozenset(),
trace_events=trace_events,
theme_font_spec=theme_font_spec,
theme_color_spec=theme_color_spec,
@@ -712,6 +828,10 @@ def convert_svg_to_slide_shapes(
shapes: list[str] = []
converted = 0
skipped = 0
has_top_level_group = any(
child.tag.replace(f'{{{SVG_NS}}}', '') == 'g'
for child in root
)
background_xml, background_skip_id = _extract_background_candidate(root, ctx)
promoted_backgrounds = 1 if background_xml else 0
if background_xml and trace_events is not None:
@@ -735,12 +855,31 @@ def convert_svg_to_slide_shapes(
shapes.append(result.xml)
converted += 1
m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
if m and not child.get('data-pptx-layer'):
if not is_static_page_frame(
child.get('data-pptx-role'),
child.get('data-pptx-placeholder'),
):
fallback_targets.append((int(m.group(1)), tag))
elem_id = child.get('id')
role = child.get('data-pptx-role')
placeholder = child.get('data-pptx-placeholder')
has_explicit_semantics = role is not None or placeholder is not None
structurally_static = (
child.get('data-pptx-layer') is not None
or (
has_explicit_semantics
and is_static_page_frame(role, placeholder)
)
)
legacy_chrome = (
not has_explicit_semantics
and is_chrome_id(elem_id)
)
explicit_legacy_override = (
elem_id is not None
and elem_id in ctx.animation_group_overrides
)
if (
m
and not structurally_static
and (not legacy_chrome or explicit_legacy_override)
):
fallback_targets.append((int(m.group(1)), elem_id or tag))
else:
if tag not in _NON_VISUAL_TAGS:
skipped += 1
@@ -752,7 +891,11 @@ def convert_svg_to_slide_shapes(
# semantic blocks, not atomized drawing primitives, so fallback is
# intentionally capped at a low count.
_ANIM_FALLBACK_CAP = 8
if not ctx.anim_targets and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP:
if (
not has_top_level_group
and not ctx.anim_targets
and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP
):
ctx.anim_targets = fallback_targets
if verbose:
@@ -22,15 +22,16 @@ from .utils import (
svg_length_x, svg_length_y, svg_length_size,
ctx_x, ctx_y, ctx_w, ctx_h,
rect_to_dml_xfrm,
parse_hex_color, resolve_url_id, get_effective_filter_id,
combine_opacity, parse_hex_color, parse_svg_color,
resolve_url_id, get_effective_filter_id,
parse_inline_style, parse_font_family, is_cjk_char, estimate_text_width,
detect_text_lang, resolve_text_run_fonts,
detect_text_lang, font_px_to_hpt, resolve_text_run_fonts,
matrix_multiply, parse_transform_matrix, transform_point, _xml_escape,
)
from .styles import (
build_solid_fill, build_gradient_fill,
build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
get_fill_opacity, get_stroke_opacity,
get_element_opacity, get_fill_opacity, get_stroke_opacity,
)
from .paths import (
PathCommand, parse_svg_path, svg_path_to_absolute,
@@ -363,7 +364,10 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_effect_xml(ctx.defs[filt_id])
effect = build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
transform = elem.get('transform')
@@ -554,7 +558,7 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
# Use the stroke color/gradient as fill for the arc shape
stroke_val = _get_attr(elem, 'stroke', ctx)
op = get_fill_opacity(elem, ctx)
op = get_stroke_opacity(elem, ctx)
grad_id = resolve_url_id(stroke_val) if stroke_val else None
if grad_id and grad_id in ctx.defs:
fill = build_gradient_fill(
@@ -564,9 +568,14 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"fill",
)
elif stroke_val:
color = parse_hex_color(stroke_val)
color, color_alpha = parse_svg_color(stroke_val)
fill = (
build_solid_fill(color, op, ctx.theme_color_spec, "fill")
build_solid_fill(
color,
combine_opacity(op, color_alpha),
ctx.theme_color_spec,
"fill",
)
if color else '<a:noFill/>'
)
else:
@@ -577,7 +586,10 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_effect_xml(ctx.defs[filt_id])
effect = build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
shape_id = ctx.next_id()
return ShapeResult(
@@ -609,7 +621,10 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_effect_xml(ctx.defs[filt_id])
effect = build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
@@ -815,7 +830,10 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_effect_xml(ctx.defs[filt_id])
effect = build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
shape_id = ctx.next_id()
off_x = px_to_emu(min_x)
@@ -1198,6 +1216,8 @@ def _extract_text_bullet(
bullet = {
'char': _TEXT_BULLET_MARKERS.get(marker, marker),
'fill': marker_run.get('fill'),
'fill_raw': marker_run.get('fill_raw'),
'opacity': marker_run.get('opacity'),
'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False),
'margin_px': max(
_estimate_text_runs_width(replacement_runs, include_headroom=False),
@@ -1226,10 +1246,21 @@ def _build_bullet_xml(
if not bullet:
return ''
fill = bullet.get('fill')
if isinstance(fill, str) and re.fullmatch(r'[0-9A-Fa-f]{6}', fill):
fill_raw = bullet.get('fill_raw')
color, color_alpha = parse_svg_color(
fill_raw if isinstance(fill_raw, str) else ''
)
if color is None and isinstance(fill, str):
color = parse_hex_color(fill)
if color:
opacity = combine_opacity(bullet.get('opacity'), color_alpha)
alpha_xml = (
f'<a:alphaMod val="{int(opacity * 100000)}"/>'
if opacity is not None else ''
)
theme_spec = ctx.theme_color_spec if ctx is not None else None
color_xml = (
f'<a:buClr>{color_node_xml(fill, theme_spec, "text")}</a:buClr>'
f'<a:buClr>{color_node_xml(color, theme_spec, "text", alpha_xml)}</a:buClr>'
)
else:
color_xml = '<a:buClrTx/>'
@@ -1272,6 +1303,16 @@ def _textbox_padding(font_size: float) -> float:
)
def _text_opacity_ratio(value: str | None) -> float:
"""Parse a text opacity component and clamp it to the SVG ``0..1`` range."""
if value is None:
return 1.0
try:
return max(0.0, min(1.0, float(value)))
except ValueError:
return 1.0
def _override_run_attrs(
parent_attrs: dict[str, Any],
tspan: ET.Element,
@@ -1283,6 +1324,27 @@ def _override_run_attrs(
def tspan_attr(name: str) -> str | None:
return inline_style.get(name) or tspan.get(name)
object_opacity = float(run_attrs.get('_object_opacity', 1.0))
fill_opacity = float(run_attrs.get('_fill_opacity', 1.0))
stroke_opacity = float(run_attrs.get('_stroke_opacity', 1.0))
if tspan_attr('opacity') is not None:
object_opacity *= _text_opacity_ratio(tspan_attr('opacity'))
if tspan_attr('fill-opacity') is not None:
fill_opacity = _text_opacity_ratio(tspan_attr('fill-opacity'))
if tspan_attr('stroke-opacity') is not None:
stroke_opacity = _text_opacity_ratio(tspan_attr('stroke-opacity'))
run_attrs['_object_opacity'] = object_opacity
run_attrs['_fill_opacity'] = fill_opacity
run_attrs['_stroke_opacity'] = stroke_opacity
effective_fill_opacity = object_opacity * fill_opacity
effective_stroke_opacity = object_opacity * stroke_opacity
run_attrs['opacity'] = (
effective_fill_opacity if effective_fill_opacity < 1.0 else None
)
run_attrs['stroke_opacity'] = (
effective_stroke_opacity if effective_stroke_opacity < 1.0 else None
)
if tspan_attr('font-weight'):
run_attrs['font_weight'] = tspan_attr('font-weight')
if tspan_attr('fill'):
@@ -1299,11 +1361,6 @@ def _override_run_attrs(
run_attrs.get('stroke_width', 1.0),
font_size=float(run_attrs.get('font_size', 16)),
)
if tspan_attr('stroke-opacity'):
try:
run_attrs['stroke_opacity'] = float(tspan_attr('stroke-opacity') or '1')
except ValueError:
pass
if tspan_attr('font-size'):
run_attrs['font_size'] = parse_svg_length(
tspan_attr('font-size'),
@@ -1411,8 +1468,11 @@ def _build_text_fill_xml(
"text",
)
parsed_color, color_alpha = parse_svg_color(fill_raw)
fill = parsed_color or fill
opacity = combine_opacity(opacity, color_alpha)
alpha_xml = ''
if opacity is not None and opacity < 1.0:
if opacity is not None:
alpha_xml = f'<a:alphaMod val="{int(opacity * 100000)}"/>'
theme_spec = ctx.theme_color_spec if ctx is not None else None
return (
@@ -1431,14 +1491,14 @@ def _build_text_outline_xml(
if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
return ''
color = parse_hex_color(stroke_raw)
color, color_alpha = parse_svg_color(stroke_raw)
if not color:
return ''
stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0)
stroke_opacity = run.get('stroke_opacity')
stroke_opacity = combine_opacity(run.get('stroke_opacity'), color_alpha)
alpha_xml = ''
if stroke_opacity is not None and stroke_opacity < 1.0:
if stroke_opacity is not None:
alpha_xml = f'<a:alphaMod val="{int(stroke_opacity * 100000)}"/>'
theme_spec = ctx.theme_color_spec if ctx is not None else None
@@ -1474,7 +1534,7 @@ def _build_run_xml(
# rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
# / integer snapping — whatever the px works out to is the size, e.g.
# 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
sz = int(round(fs_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
sz = font_px_to_hpt(fs_px)
b_attr = ' b="1"' if fw in ('bold', '600', '700', '800', '900') else ''
i_attr = ' i="1"' if fstyle == 'italic' else ''
u_attr = ' u="sng"' if 'underline' in text_dec else ''
@@ -1520,9 +1580,13 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
fill_raw = _get_attr(elem, 'fill', ctx) or '#000000'
fill_color = parse_hex_color(fill_raw) or '000000'
opacity = get_fill_opacity(elem, ctx)
object_opacity = get_element_opacity(elem, ctx)
object_opacity = 1.0 if object_opacity is None else object_opacity
fill_opacity = _text_opacity_ratio(_get_attr(elem, 'fill-opacity', ctx))
stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0)
stroke_opacity = get_stroke_opacity(elem, ctx)
stroke_opacity_value = _text_opacity_ratio(_get_attr(elem, 'stroke-opacity', ctx))
font_style = _get_attr(elem, 'font-style', ctx) or ''
text_decoration = _get_attr(elem, 'text-decoration', ctx) or ''
letter_spacing_px = _parse_letter_spacing_px(
@@ -1543,6 +1607,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
'text_decoration': text_decoration,
'letter_spacing': letter_spacing_px,
'_scale_x': ctx.scale_x or 1.0,
'_object_opacity': object_opacity,
'_fill_opacity': fill_opacity,
'_stroke_opacity': stroke_opacity_value,
'opacity': opacity,
'stroke_raw': stroke_raw,
'stroke_width': stroke_width,
@@ -1724,9 +1791,15 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
filter_elem = ctx.defs[filt_id]
effect_kind = classify_filter_effect(filter_elem)
if effect_kind == 'glow':
text_effect_xml = build_effect_xml(filter_elem)
text_effect_xml = build_effect_xml(
filter_elem,
get_element_opacity(elem, ctx),
)
elif effect_kind == 'shadow':
shape_effect_xml = build_effect_xml(filter_elem)
shape_effect_xml = build_effect_xml(
filter_elem,
get_element_opacity(elem, ctx),
)
shape_id = ctx.next_id()
rot_attr = f' rot="{text_rot}"' if text_rot else ''
@@ -2369,6 +2442,18 @@ def _resolve_image_meet_fit(
return (dx, dy, fit_w, fit_h)
def _build_image_blip_xml(r_id: str, opacity: float | None) -> str:
"""Build an image blip with native DrawingML transparency when requested."""
if opacity is None:
return f'<a:blip r:embed="{r_id}"/>'
alpha = int(round(opacity * 100000))
return (
f'<a:blip r:embed="{r_id}">'
f'<a:alphaModFix amt="{alpha}"/>'
'</a:blip>'
)
def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert SVG <image> to DrawingML picture element.
@@ -2439,6 +2524,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
# Image optimization only downscales the full source image; it never crops
# pixels out of the embedded media.
src_rect_xml = _resolve_image_src_rect(elem, img_data, w, h)
blip_xml = _build_image_blip_xml(r_id, get_element_opacity(elem, ctx))
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
# frame to match the image's aspect ratio. Skipped when a real clip-path
@@ -2495,7 +2581,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{r_id}"/>
{blip_xml}
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
@@ -2514,8 +2600,14 @@ def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
raw_cx = svg_length_x(elem.get('cx'), ctx)
raw_cy = svg_length_y(elem.get('cy'), ctx)
raw_rx = svg_length_x(elem.get('rx'), ctx)
raw_ry = svg_length_y(elem.get('ry'), ctx)
rx_attr = elem.get('rx')
ry_attr = elem.get('ry')
raw_rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
raw_ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
if rx_attr is not None and ry_attr is None:
raw_ry = raw_rx
elif ry_attr is not None and rx_attr is None:
raw_rx = raw_ry
cx_ = ctx_x(raw_cx, ctx)
cy_ = ctx_y(raw_cy, ctx)
rx = raw_rx * ctx.scale_x
@@ -2668,6 +2760,10 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
transform,
)
clip_geom = _resolve_clip_geometry(elem, ctx, svg_x, svg_y, svg_w, svg_h)
blip_xml = _build_image_blip_xml(
r_id,
get_element_opacity(image_elem, ctx),
)
return ShapeResult(xml=f'''<p:pic>
<p:nvPicPr>
@@ -2676,7 +2772,7 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{r_id}"/>
{blip_xml}
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
@@ -11,7 +11,8 @@ 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,
parse_hex_color, parse_stop_style, resolve_url_id,
combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
parse_svg_color, resolve_url_id,
)
@@ -58,24 +59,21 @@ def build_gradient_fill(
# Parse color from style attribute or direct attributes
style = child.get('style', '')
style_values = parse_inline_style(style)
color, stop_opacity = parse_stop_style(style)
if not color:
color = parse_hex_color(child.get('stop-color', '#000000'))
color, color_alpha = parse_svg_color(child.get('stop-color', '#000000'))
stop_opacity *= color_alpha
if color is None:
color = '000000'
direct_stop_op = child.get('stop-opacity')
if direct_stop_op is not None:
try:
stop_opacity = float(direct_stop_op)
except ValueError:
pass
if direct_stop_op is not None and 'stop-opacity' not in style_values:
stop_opacity *= parse_opacity(direct_stop_op)
alpha_xml = ''
effective_opacity = stop_opacity
if opacity is not None:
effective_opacity *= opacity
if effective_opacity < 1.0:
effective_opacity = combine_opacity(stop_opacity, opacity)
if effective_opacity is not None:
alpha_xml = f'<a:alpha val="{int(effective_opacity * 100000)}"/>'
stops_xml.append(
@@ -157,9 +155,14 @@ def build_fill_xml(
usage,
)
color = parse_hex_color(fill)
color, color_alpha = parse_svg_color(fill)
if color:
return build_solid_fill(color, opacity, ctx.theme_color_spec, usage)
return build_solid_fill(
color,
combine_opacity(opacity, color_alpha),
ctx.theme_color_spec,
usage,
)
return '<a:noFill/>'
@@ -178,32 +181,108 @@ def build_pattern_fill(
"""
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
paint_entries = []
for child in pattern_elem:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
style_values = parse_inline_style(child.get('style'))
object_opacity = parse_opacity(
style_values.get('opacity') or child.get('opacity')
)
for paint_attr in ('fill', 'stroke'):
paint = style_values.get(paint_attr) or child.get(paint_attr)
paint_hex, paint_alpha = parse_svg_color(paint) if paint else (None, 1.0)
if paint_hex is None:
continue
paint_opacity = parse_opacity(
style_values.get(f'{paint_attr}-opacity')
or child.get(f'{paint_attr}-opacity')
)
paint_entries.append({
'attr': paint_attr,
'alpha': paint_alpha,
'color': paint,
'hex': paint_hex,
'opacity': object_opacity * paint_opacity,
'tag': tag,
})
fallback_bg = next((
entry
for entry in paint_entries
if entry['tag'] == 'rect' and entry['attr'] == 'fill'
), None)
fallback_fg = next((
entry for entry in paint_entries if entry['attr'] == 'stroke'
), None)
if fallback_fg is None:
fallback_fg = next((
entry
for entry in paint_entries
if entry['attr'] == 'fill' and entry is not fallback_bg
), None)
fg_color = pattern_elem.get('data-pptx-fg')
bg_color = pattern_elem.get('data-pptx-bg')
fg_from_metadata = bool(fg_color)
bg_from_metadata = bool(bg_color)
if not fg_color and fallback_fg is not None:
fg_color = fallback_fg['color']
if not bg_color and fallback_bg is not None:
bg_color = fallback_bg['color']
if not fg_color or not bg_color:
# Hand-authored fallback: derive from child elements.
for child in pattern_elem:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'rect' and not bg_color:
bg_color = child.get('fill')
elif tag == 'path' and not fg_color:
fg_color = child.get('stroke')
fg_hex = parse_hex_color(fg_color) if fg_color else None
bg_hex = parse_hex_color(bg_color) if bg_color else None
fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0)
bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0)
if not fg_hex:
return ''
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
bg_source = next((
entry
for entry in paint_entries
if entry['tag'] == 'rect'
and entry['attr'] == 'fill'
and entry['hex'] == bg_hex
), None)
fg_source = next((
entry
for entry in paint_entries
if entry['attr'] == 'stroke' and entry['hex'] == fg_hex
), None)
if fg_source is None:
fg_source = next((
entry
for entry in paint_entries
if entry['attr'] == 'fill'
and entry['hex'] == fg_hex
and entry is not bg_source
), None)
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, alpha_xml)
fg_child_opacity = 1.0
if fg_source is not None:
fg_child_opacity = fg_source['opacity'] * (
fg_source['alpha'] if fg_from_metadata else 1.0
)
bg_child_opacity = 1.0
if bg_source is not None:
bg_child_opacity = bg_source['opacity'] * (
bg_source['alpha'] if bg_from_metadata else 1.0
)
fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity)
bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity)
fg_alpha_xml = (
f'<a:alpha val="{int(fg_opacity * 100000)}"/>'
if fg_opacity is not None else ''
)
bg_alpha_xml = (
f'<a:alpha val="{int(bg_opacity * 100000)}"/>'
if bg_opacity is not None else ''
)
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, fg_alpha_xml)
if bg_hex:
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage)
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage, bg_alpha_xml)
else:
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage)
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage, bg_alpha_xml)
return (
f'<a:pattFill prst="{prst}">'
@@ -432,10 +511,11 @@ def build_stroke_xml(
return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
# Solid color stroke
color = parse_hex_color(stroke)
color, color_alpha = parse_svg_color(stroke)
if not color:
return '<a:ln><a:noFill/></a:ln>'
opacity = combine_opacity(opacity, color_alpha)
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
@@ -457,12 +537,19 @@ def _parse_filter_params(
std_dev = 4.0
dx = 0.0
dy = 0.0
opacity = 0.3
paint_opacity: float | None = None
transfer_opacity: float | None = None
color_alpha = 1.0
color = '000000'
has_offset = False
for child in filter_elem.iter():
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
style_values = parse_inline_style(child.get('style'))
def effect_attr(name: str, default: str | None = None) -> str | None:
return style_values.get(name) or child.get(name, default)
if tag == 'feDropShadow':
# Shorthand element: all params in one place
std_dev = _f(child.get('stdDeviation'), 4.0)
@@ -470,10 +557,13 @@ def _parse_filter_params(
dy = _f(child.get('dy'), 0.0)
if abs(dx) > 0.01 or abs(dy) > 0.01:
has_offset = True
opacity = _f(child.get('flood-opacity'), 0.3)
raw_color = child.get('flood-color', '').strip().lstrip('#')
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
color = raw_color.upper()
paint_opacity = parse_opacity(effect_attr('flood-opacity'), 0.3)
parsed_color, parsed_alpha = parse_svg_color(
effect_attr('flood-color', '#000000')
)
if parsed_color:
color = parsed_color
color_alpha = parsed_alpha
elif tag == 'feGaussianBlur':
std_dev = _f(child.get('stdDeviation'), 4.0)
elif tag == 'feOffset':
@@ -482,13 +572,29 @@ def _parse_filter_params(
if abs(dx) > 0.01 or abs(dy) > 0.01:
has_offset = True
elif tag == 'feFlood':
opacity = _f(child.get('flood-opacity'), 0.3)
raw_color = child.get('flood-color', '').strip().lstrip('#')
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
color = raw_color.upper()
paint_opacity = parse_opacity(effect_attr('flood-opacity'), 0.3)
parsed_color, parsed_alpha = parse_svg_color(
effect_attr('flood-color', '#000000')
)
if parsed_color:
color = parsed_color
color_alpha = parsed_alpha
elif tag == 'feFuncA':
if child.get('type') == 'linear':
opacity = _f(child.get('slope'), 0.3)
slope = max(0.0, _f(child.get('slope'), 0.3))
transfer_opacity = (
slope
if transfer_opacity is None
else transfer_opacity * slope
)
if paint_opacity is None:
opacity = transfer_opacity if transfer_opacity is not None else 0.3
elif transfer_opacity is None:
opacity = paint_opacity
else:
opacity = paint_opacity * transfer_opacity
opacity = max(0.0, min(1.0, opacity * color_alpha))
return {
'std_dev': std_dev, 'dx': dx, 'dy': dy,
@@ -534,7 +640,10 @@ def _shadow_dir_angle(dx: float, dy: float) -> int:
return int(angle_deg * ANGLE_UNIT)
def build_shadow_xml(filter_elem: ET.Element) -> str:
def build_shadow_xml(
filter_elem: ET.Element,
opacity: float | None = None,
) -> str:
"""Build <a:effectLst> with <a:outerShdw> from SVG filter element.
SVG-to-DrawingML shadow mapping notes:
@@ -564,7 +673,8 @@ def build_shadow_xml(filter_elem: ET.Element) -> str:
# PowerPoint renders outerShdw alpha slightly heavier than SVG's filter
# composite (different blending path). Scale by 0.75 to match the SVG
# preview after blur has been corrected to 2.0× σ.
alpha_val = int(p['opacity'] * 75000)
opacity_multiplier = 1.0 if opacity is None else opacity
alpha_val = int(p['opacity'] * opacity_multiplier * 75000)
algn = _infer_shadow_alignment(dx, dy)
return f'''<a:effectLst>
@@ -574,7 +684,10 @@ def build_shadow_xml(filter_elem: ET.Element) -> str:
</a:effectLst>'''
def build_glow_xml(filter_elem: ET.Element) -> str:
def build_glow_xml(
filter_elem: ET.Element,
opacity: float | None = None,
) -> str:
"""Build <a:effectLst> with <a:glow> from SVG filter element.
Used for filters that have feGaussianBlur without meaningful feOffset,
@@ -585,7 +698,8 @@ def build_glow_xml(filter_elem: ET.Element) -> str:
p = _parse_filter_params(filter_elem)
rad = px_to_emu(p['std_dev'])
alpha_val = int(p['opacity'] * 100000)
opacity_multiplier = 1.0 if opacity is None else opacity
alpha_val = int(p['opacity'] * opacity_multiplier * 100000)
return f'''<a:effectLst>
<a:glow rad="{rad}">
@@ -603,7 +717,10 @@ def classify_filter_effect(filter_elem: ET.Element) -> str | None:
return 'shadow' if p['has_offset'] else 'glow'
def build_effect_xml(filter_elem: ET.Element) -> str:
def build_effect_xml(
filter_elem: ET.Element,
opacity: float | None = None,
) -> str:
"""Build effect XML by classifying the SVG filter as shadow or glow.
Classification rules:
@@ -615,22 +732,29 @@ def build_effect_xml(filter_elem: ET.Element) -> str:
effect_kind = classify_filter_effect(filter_elem)
if effect_kind == 'shadow':
return build_shadow_xml(filter_elem)
return build_shadow_xml(filter_elem, opacity)
if effect_kind == 'glow':
return build_glow_xml(filter_elem)
return build_glow_xml(filter_elem, opacity)
return ''
def get_element_opacity(elem: ET.Element) -> float | None:
"""Get opacity value from element. Returns None if 1.0 or not set."""
op = elem.get('opacity')
def get_element_opacity(
elem: ET.Element,
ctx: ConvertContext | None = None,
) -> float | None:
"""Get local opacity multiplied by any approximated ancestor group alpha."""
base = ctx.opacity_multiplier if ctx is not None else 1.0
if ctx is not None:
op = _get_attr(elem, 'opacity', ctx)
else:
op = parse_inline_style(elem.get('style')).get('opacity') or elem.get('opacity')
if op is None:
return None
return base if base < 1.0 else None
try:
val = float(op)
val = base * max(0.0, min(1.0, float(op)))
return val if val < 1.0 else None
except ValueError:
return None
return base if base < 1.0 else None
def get_fill_opacity(
@@ -642,19 +766,19 @@ def get_fill_opacity(
Returns:
Combined opacity value, or None if fully opaque.
"""
base = 1.0
base = ctx.opacity_multiplier if ctx is not None else 1.0
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
if op:
try:
base = float(op)
base *= max(0.0, min(1.0, float(op)))
except ValueError:
pass
fill_op = _get_attr(elem, 'fill-opacity', ctx) if ctx else elem.get('fill-opacity')
if fill_op:
try:
base *= float(fill_op)
base *= max(0.0, min(1.0, float(fill_op)))
except ValueError:
pass
@@ -670,19 +794,19 @@ def get_stroke_opacity(
Returns:
Combined opacity value, or None if fully opaque.
"""
base = 1.0
base = ctx.opacity_multiplier if ctx is not None else 1.0
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
if op:
try:
base = float(op)
base *= max(0.0, min(1.0, float(op)))
except ValueError:
pass
stroke_op = _get_attr(elem, 'stroke-opacity', ctx) if ctx else elem.get('stroke-opacity')
if stroke_op:
try:
base *= float(stroke_op)
base *= max(0.0, min(1.0, float(stroke_op)))
except ValueError:
pass
@@ -1,18 +1,22 @@
"""Theme-font contracts shared by SVG conversion and PPTX package assembly."""
"""Theme typography contracts shared by SVG conversion and PPTX assembly."""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET
from .utils import parse_font_family
from .utils import FONT_PX_TO_HUNDREDTHS_PT, font_px_to_hpt, parse_font_family
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
_TEXT_FONT_SIZE_MIN = 100
_TEXT_FONT_SIZE_MAX = 400_000
class ThemeFontError(RuntimeError):
@@ -42,6 +46,14 @@ class ThemeFontSpec:
minor_family: str
@dataclass(frozen=True)
class MasterTextStyleSpec:
"""Title/body defaults written to a template slide master's txStyles."""
title_hpt: int
body_hpt: int
def _font_face(font_family: str) -> ThemeFontFace:
fonts = parse_font_family(font_family)
return ThemeFontFace(
@@ -91,6 +103,47 @@ def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
)
def _font_size_hpt(raw: str, field: str) -> int:
try:
px = float(raw)
except (TypeError, ValueError, OverflowError) as exc:
raise ThemeFontError(
f"spec_lock.md typography {field} must be a numeric px value: {raw!r}"
) from exc
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
if not math.isfinite(scaled):
raise ThemeFontError(
f"spec_lock.md typography {field} must be finite: {raw!r}"
)
size = font_px_to_hpt(px)
if not _TEXT_FONT_SIZE_MIN <= size <= _TEXT_FONT_SIZE_MAX:
raise ThemeFontError(
f"spec_lock.md typography {field} is outside the PowerPoint "
f"font-size range: {raw!r}"
)
return size
def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec:
"""Load required title/body defaults for template slide-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"
)
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: "
+ ", ".join(missing)
)
return MasterTextStyleSpec(
title_hpt=_font_size_hpt(rows["title"], "title"),
body_hpt=_font_size_hpt(rows["body"], "body"),
)
def theme_font_tokens(
fonts: dict[str, str],
spec: ThemeFontSpec | None,
@@ -151,3 +204,82 @@ def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None:
_patch_font_collection(major, spec.major)
_patch_font_collection(minor, spec.minor)
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]:
run_properties = list(style.iter(f"{{{DML_NS}}}defRPr"))
if not run_properties:
raise ThemeFontError(f"slide master {label} has no a:defRPr entries")
return run_properties
def apply_master_text_style_spec(
extract_dir: Path,
spec: MasterTextStyleSpec,
) -> int:
"""Install locked title/body sizes into template slide-master txStyles."""
master_dir = extract_dir / "ppt" / "slideMasters"
master_paths = sorted(master_dir.glob("slideMaster*.xml"))
if not master_paths:
raise ThemeFontError(f"PPTX package has no slide master under {master_dir}")
ET.register_namespace("a", DML_NS)
ET.register_namespace("p", PML_NS)
for master_path in master_paths:
try:
tree = ET.parse(master_path)
except (OSError, ET.ParseError) as exc:
raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc
text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles")
if text_styles is None:
raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}")
style_sizes = (
("titleStyle", spec.title_hpt),
("bodyStyle", spec.body_hpt),
("otherStyle", spec.body_hpt),
)
for style_name, size in style_sizes:
style = text_styles.find(f"{{{PML_NS}}}{style_name}")
if style is None:
raise ThemeFontError(
f"Slide master has no p:{style_name}: {master_path}"
)
for run_properties in _style_run_properties(
style,
f"p:{style_name}",
):
run_properties.set("sz", str(size))
tree.write(master_path, encoding="utf-8", xml_declaration=True)
try:
read_back = ET.parse(master_path).getroot()
except (OSError, ET.ParseError) as exc:
raise ThemeFontError(
f"Cannot read back slide master {master_path}: {exc}"
) from exc
read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles")
if read_back_styles is None:
raise ThemeFontError(
f"Slide master lost p:txStyles after update: {master_path}"
)
for style_name, expected_size in style_sizes:
style = read_back_styles.find(f"{{{PML_NS}}}{style_name}")
actual_sizes = (
{
item.get("sz")
for item in _style_run_properties(
style,
f"p:{style_name}",
)
}
if style is not None
else set()
)
if actual_sizes != {str(expected_size)}:
raise ThemeFontError(
f"Slide master p:{style_name} size read-back failed: "
f"{master_path}"
)
return len(master_paths)
@@ -2,8 +2,9 @@
from __future__ import annotations
import re
import colorsys
import math
import re
from xml.etree import ElementTree as ET
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
@@ -22,7 +23,7 @@ ANGLE_UNIT = 60000 # DrawingML angle: 60000ths of a degree
# SVG attributes inheritable from parent <g>
INHERITABLE_ATTRS = [
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
'stroke-linejoin', 'opacity', 'fill-opacity', 'stroke-opacity',
'stroke-linejoin', 'fill-opacity', 'stroke-opacity',
'font-family', 'font-size', 'font-weight', 'font-style',
'text-anchor', 'letter-spacing', 'text-decoration',
]
@@ -145,6 +146,11 @@ def px_to_emu(px: float) -> int:
return round(px * EMU_PER_PX)
def font_px_to_hpt(font_size_px: float) -> int:
"""Convert SVG px to DrawingML hundredths-of-a-point at 0.1pt precision."""
return int(round(font_size_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
def _f(val: str | None, default: float = 0.0) -> float:
"""Parse a float attribute value, returning default if missing."""
if val is None:
@@ -447,41 +453,146 @@ def parse_inline_style(style_str: str | None) -> dict[str, str]:
return styles
def _finite_float(raw: str) -> float:
"""Parse a finite floating-point number."""
value = float(raw)
if not math.isfinite(value):
raise ValueError(f'Non-finite numeric value: {raw}')
return value
def _parse_color_channel(raw: str) -> int:
raw = raw.strip()
if raw.endswith('%'):
value = float(raw[:-1]) * 255.0 / 100.0
value = _finite_float(raw[:-1]) * 255.0 / 100.0
else:
value = float(raw)
value = _finite_float(raw)
return max(0, min(255, int(round(value))))
def parse_hex_color(color_str: str) -> str | None:
"""Parse SVG color values to 'RRGGBB'. Returns None on failure."""
def _parse_alpha_channel(raw: str) -> float:
"""Parse a CSS alpha channel as a clamped ``0..1`` ratio."""
raw = raw.strip()
value = (
_finite_float(raw[:-1]) / 100.0
if raw.endswith('%')
else _finite_float(raw)
)
return max(0.0, min(1.0, value))
def parse_opacity(raw: str | None, default: float = 1.0) -> float:
"""Parse a number or percentage opacity, falling back to ``default``."""
if raw is None:
return max(0.0, min(1.0, default))
try:
return _parse_alpha_channel(raw)
except ValueError:
return max(0.0, min(1.0, default))
def _functional_color_parts(body: str) -> tuple[list[str], str | None]:
"""Split legacy comma or modern space/slash functional color syntax."""
before, separator, after = body.partition('/')
parts = [part for part in re.split(r'[\s,]+', before.strip()) if part]
alpha = after.strip() if separator else None
if alpha is None and len(parts) > 3:
alpha = parts.pop()
return parts, alpha
def _parse_hue_degrees(raw: str) -> float:
"""Normalize a CSS hue angle to degrees."""
value = raw.strip().lower()
for suffix, multiplier in (
('turn', 360.0),
('grad', 0.9),
('rad', 180.0 / math.pi),
('deg', 1.0),
):
if value.endswith(suffix):
return _finite_float(value[:-len(suffix)]) * multiplier
return _finite_float(value)
def _parse_percentage(raw: str) -> float:
"""Parse a CSS percentage channel as a clamped ``0..1`` ratio."""
value = raw.strip()
ratio = (
_finite_float(value[:-1]) / 100.0
if value.endswith('%')
else _finite_float(value) / 100.0
)
return max(0.0, min(1.0, ratio))
def parse_svg_color(color_str: str) -> tuple[str | None, float]:
"""Parse an SVG/CSS color into ``(RRGGBB, alpha)``."""
if not color_str:
return None
return None, 1.0
color_str = color_str.strip()
named = _CSS_NAMED_COLORS.get(color_str.lower())
if named is not None or color_str.lower() in _CSS_NAMED_COLORS:
return named
if color_str.lower() == 'transparent':
return '000000', 0.0
return named, 1.0
rgb_match = re.match(r'rgba?\((.+)\)$', color_str, flags=re.IGNORECASE)
if rgb_match:
channels = re.findall(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?%?', rgb_match.group(1))
if len(channels) >= 3:
channels, alpha_raw = _functional_color_parts(rgb_match.group(1))
if len(channels) == 3:
try:
r, g, b = (_parse_color_channel(ch) for ch in channels[:3])
return f'{r:02X}{g:02X}{b:02X}'
r, g, b = (_parse_color_channel(ch) for ch in channels)
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
return f'{r:02X}{g:02X}{b:02X}', alpha
except ValueError:
return None
return None, 1.0
hsl_match = re.match(r'hsla?\((.+)\)$', color_str, flags=re.IGNORECASE)
if hsl_match:
channels, alpha_raw = _functional_color_parts(hsl_match.group(1))
if len(channels) == 3:
try:
hue = (_parse_hue_degrees(channels[0]) % 360.0) / 360.0
saturation = _parse_percentage(channels[1])
lightness = _parse_percentage(channels[2])
red, green, blue = colorsys.hls_to_rgb(hue, lightness, saturation)
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
return (
f'{round(red * 255):02X}{round(green * 255):02X}{round(blue * 255):02X}',
alpha,
)
except ValueError:
return None, 1.0
if color_str.startswith('#'):
color_str = color_str[1:]
if len(color_str) == 3:
color_str = ''.join(c * 2 for c in color_str)
elif len(color_str) == 4:
color_str = ''.join(c * 2 for c in color_str)
if len(color_str) == 8 and all(c in '0123456789abcdefABCDEF' for c in color_str):
return color_str[:6].upper(), int(color_str[6:], 16) / 255.0
if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
return color_str.upper()
return None
return color_str.upper(), 1.0
return None, 1.0
def parse_hex_color(color_str: str) -> str | None:
"""Parse SVG color values to ``RRGGBB``, ignoring any alpha channel."""
if color_str and color_str.strip().lower() == 'transparent':
return None
color, _alpha = parse_svg_color(color_str)
return color
def combine_opacity(*values: float | None) -> float | None:
"""Multiply opacity components, returning ``None`` when fully opaque."""
combined = 1.0
for value in values:
if value is not None:
combined *= max(0.0, min(1.0, value))
return combined if combined < 1.0 else None
def parse_stop_style(style_str: str) -> tuple[str | None, float]:
@@ -494,21 +605,18 @@ def parse_stop_style(style_str: str) -> tuple[str | None, float]:
(color, opacity) tuple.
"""
color = None
opacity = 1.0
if not style_str:
return color, opacity
color_alpha = 1.0
stop_opacity = 1.0
style_values = parse_inline_style(style_str)
if not style_values:
return color, stop_opacity
for part in style_str.split(';'):
part = part.strip()
if part.startswith('stop-color:'):
color = parse_hex_color(part.split(':', 1)[1].strip())
elif part.startswith('stop-opacity:'):
try:
opacity = float(part.split(':', 1)[1].strip())
except ValueError:
pass
if 'stop-color' in style_values:
color, color_alpha = parse_svg_color(style_values['stop-color'])
if 'stop-opacity' in style_values:
stop_opacity = parse_opacity(style_values['stop-opacity'])
return color, opacity
return color, color_alpha * stop_opacity
def resolve_url_id(url_str: str) -> str | None:
@@ -0,0 +1,140 @@
"""Materialize the supported inline SVG geometry-property subset.
SVG 2 lets selected geometry values participate in CSS. PPT Master does not
run a CSS engine, but it can safely compile literal per-element ``style``
declarations into the equivalent XML geometry attributes before any existing
SVG post-processing or DrawingML conversion runs.
"""
from __future__ import annotations
import copy
import math
import re
from pathlib import Path
from xml.etree import ElementTree as ET
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
INLINE_GEOMETRY_PROPERTIES = {
'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}),
'circle': frozenset({'cx', 'cy', 'r'}),
'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}),
'image': frozenset({'x', 'y', 'width', 'height'}),
'svg': frozenset({'x', 'y', 'width', 'height'}),
'use': frozenset({'x', 'y', 'width', 'height'}),
}
_GEOMETRY_LIKE_PROPERTIES = frozenset({
'x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r',
'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points', 'd',
})
_NON_NEGATIVE_PROPERTIES = frozenset({'width', 'height', 'rx', 'ry', 'r'})
_PX_LENGTH_RE = re.compile(
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(px)?\s*$',
re.IGNORECASE,
)
class GeometryStyleError(ValueError):
"""Reject inline geometry that cannot be compiled deterministically."""
def _local_tag(elem: ET.Element) -> str:
tag = str(elem.tag)
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
def _format_number(value: float) -> str:
if abs(value) < 1e-12:
value = 0.0
return f'{value:.12g}'
def _normalize_px_length(raw: str, tag: str, prop: str) -> str:
"""Return one finite CSS px literal as a unitless SVG user value."""
match = _PX_LENGTH_RE.fullmatch(raw)
if match is None:
raise GeometryStyleError(
f'<{tag}> inline geometry {prop} requires a finite px literal; '
f'got {raw!r}'
)
value = float(match.group(1))
if not math.isfinite(value):
raise GeometryStyleError(
f'<{tag}> inline geometry {prop} must be finite; got {raw!r}'
)
if match.group(2) is None and value != 0:
raise GeometryStyleError(
f'<{tag}> inline geometry {prop} requires px for non-zero values; '
f'got {raw!r}'
)
if prop in _NON_NEGATIVE_PROPERTIES and value < 0:
raise GeometryStyleError(
f'<{tag}> inline geometry {prop} cannot be negative; got {raw!r}'
)
return _format_number(value)
def materialize_inline_geometry_properties(root: ET.Element) -> int:
"""Compile supported inline geometry declarations into XML attributes."""
materialized = 0
for elem in root.iter():
style = elem.get('style')
if not style:
continue
tag = _local_tag(elem)
supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
retained: list[str] = []
element_materialized = 0
for raw_declaration in style.split(';'):
declaration = raw_declaration.strip()
if not declaration:
continue
if ':' not in declaration:
retained.append(declaration)
continue
raw_name, raw_value = declaration.split(':', 1)
name = raw_name.strip().lower()
value = raw_value.strip()
if name not in _GEOMETRY_LIKE_PROPERTIES:
retained.append(declaration)
continue
if name not in supported:
raise GeometryStyleError(
f'<{tag}> does not support inline geometry property {name!r}; '
'use the element\'s XML geometry attribute instead'
)
elem.set(name, _normalize_px_length(value, tag, name))
materialized += 1
element_materialized += 1
if element_materialized == 0:
continue
if retained:
elem.set('style', '; '.join(retained))
else:
elem.attrib.pop('style', None)
return materialized
def validate_inline_geometry_properties(root: ET.Element) -> list[str]:
"""Return inline geometry errors without mutating the caller's SVG tree."""
try:
materialize_inline_geometry_properties(copy.deepcopy(root))
except GeometryStyleError as exc:
return [str(exc)]
return []
def materialize_inline_geometry_in_file(svg_path: Path) -> int:
"""Materialize inline geometry in one SVG file in place."""
tree = ET.parse(str(svg_path))
count = materialize_inline_geometry_properties(tree.getroot())
if count:
ET.register_namespace('', SVG_NS)
ET.register_namespace('xlink', XLINK_NS)
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
return count
@@ -17,6 +17,7 @@ from ..drawingml.utils import (
ctx_w,
ctx_x,
ctx_y,
font_px_to_hpt,
matrix_multiply,
parse_transform_matrix,
transform_point,
@@ -822,7 +823,7 @@ def _font_size_hpt(value: Any, default_px: int = 18) -> int:
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
if not math.isfinite(scaled):
return None
size = int(round(scaled / 10.0)) * 10
size = font_px_to_hpt(px)
if not _TEXT_FONT_SIZE_MIN <= size <= _TEXT_FONT_SIZE_MAX:
return None
return size
@@ -4,9 +4,11 @@ from __future__ import annotations
import hashlib
import json
import math
import mimetypes
import os
import posixpath
import random
import re
import shutil
import stat
@@ -15,7 +17,7 @@ import tempfile
import uuid
import zipfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -26,6 +28,25 @@ from xml.sax.saxutils import escape
from pptx import Presentation
from pptx.util import Emu
from pptx_transitions import (
TRANSITIONS,
create_transition_xml,
normalize_transition_effect,
set_directory_use_timings,
validate_generated_transition_xml,
validate_pptx_transition_package,
validate_seconds,
)
from pptx_animations import (
animation_seconds_to_milliseconds,
create_sequence_timing_xml,
normalize_animation_effect,
normalize_animation_trigger,
pick_animation_effect,
validate_generated_animation_xml,
validate_pptx_animation_package,
)
from ..drawingml.converter import convert_svg_to_slide_shapes
from ..drawingml.theme_colors import (
ThemeColorSpec,
@@ -33,7 +54,9 @@ from ..drawingml.theme_colors import (
rewrite_chart_accent_colors,
)
from ..drawingml.theme_fonts import (
MasterTextStyleSpec,
ThemeFontSpec,
apply_master_text_style_spec,
apply_theme_font_spec,
)
from ..drawingml.utils import EMU_PER_PX
@@ -69,31 +92,21 @@ from .narration import (
probe_audio_duration,
)
from .slide_xml import (
ANIMATIONS_AVAILABLE, TRANSITIONS,
create_slide_xml_with_svg, create_slide_rels_xml,
)
from .template_structure import (
NativeStructureContract,
OOXML_UINT32_MAX,
TEMPLATE_PLACEHOLDER_TYPES,
TemplateElementSpec,
TemplateSlideSpec,
TemplateStructureError,
match_native_placeholders,
parse_preserve_slides,
parse_template_slides,
template_placeholder_bindings,
)
# Re-import create_transition_xml only if available
try:
from pptx_animations import (
create_transition_xml,
create_sequence_timing_xml,
pick_animation_effect,
)
except ImportError:
create_transition_xml = None
create_sequence_timing_xml = None
pick_animation_effect = None
from .template_validation import validate_pptx_template_package
SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
@@ -658,7 +671,13 @@ def _next_slide_layout_id(extract_dir: Path, master_xml: str) -> int:
r'\bid="(\d{9,})"', presentation_path.read_text(encoding="utf-8")
)
)
return max([*ids, 2147483648]) + 1
next_id = max([*ids, 2147483648]) + 1
if next_id > OOXML_UINT32_MAX:
raise TemplateStructureError(
"Cannot register another Slide Layout because the OOXML UInt32 "
"identifier range is exhausted"
)
return next_id
def _create_cover_layout(extract_dir: Path, master_part: str, base_layout_part: str) -> str:
@@ -1150,19 +1169,6 @@ def _promote_common_chrome_shapes_to_layouts(
return promoted
_TEMPLATE_PLACEHOLDER_TYPES = {
"title": "title",
"subtitle": "subTitle",
"body": "body",
"picture": "pic",
"chart": "chart",
"table": "tbl",
"object": "obj",
"media": "media",
"date": "dt",
"footer": "ftr",
"slide-number": "sldNum",
}
_TEMPLATE_PLACEHOLDER_PROMPTS = {
"title": "Click to add title",
"subtitle": "Click to add subtitle",
@@ -1611,6 +1617,46 @@ def _replace_shape_xfrm(
sp_pr.insert(0, xfrm)
def _set_layout_level_one_default_size(
list_style: ET.Element,
source_run_pr: ET.Element | None,
) -> None:
"""Persist the prototype run size as the Layout's level-one text default."""
if source_run_pr is None or source_run_pr.get("sz") is None:
return
level_tag = f"{{{DML_NS}}}lvl1pPr"
level_props = list_style.find(level_tag)
if level_props is None:
level_props = ET.Element(level_tag)
trailing_tags = {
f"{{{DML_NS}}}lvl{level}pPr" for level in range(2, 10)
}
trailing_tags.add(f"{{{DML_NS}}}extLst")
insert_at = next(
(
index
for index, child in enumerate(list_style)
if child.tag in trailing_tags
),
len(list_style),
)
list_style.insert(insert_at, level_props)
default_props = level_props.find(f"{{{DML_NS}}}defRPr")
if default_props is None:
default_props = ET.Element(f"{{{DML_NS}}}defRPr")
ext_tag = f"{{{DML_NS}}}extLst"
insert_at = next(
(
index
for index, child in enumerate(level_props)
if child.tag == ext_tag
),
len(level_props),
)
level_props.insert(insert_at, default_props)
default_props.set("sz", source_run_pr.get("sz", ""))
def _placeholder_text_body(
source_shape: ET.Element,
item: TemplateElementSpec,
@@ -1627,26 +1673,28 @@ def _placeholder_text_body(
if source_tx_body is not None
else None
)
tx_body.append(
ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
if source_body_pr is not None
else ET.Element(f"{{{DML_NS}}}bodyPr")
)
tx_body.append(
ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
if source_lst_style is not None
else ET.Element(f"{{{DML_NS}}}lstStyle")
)
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
if item.placeholder == "body":
paragraph_props = ET.SubElement(paragraph, f"{{{DML_NS}}}pPr")
ET.SubElement(paragraph_props, f"{{{DML_NS}}}buNone")
source_run_pr = (
source_tx_body.find(f".//{{{DML_NS}}}rPr")
if source_tx_body is not None
else None
)
tx_body.append(
ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
if source_body_pr is not None
else ET.Element(f"{{{DML_NS}}}bodyPr")
)
list_style = (
ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
if source_lst_style is not None
else ET.Element(f"{{{DML_NS}}}lstStyle")
)
_set_layout_level_one_default_size(list_style, source_run_pr)
tx_body.append(list_style)
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
if item.placeholder == "body":
paragraph_props = ET.SubElement(paragraph, f"{{{DML_NS}}}pPr")
ET.SubElement(paragraph_props, f"{{{DML_NS}}}buNone")
if item.placeholder in {"slide-number", "date"}:
field_type = (
"slidenum"
@@ -1734,7 +1782,7 @@ def _set_placeholder_theme_font_role(
return
if item.placeholder == "title":
prefix = "+mj"
elif item.placeholder in _TEMPLATE_PLACEHOLDER_TYPES:
elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
prefix = "+mn"
else:
return
@@ -1753,7 +1801,7 @@ def _layout_placeholder_shape(
theme_font_spec: ThemeFontSpec | None = None,
) -> ET.Element:
"""Build one reusable p:sp placeholder from a prototype slide object."""
placeholder_type = _TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
if placeholder_type is None:
raise TemplateStructureError(
f"Unsupported placeholder type: {item.placeholder!r}"
@@ -1806,7 +1854,7 @@ def _patch_slide_placeholder(
placeholder_type: str | None = None,
theme_font_spec: ThemeFontSpec | None = None,
) -> None:
resolved_type = placeholder_type or _TEMPLATE_PLACEHOLDER_TYPES.get(
resolved_type = placeholder_type or TEMPLATE_PLACEHOLDER_TYPES.get(
item.placeholder or ""
)
if resolved_type is None:
@@ -1948,8 +1996,10 @@ def _apply_template_structure(
layout_path = extract_dir / layout_part
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
placeholder_idx = 1
assigned_placeholder_indices: set[int] = set()
placeholder_bindings = {
binding.element.element_id: binding
for binding in template_placeholder_bindings(prototype)
}
for item in prototype.elements:
if item.layer == "layout":
_move_template_static_shape(
@@ -1968,21 +2018,8 @@ def _apply_template_structure(
raise TemplateStructureError(
f"Placeholder {item.element_id!r} cannot be a slide background"
)
if item.placeholder == "title":
assigned_idx = item.placeholder_idx
else:
assigned_idx = (
item.placeholder_idx
if item.placeholder_idx is not None
else placeholder_idx
)
if (
assigned_idx is not None
and assigned_idx in assigned_placeholder_indices
):
raise TemplateStructureError(
f"Layout {layout_key!r} repeats placeholder idx {assigned_idx}"
)
binding = placeholder_bindings[item.element_id]
assigned_idx = binding.assigned_idx
layout_placeholder = _layout_placeholder_shape(
prototype_shape,
item,
@@ -2003,9 +2040,6 @@ def _apply_template_structure(
assigned_idx,
theme_font_spec=theme_font_spec,
)
if assigned_idx is not None:
assigned_placeholder_indices.add(assigned_idx)
placeholder_idx = max(placeholder_idx, assigned_idx + 1)
placeholder_count += 1
_set_template_layout_header_footer(layout_path, prototype.placeholders)
@@ -2755,21 +2789,18 @@ def _ensure_notes_master(extract_dir: Path) -> None:
presentation_path.write_text(presentation_xml, encoding='utf-8')
def _to_float(value: Any, default: float) -> float:
if value is None:
return default
try:
number = float(value)
except (TypeError, ValueError):
return default
return number if number >= 0 else default
def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dict[str, Any]:
if not animation_config:
return {}
slides = _as_dict(animation_config.get('slides'))
return _as_dict(slides.get(svg_stem))
slides_value = animation_config.get('slides', {})
if not isinstance(slides_value, dict):
raise ValueError('animations.json field "slides" must be an object')
slide_value = slides_value.get(svg_stem, {})
if not isinstance(slide_value, dict):
raise ValueError(
f'animations.json slide "{svg_stem}" must be an object'
)
return slide_value
def _slide_transition_settings(
@@ -2779,15 +2810,30 @@ def _slide_transition_settings(
auto_advance: float | None,
cli_overrides: dict[str, bool],
) -> tuple[str | None, float, float | None]:
trans_cfg = _as_dict(slide_cfg.get('transition'))
trans_value = slide_cfg.get('transition', {})
if not isinstance(trans_value, dict):
raise ValueError('animations.json slide transition must be an object')
trans_cfg = trans_value
effect = transition
if not cli_overrides.get('transition') and 'effect' in trans_cfg:
cfg_effect = str(trans_cfg.get('effect'))
effect = None if cfg_effect == 'none' else cfg_effect
raw_effect = trans_cfg['effect']
if not isinstance(raw_effect, str):
raise ValueError('animations.json transition effect must be a string')
cfg_effect = normalize_transition_effect(raw_effect)
effect = cfg_effect
if not cli_overrides.get('transition_duration'):
duration = _to_float(trans_cfg.get('duration'), duration)
if 'duration' in trans_cfg:
duration = validate_seconds(
trans_cfg.get('duration'),
"transition duration",
allow_zero=effect is None,
)
if not cli_overrides.get('auto_advance') and 'auto_advance' in trans_cfg:
auto_advance = _to_float(trans_cfg.get('auto_advance'), auto_advance or 0)
auto_advance = validate_seconds(
trans_cfg.get('auto_advance'),
"transition auto_advance",
allow_zero=True,
)
return effect, duration, auto_advance
@@ -2799,65 +2845,131 @@ def _slide_animation_settings(
trigger: str,
cli_overrides: dict[str, bool],
) -> tuple[str | None, float, float, str]:
anim_cfg = _as_dict(slide_cfg.get('animation'))
effect = animation
anim_value = slide_cfg.get('animation', {})
if not isinstance(anim_value, dict):
raise ValueError('animations.json slide animation must be an object')
anim_cfg = anim_value
effect = normalize_animation_effect(
animation,
allow_none=True,
allow_modes=True,
)
if not cli_overrides.get('animation') and 'effect' in anim_cfg:
cfg_effect = str(anim_cfg.get('effect'))
effect = None if cfg_effect == 'none' else cfg_effect
effect = normalize_animation_effect(
anim_cfg.get('effect'),
allow_none=True,
allow_modes=True,
)
if not cli_overrides.get('animation_duration'):
duration = _to_float(anim_cfg.get('duration'), duration)
duration = validate_seconds(
anim_cfg.get('duration', duration),
'animation duration',
allow_zero=False,
)
if not cli_overrides.get('animation_stagger'):
stagger = _to_float(anim_cfg.get('stagger'), stagger)
if not cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
trigger = str(anim_cfg.get('trigger'))
stagger = validate_seconds(
anim_cfg.get('stagger', stagger),
'animation stagger',
allow_zero=True,
)
if not cli_overrides.get('animation_trigger') and 'trigger' in anim_cfg:
trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
else:
trigger = normalize_animation_trigger(trigger)
animation_seconds_to_milliseconds(
duration,
'animation duration',
allow_zero=False,
)
animation_seconds_to_milliseconds(
stagger,
'animation stagger',
allow_zero=True,
)
return effect, duration, stagger, trigger
def _build_sequence_targets(
anim_targets: list[tuple[int, str]],
slide_cfg: dict[str, Any],
animation: str,
animation: str | None,
duration: float,
stagger: float,
mixed_animation_offset: int,
animation_rng: random.Random,
) -> tuple[list[tuple[int, int, str, float]], int]:
groups_cfg = _as_dict(slide_cfg.get('groups'))
ordered: list[tuple[int, int, int, str, dict[str, Any]]] = []
groups_value = slide_cfg.get('groups', {})
if not isinstance(groups_value, dict):
raise ValueError('animations.json slide groups must be an object')
groups_cfg = groups_value
ordered: list[tuple[int, int, str, dict[str, Any]]] = []
for idx, (sid, svg_id) in enumerate(anim_targets):
group_cfg = _as_dict(groups_cfg.get(svg_id))
if str(group_cfg.get('effect', '')).lower() == 'none':
group_value = groups_cfg.get(svg_id, {})
if not isinstance(group_value, dict):
raise ValueError(
f'animations.json group "{svg_id}" must be an object'
)
group_cfg = group_value
raw_effect = group_cfg.get('effect')
if raw_effect is not None:
normalized_effect = normalize_animation_effect(
raw_effect,
allow_none=True,
allow_modes=True,
)
else:
normalized_effect = None
if 'effect' in group_cfg and normalized_effect is None:
continue
if animation is None and normalized_effect is None:
continue
order_value = group_cfg.get('order')
try:
order = int(order_value)
has_order = 0
except (TypeError, ValueError):
order = idx
has_order = 1
order = order_value if order_value is not None else idx + 1
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
raise ValueError(
f'animations.json group "{svg_id}" order must be a positive integer'
)
group_entry = dict(group_cfg)
group_entry['_shape_id'] = sid
ordered.append((has_order, order, idx, svg_id, group_entry))
group_entry['_effect'] = normalized_effect
ordered.append((order, idx, svg_id, group_entry))
ordered.sort(key=lambda item: (item[0], item[1], item[2]))
ordered.sort(key=lambda item: (item[0], item[1]))
seq_targets: list[tuple[int, int, str, float]] = []
for seq_idx, (_has_order, _order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
resolved_group_modes: list[str | None] = []
for seq_idx, (_order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
shape_id = int(group_cfg['_shape_id'])
raw_effect = group_cfg.get('effect')
raw_effect = group_cfg.get('_effect')
resolved_group_modes.append(
raw_effect if raw_effect in ('auto', 'mixed', 'random') else None
)
if raw_effect in ('auto', 'mixed', 'random'):
effect = pick_animation_effect(
str(raw_effect), seq_idx, mixed_animation_offset, group_id=_svg_id,
rng=animation_rng,
)
else:
effect = str(raw_effect or pick_animation_effect(
animation, seq_idx, mixed_animation_offset, group_id=_svg_id,
rng=animation_rng,
))
item_duration = _to_float(group_cfg.get('duration'), duration)
delay_seconds = _to_float(
group_cfg.get('delay'),
0 if seq_idx == 0 else stagger,
item_duration = validate_seconds(
group_cfg.get('duration', duration),
f'animation duration for group "{_svg_id}"',
allow_zero=False,
)
seq_targets.append((shape_id, int(delay_seconds * 1000), effect, item_duration))
delay_seconds = validate_seconds(
group_cfg.get('delay', 0 if seq_idx == 0 else stagger),
f'animation delay for group "{_svg_id}"',
allow_zero=True,
)
delay_ms = animation_seconds_to_milliseconds(
delay_seconds,
f'animation delay for group "{_svg_id}"',
allow_zero=True,
)
seq_targets.append((shape_id, delay_ms, effect, item_duration))
mixed_count = 0
if animation == 'mixed':
@@ -2868,6 +2980,12 @@ def _build_sequence_targets(
# semantic matches (title→fade, chart→wipe etc.) are unaffected
# because they ignore the offset.
mixed_count = len(seq_targets)
else:
mixed_count = sum(
1
for seq_idx, mode in enumerate(resolved_group_modes)
if mode == 'auto' or (mode == 'mixed' and seq_idx > 0)
)
return seq_targets, mixed_count
@@ -3220,7 +3338,7 @@ def create_pptx_with_native_svg(
use_compat_mode: bool = True,
notes: dict[str, str] | None = None,
enable_notes: bool = True,
use_native_shapes: bool = False,
use_native_shapes: bool = True,
animation: str | None = None,
animation_duration: float = 0.4,
animation_stagger: float = 0.5,
@@ -3244,9 +3362,10 @@ def create_pptx_with_native_svg(
pptx_structure: str = "baseline",
native_structure_contract: NativeStructureContract | None = None,
theme_font_spec: ThemeFontSpec | None = None,
master_text_style_spec: MasterTextStyleSpec | None = None,
theme_color_spec: ThemeColorSpec | None = None,
) -> bool:
"""Create a PPTX file with native SVG.
"""Create a PPTX file with native DrawingML shapes.
Args:
svg_files: List of SVG files.
@@ -3256,10 +3375,10 @@ def create_pptx_with_native_svg(
transition: Transition effect name.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
use_compat_mode: Use Office compatibility mode (PNG + SVG dual format).
use_compat_mode: Retained for API compatibility; ignored in native mode.
notes: Notes dict, key is SVG stem, value is notes content.
enable_notes: Whether to enable notes embedding.
use_native_shapes: Convert SVG to native DrawingML shapes.
use_native_shapes: Must remain true; SVG-image PPTX export is unsupported.
animation: Per-element entrance animation mode (single effect name,
'mixed', 'random', or None to disable). Native shapes mode only.
animation_duration: Per-element entrance duration in seconds.
@@ -3293,6 +3412,8 @@ def create_pptx_with_native_svg(
``preserve`` mode.
theme_font_spec: Locked project major/minor fonts for baseline/template
theme inheritance. Preserve and flat modes ignore this value.
master_text_style_spec: Required locked title/body sizes for template
slide-master text styles. Other structure modes ignore this value.
theme_color_spec: Locked project color scheme for context-aware
baseline/template theme inheritance. Preserve and flat modes
ignore this value.
@@ -3300,15 +3421,23 @@ def create_pptx_with_native_svg(
Returns:
Whether all slides were successfully created.
"""
if not use_native_shapes:
raise ValueError(
"SVG-image PPTX export is no longer supported; use svg_final/ "
"directly for preview and native DrawingML PPTX for delivery"
)
if not svg_files:
print("Error: No SVG files found")
return False
# Native shapes mode takes priority over compat mode
if use_native_shapes:
use_compat_mode = False
use_compat_mode = False
if pptx_structure not in {"baseline", "template", "preserve", "flat"}:
raise ValueError(f"Unsupported pptx_structure: {pptx_structure}")
if pptx_structure == "template" and master_text_style_spec is None:
raise ValueError(
"Template export requires locked typography title/body sizes "
"in master_text_style_spec"
)
if use_native_shapes and pptx_structure == "template":
template_specs = parse_template_slides(svg_files)
elif use_native_shapes and pptx_structure == "preserve":
@@ -3453,6 +3582,18 @@ def create_pptx_with_native_svg(
)
if active_theme_color_spec is not None:
apply_theme_color_spec(extract_dir, active_theme_color_spec)
if pptx_structure == "template":
master_count = apply_master_text_style_spec(
extract_dir,
master_text_style_spec,
)
if verbose:
print(
" Template master text styles: "
f"{master_count} master(s), "
f"title {master_text_style_spec.title_hpt / 100:g}pt, "
f"body {master_text_style_spec.body_hpt / 100:g}pt"
)
structure = _read_slide_layout_targets(extract_dir, len(svg_files))
media_dir = extract_dir / 'ppt' / 'media'
@@ -3484,7 +3625,19 @@ def create_pptx_with_native_svg(
notes_slides_created: set[int] = set()
narration_slides_created: set[int] = set()
audio_exts_used: set[str] = set()
package_uses_timings = False
mixed_animation_offset = 0
animation_seed = json.dumps(
{
'animation': animation,
'config': animation_config,
'slides': [path.name for path in svg_files],
},
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
)
animation_rng = random.Random(animation_seed)
conversion_trace: list[dict[str, Any]] | None = [] if conversion_trace_path else None
structure_trace: list[dict[str, Any]] | None = (
[]
@@ -3494,35 +3647,14 @@ def create_pptx_with_native_svg(
for i, svg_path in enumerate(svg_files, 1):
slide_num = i
expected_animation_targets: list[tuple[int, int, str, float]] = []
expected_animation_duration = animation_duration
expected_animation_trigger = normalize_animation_trigger(animation_trigger)
try:
# ---- Native shapes mode ----
if use_native_shapes:
slide_cfg = _slide_config(animation_config, svg_path.stem)
(
slide_xml,
media_files_dict,
rel_entries,
anim_targets,
package_files_dict,
content_type_overrides,
) = (
convert_svg_to_slide_shapes(
svg_path, slide_num=slide_num, verbose=verbose,
merge_paragraphs=merge_paragraphs,
image_optimize=image_optimize,
image_max_dimension=image_max_dimension,
image_sizing=image_sizing,
image_scale=image_scale,
image_quality=image_quality,
native_objects=native_objects,
theme_font_spec=active_theme_font_spec,
theme_color_spec=active_theme_color_spec,
trace_out=conversion_trace
if conversion_trace is not None
else structure_trace,
)
)
slide_transition, slide_transition_duration, slide_auto_advance = (
_slide_transition_settings(
slide_cfg,
@@ -3545,26 +3677,79 @@ def create_pptx_with_native_svg(
animation_trigger,
animation_cli_overrides,
)
groups_value = slide_cfg.get('groups', {})
if not isinstance(groups_value, dict):
raise ValueError(
'animations.json slide groups must be an object'
)
animation_hard_disabled = (
animation_cli_overrides.get('animation', False)
and animation is None
)
explicit_animation_groups = (
frozenset(
str(group_id)
for group_id, group_cfg in groups_value.items()
if isinstance(group_cfg, dict)
and group_cfg.get('effect') != 'none'
and (
slide_animation is not None
or 'effect' in group_cfg
)
)
if not animation_hard_disabled
else frozenset()
)
(
slide_xml,
media_files_dict,
rel_entries,
anim_targets,
package_files_dict,
content_type_overrides,
) = (
convert_svg_to_slide_shapes(
svg_path, slide_num=slide_num, verbose=verbose,
merge_paragraphs=merge_paragraphs,
image_optimize=image_optimize,
image_max_dimension=image_max_dimension,
image_sizing=image_sizing,
image_scale=image_scale,
image_quality=image_quality,
native_objects=native_objects,
animation_group_overrides=explicit_animation_groups,
theme_font_spec=active_theme_font_spec,
theme_color_spec=active_theme_color_spec,
trace_out=conversion_trace
if conversion_trace is not None
else structure_trace,
)
)
# Order matters: OOXML schema requires <p:transition>
# to precede <p:timing> inside <p:sld>. Both use the same
# </p:sld> string-replace anchor, so transition must be
# injected first and timing second.
if slide_transition and ANIMATIONS_AVAILABLE and create_transition_xml:
transition_xml = '\n' + create_transition_xml(
if slide_transition is not None or slide_auto_advance is not None:
transition_fragment = create_transition_xml(
effect=slide_transition,
duration=slide_transition_duration,
advance_after=slide_auto_advance,
)
slide_xml = slide_xml.replace(
'</p:sld>',
transition_xml + '\n</p:sld>',
)
if transition_fragment:
slide_xml = slide_xml.replace(
'</p:sld>',
'\n' + transition_fragment + '\n</p:sld>',
)
if slide_auto_advance is not None:
package_uses_timings = True
if (slide_animation and slide_animation != 'none'
and create_sequence_timing_xml
and pick_animation_effect
and anim_targets):
expected_animation_duration = slide_animation_duration
expected_animation_trigger = slide_animation_trigger
if (
not animation_hard_disabled
and (slide_animation or explicit_animation_groups)
and anim_targets
):
seq_targets, mixed_count = _build_sequence_targets(
anim_targets,
slide_cfg,
@@ -3572,8 +3757,10 @@ def create_pptx_with_native_svg(
slide_animation_duration,
slide_animation_stagger,
mixed_animation_offset,
animation_rng,
)
if slide_animation in ('mixed', 'auto'):
expected_animation_targets = seq_targets
if mixed_count:
mixed_animation_offset += mixed_count
timing_xml = '\n' + create_sequence_timing_xml(
seq_targets, duration=slide_animation_duration,
@@ -3729,6 +3916,9 @@ def create_pptx_with_native_svg(
with open(rels_path, 'w', encoding='utf-8') as f:
f.write(rels_xml)
resolved_advance_after = slide_auto_advance
resolved_advance_on_click = True
# --- Process notes (shared between native and legacy mode) ---
notes_content = ''
if enable_notes:
@@ -3816,11 +4006,47 @@ def create_pptx_with_native_svg(
slide_xml,
advance_after=duration + narration_padding,
transition_duration=slide_transition_duration,
transition_effect=slide_transition or 'fade',
transition_effect=slide_transition,
)
resolved_advance_after = duration + narration_padding
resolved_advance_on_click = False
package_uses_timings = True
slide_xml_path.write_text(slide_xml, encoding='utf-8')
narration_slides_created.add(slide_num)
final_slide_xml = slide_xml_path.read_text(encoding='utf-8')
try:
resolved_motion = validate_generated_transition_xml(
final_slide_xml,
effect=slide_transition,
duration=slide_transition_duration,
advance_on_click=resolved_advance_on_click,
advance_after=resolved_advance_after,
)
except ValueError as exc:
raise RuntimeError(
f'Slide {slide_num} transition validation failed: {exc}'
) from exc
try:
resolved_animation = validate_generated_animation_xml(
final_slide_xml,
expected_animation_targets,
duration=expected_animation_duration,
trigger=expected_animation_trigger,
)
except ValueError as exc:
raise RuntimeError(
f'Slide {slide_num} animation validation failed: {exc}'
) from exc
if conversion_trace is not None:
motion_summary = asdict(resolved_motion)
for trace_entry in reversed(conversion_trace):
if trace_entry.get('slide_num') == slide_num:
trace_entry['motion'] = motion_summary
trace_entry['animation'] = asdict(resolved_animation)
break
if verbose:
if use_native_shapes:
mode_str = " (Native)"
@@ -4003,6 +4229,9 @@ def create_pptx_with_native_svg(
with open(content_types_path, 'w', encoding='utf-8') as f:
f.write(content_types)
if package_uses_timings:
set_directory_use_timings(extract_dir)
rels_problems = _verify_internal_rels_targets(extract_dir)
if rels_problems:
details = '\n'.join(f' - {p}' for p in rels_problems)
@@ -4025,6 +4254,39 @@ def create_pptx_with_native_svg(
if file_path.is_file():
arcname = file_path.relative_to(extract_dir)
zf.write(file_path, arcname)
if (
use_native_shapes
and pptx_structure == "template"
and success_count == len(svg_files)
):
if template_specs is None:
raise TemplateStructureError(
"Template structure metadata was not parsed before validation"
)
try:
validate_pptx_template_package(temp_output_path, template_specs)
except ValueError as exc:
raise TemplateStructureError(
f"PPTX template package validation failed: {exc}"
) from exc
try:
validate_pptx_transition_package(
temp_output_path,
require_use_timings=package_uses_timings,
)
except ValueError as exc:
raise RuntimeError(
f'PPTX transition package validation failed: {exc}'
) from exc
try:
validate_pptx_animation_package(
temp_output_path,
require_supported_effects=True,
)
except ValueError as exc:
raise RuntimeError(
f'PPTX animation package validation failed: {exc}'
) from exc
shutil.move(str(temp_output_path), str(output_path))
permission_warnings = _relax_output_permissions(output_path)
@@ -4,6 +4,7 @@ from __future__ import annotations
import sys
import json
import math
import shutil
import argparse
from datetime import datetime
@@ -15,6 +16,13 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_animations import ( # noqa: E402
ANIMATIONS,
animation_seconds_to_milliseconds,
normalize_animation_effect,
normalize_animation_trigger,
)
from pptx_transitions import validate_seconds # noqa: E402
configure_utf8_stdio()
@@ -30,7 +38,11 @@ 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 ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
from ..drawingml.theme_fonts import ThemeFontError, load_theme_font_spec
from ..drawingml.theme_fonts import (
ThemeFontError,
load_master_text_style_spec,
load_theme_font_spec,
)
from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
from .slide_xml import TRANSITIONS
from .template_structure import (
@@ -42,12 +54,12 @@ from .template_structure import (
parse_template_slides,
template_lock_errors,
)
from ..animation_config import load_animation_config, validate_animation_config
try:
from pptx_animations import ANIMATIONS as _ANIMATIONS
except ImportError:
_ANIMATIONS = {}
from ..animation_config import (
load_animation_config,
validate_animation_config,
validate_animation_config_errors,
validate_transition_config,
)
def _as_dict(value: object) -> dict:
@@ -79,6 +91,8 @@ def _recorded_narration_on_click_slides(
animation_cli_overrides: dict[str, bool],
) -> list[str]:
"""Return slides whose effective recorded-video animation trigger is on-click."""
if animation_cli_overrides.get('animation') and animation is None:
return []
slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
blocked: list[str] = []
for svg_path in ref_files:
@@ -87,14 +101,20 @@ def _recorded_narration_on_click_slides(
slide_animation = animation
if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
cfg_effect = str(anim_cfg.get('effect'))
slide_animation = None if cfg_effect == 'none' else cfg_effect
if slide_animation is None:
slide_animation = normalize_animation_effect(anim_cfg.get('effect'))
groups_cfg = _as_dict(slide_cfg.get('groups'))
has_explicit_animation = any(
isinstance(group_cfg, dict)
and 'effect' in group_cfg
and normalize_animation_effect(group_cfg.get('effect')) is not None
for group_cfg in groups_cfg.values()
)
if slide_animation is None and not has_explicit_animation:
continue
slide_trigger = animation_trigger
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
slide_trigger = str(anim_cfg.get('trigger'))
slide_trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
if slide_trigger == 'on-click':
blocked.append(svg_path.stem)
return blocked
@@ -107,20 +127,14 @@ def main(argv: list[str] | None = None) -> int:
else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
)
animation_choices = (
['none'] + (list(_ANIMATIONS.keys()) if _ANIMATIONS
else ['fade', 'fly', 'zoom', 'appear'])
+ ['auto', 'mixed', 'random']
)
animation_choices = ['none', *ANIMATIONS, 'auto', 'mixed', 'random']
parser = argparse.ArgumentParser(
description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
description='PPT Master - SVG to native DrawingML PPTX Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f'''
Examples:
%(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
%(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx
%(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native)
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
# Disable transition / change transition effect
@@ -129,9 +143,9 @@ Examples:
SVG source directory (-s):
output - svg_output (hand-authored source; native default)
final - svg_final (post-processed; legacy SVG-image path source)
final - svg_final (post-processed preview; diagnostic native input only)
<any> - Specify a subdirectory name directly
Omit -s to use the default: native reads svg_output, legacy reads svg_final.
Omit -s to use the default: native export reads svg_output.
Transition effects (-t/--transition):
{', '.join(transition_choices)}
@@ -154,13 +168,6 @@ Per-element entrance animation (-a/--animation, native shapes mode):
random samples from the same legacy pool. Use "-a none" to disable
element builds explicitly.
Compatibility mode (enabled by default):
- Automatically generates PNG fallback images, SVG embedded as extension
- Compatible with all Office versions (including Office LTSC 2021)
- Newer Office still displays SVG (editable), older versions display PNG
- Requires svglib: pip install svglib reportlab
- Use --no-compat to disable (only Office 2019+ supported)
Speaker notes (enabled by default):
- Automatically reads Markdown notes files from the notes/ directory
- Supports two naming conventions:
@@ -186,24 +193,13 @@ Recorded narration:
parser.add_argument('project_path', type=str, help='Project directory path')
parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
parser.add_argument('-s', '--source', type=str, default=None,
help='SVG source directory. Default: native reads '
'svg_output/ (high-fidelity, preserves icons / '
'preserveAspectRatio / rx-ry); legacy reads '
'svg_final/ (PPT-internal SVG parser fallback). '
'Pass output/final/<name> to force one source.')
help='Native SVG source directory. Default: svg_output/. '
'Pass output/final/<name> only for diagnostics.')
parser.add_argument('-f', '--format', type=str,
choices=list(CANVAS_FORMATS.keys()), default=None,
help='Specify canvas format')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
parser.add_argument('--no-compat', action='store_true',
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
mode_group.add_argument('--native', action='store_true', default=False,
help='(Deprecated, now default) Convert SVG to native DrawingML shapes')
merge_group = parser.add_mutually_exclusive_group()
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
help='Compatibility no-op: mergeable paragraph blocks are merged '
@@ -238,14 +234,6 @@ Recorded narration:
'flat leaves generated structure slide-local for debugging/comparison.'
),
)
parser.add_argument('--svg-snapshot', action='store_true', default=False,
help='Also emit the SVG-rendered snapshot pptx alongside '
'the native pptx in exports/ (named '
'<project>_<ts>_svg.pptx). Off by default — the '
'native pptx is the canonical output; live preview '
'already provides the SVG visual reference. '
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ '
'regardless of this flag.')
parser.add_argument('--no-image-optimize', action='store_true',
help='Disable native PPTX raster image optimization; embeds original image bytes.')
parser.add_argument('--image-max-dimension', type=int, default=2560,
@@ -264,12 +252,20 @@ Recorded narration:
number = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
if not math.isfinite(number):
raise argparse.ArgumentTypeError("must be finite")
if number < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return number
def positive_float(value: str) -> float:
number = non_negative_float(value)
if number <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return number
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
help='Page transition effect (default: fade, use "none" to disable)')
help='Page transition effect (default: fade; "none" removes visual motion)')
parser.add_argument('--transition-duration', type=non_negative_float, default=None,
help='Transition duration in seconds (default: 0.4)')
parser.add_argument('--auto-advance', type=non_negative_float, default=None,
@@ -284,7 +280,7 @@ Recorded narration:
'richer pool for visual variation, fallback cycles fade/'
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
'"random".')
parser.add_argument('--animation-duration', type=non_negative_float, default=None,
parser.add_argument('--animation-duration', type=positive_float, default=None,
help='Per-element entrance duration in seconds (default: 0.4)')
parser.add_argument('--animation-trigger', type=str,
choices=['on-click', 'with-previous', 'after-previous'],
@@ -311,23 +307,9 @@ Recorded narration:
help='Prepare PowerPoint recorded timings and narrations from a complete audio '
'directory. Default-flow exports get the _narrated name suffix '
'(<project>_<ts>_narrated.pptx) to tell them apart from silent exports.')
parser.add_argument('--narration-padding', type=float, default=0.5,
parser.add_argument('--narration-padding', type=non_negative_float, default=0.5,
help='Seconds to add after each narration before auto-advance (default: 0.5)')
parser.add_argument('--cache-dir', type=str, default=None,
help='Cache directory for SVG→PNG renders (default: '
'<project>/.cache/svg_png). Cache key uses SVG content '
'hash + size + renderer; safe across renderer switches. '
'Removed automatically after a successful export.')
parser.add_argument('--no-cache', action='store_true',
help='Disable the SVG→PNG cache for this run (still parallel).')
parser.add_argument('--keep-cache', action='store_true',
help='Keep the SVG→PNG cache directory after export '
'(default: removed on success to keep project clean).')
parser.add_argument('--workers', type=int, default=None,
help='Parallel workers for SVG→PNG pre-rendering. '
'Default: min(cpu, pages, 8). Set 1 for sequential.')
args = parser.parse_args(argv)
project_path = Path(args.project_path)
@@ -367,10 +349,13 @@ Recorded narration:
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'}:
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)
theme_color_spec = load_theme_color_spec(project_path)
except (ThemeFontError, ThemeColorError) as exc:
print(f"Error: {exc}", file=sys.stderr)
@@ -397,49 +382,17 @@ Recorded narration:
if canvas_format is None and detected_format and detected_format != 'unknown':
canvas_format = detected_format
# Determine which versions to generate.
# Default is native-only; SVG snapshot is opt-in via --svg-snapshot.
# --only native / --only legacy still force a single version explicitly.
only_mode = args.only
if only_mode == 'native':
gen_native, gen_legacy = True, False
elif only_mode == 'legacy':
gen_native, gen_legacy = False, True
else:
gen_native = True
gen_legacy = args.svg_snapshot
# Pipeline split: native pptx gets the high-fidelity svg_output/ source
# (icons, preserveAspectRatio, rounded-rect rx/ry are all preserved by the
# converter); legacy pptx still needs svg_final/ because PowerPoint's
# internal SVG parser cannot handle <use data-icon> or honour
# preserveAspectRatio. An explicit -s overrides both branches so callers
# can keep the previous single-source behaviour for unusual workflows.
explicit_source = args.source is not None
native_source = args.source if explicit_source else 'output'
legacy_source = args.source if explicit_source else 'final'
native_files: list[Path] = []
legacy_files: list[Path] = []
native_source_dir = ''
legacy_source_dir = ''
if gen_native:
native_files, native_source_dir = find_svg_files(project_path, native_source)
if gen_legacy:
legacy_files, legacy_source_dir = find_svg_files(project_path, legacy_source)
# Reference list for cross-product lookups (notes / narration matching).
# native_files and legacy_files share filenames because svg_final/ is
# copytree'd from svg_output/, so either list works for matching.
ref_files = native_files or legacy_files
if not ref_files:
# Native DrawingML is the only PPTX product. ``-s`` remains an explicit
# diagnostic source override; standard export always reads svg_output/.
native_source = args.source or 'output'
native_files, native_source_dir = find_svg_files(project_path, native_source)
ref_files = native_files
if not native_files:
print("Error: No SVG files found")
return 1
if (
gen_native
and pptx_structure in {'template', 'preserve'}
pptx_structure in {'template', 'preserve'}
and structure_lock is not None
):
try:
@@ -491,37 +444,24 @@ Recorded narration:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir: Path | None = None
legacy_path: Path | None = None
if args.output:
output_base = Path(args.output)
native_path = output_base
if gen_legacy:
stem = output_base.stem
legacy_path = output_base.parent / f"{stem}_svg{output_base.suffix}"
native_path = Path(args.output)
else:
exports_dir = project_path / "exports"
exports_dir.mkdir(parents=True, exist_ok=True)
# --native-objects yields a materially different file (real editable
# PowerPoint chart/table objects instead of flattened shapes), so mark
# it in the default-flow name to tell it apart from a plain shape export
# and the _svg snapshot. Narration flags likewise mark _narrated (audio
# embedded per slide + auto-advance timings); both entry points share
# the tag. Flag-driven (not content-sniffed) so the name is predictable;
# an explicit -o keeps the caller's exact name untouched.
# it in the default-flow name to tell it apart from a plain shape export.
# Narration flags likewise mark _narrated (audio embedded per slide +
# auto-advance timings). Flag-driven (not content-sniffed) so the name
# is predictable; an explicit -o keeps the caller's exact name untouched.
native_tag = "_native_charts" if args.native_objects else ""
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
# svg_output/ snapshot always goes under backup/<ts>/ in default-flow
# mode (no -o). --svg-snapshot only controls the optional legacy
# SVG-rendered pptx, which now sits alongside the native pptx in
# exports/ rather than nested inside backup/.
# Preserve the authored svg_output/ beside every default-flow export.
backup_dir = project_path / "backup" / timestamp
if gen_legacy:
legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"
native_path.parent.mkdir(parents=True, exist_ok=True)
if legacy_path is not None:
legacy_path.parent.mkdir(parents=True, exist_ok=True)
verbose = not args.quiet
@@ -620,15 +560,41 @@ Recorded narration:
except Exception as exc:
print(f"Error: Failed to load animation config: {exc}")
return 1
config_errors: list[str] = []
if animation_config:
config_errors.extend(validate_transition_config(animation_config))
config_errors.extend(validate_animation_config_errors(animation_config))
config_errors = list(dict.fromkeys(config_errors))
if config_errors:
for error in config_errors:
print(f"Error: {error}", file=sys.stderr)
return 1
config_warnings: list[str] = []
if animation_config:
reference_messages = validate_animation_config(project_path, animation_config)
config_warnings = [
message for message in reference_messages
if ' has no id and cannot be customized in animations.json' in message
]
reference_errors = [
message for message in reference_messages
if message not in config_warnings
]
if reference_errors:
for error in reference_errors:
print(f"Error: {error}", file=sys.stderr)
return 1
if animation_config and verbose:
config_label = args.animation_config or str(project_path / 'animations.json')
print(f" Animation config: {config_label}")
for warning in validate_animation_config(project_path, animation_config):
for warning in config_warnings:
print(f" [warn] {warning}")
defaults = animation_config.get('defaults', {}) if animation_config else {}
transition_defaults = defaults.get('transition', {}) if isinstance(defaults, dict) else {}
animation_defaults = defaults.get('animation', {}) if isinstance(defaults, dict) else {}
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
animation_defaults = _as_dict(defaults.get('animation')) if isinstance(defaults, dict) else {}
transition_arg = args.transition
transition_effect = (
@@ -637,37 +603,77 @@ Recorded narration:
else transition_defaults.get('effect', 'fade')
)
transition = None if transition_effect == 'none' else transition_effect
transition_duration = (
args.transition_duration
if args.transition_duration is not None
else float(transition_defaults.get('duration', 0.4))
)
try:
transition_duration = validate_seconds(
(
args.transition_duration
if args.transition_duration is not None
else transition_defaults.get('duration', 0.4)
),
"transition duration",
allow_zero=transition is None,
)
auto_advance = (
args.auto_advance
if args.auto_advance is not None
else transition_defaults.get('auto_advance')
)
if auto_advance is not None:
auto_advance = validate_seconds(
auto_advance,
"transition auto_advance",
allow_zero=True,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
animation_arg = args.animation
animation_effect = (
animation_arg
if animation_arg is not None
# Per-element entrance is opt-in by default: auto-firing element builds
# read as the "AI deck" tell and were unsolicited. Page transitions stay
# on (see transition default above). Re-enable with -a auto / animations.json.
else animation_defaults.get('effect', 'none')
)
animation = None if animation_effect == 'none' else animation_effect
animation_duration = (
args.animation_duration
if args.animation_duration is not None
else float(animation_defaults.get('duration', 0.4))
)
animation_stagger = (
args.animation_stagger
if args.animation_stagger is not None
else float(animation_defaults.get('stagger', 0.5))
)
animation_trigger = (
args.animation_trigger
if args.animation_trigger is not None
else animation_defaults.get('trigger', 'after-previous')
)
try:
animation_effect = (
args.animation
if args.animation is not None
# Per-element entrance is opt-in by default: auto-firing element builds
# read as the "AI deck" tell and were unsolicited. Page transitions stay
# on (see transition default above). Re-enable with -a auto / animations.json.
else animation_defaults.get('effect', 'none')
)
animation = normalize_animation_effect(animation_effect)
animation_duration = validate_seconds(
(
args.animation_duration
if args.animation_duration is not None
else animation_defaults.get('duration', 0.4)
),
"animation duration",
allow_zero=False,
)
animation_seconds_to_milliseconds(
animation_duration,
"animation duration",
allow_zero=False,
)
animation_stagger = validate_seconds(
(
args.animation_stagger
if args.animation_stagger is not None
else animation_defaults.get('stagger', 0.5)
),
"animation stagger",
allow_zero=True,
)
animation_seconds_to_milliseconds(
animation_stagger,
"animation stagger",
allow_zero=True,
)
animation_trigger = normalize_animation_trigger(
args.animation_trigger
if args.animation_trigger is not None
else animation_defaults.get('trigger', 'after-previous')
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
animation_cli_overrides = {
'transition': args.transition is not None,
@@ -679,7 +685,7 @@ Recorded narration:
'animation_trigger': args.animation_trigger is not None,
}
if args.recorded_narration and gen_native:
if args.recorded_narration:
on_click_slides = _recorded_narration_on_click_slides(
ref_files,
animation_config,
@@ -699,17 +705,6 @@ Recorded narration:
print(f" ... and {len(on_click_slides) - 20} more", file=sys.stderr)
return 1
if args.no_cache:
cache_dir: Path | None = None
elif args.cache_dir:
cache_dir = Path(args.cache_dir)
if not cache_dir.is_absolute():
cache_dir = project_path / cache_dir
else:
cache_dir = project_path / '.cache' / 'svg_png'
# svg_files is per-product (native vs legacy may now read different
# directories); everything else is shared.
# Optional per-project document properties. Absent file → factual fields
# are still stamped at export; only the authored fields stay blank.
doc_metadata = None
@@ -733,8 +728,7 @@ Recorded narration:
verbose=verbose,
transition=transition,
transition_duration=transition_duration,
auto_advance=args.auto_advance,
use_compat_mode=not args.no_compat,
auto_advance=auto_advance,
notes=notes,
enable_notes=enable_notes,
animation=animation,
@@ -746,8 +740,6 @@ Recorded narration:
narration_audio=narration_audio,
use_narration_timings=use_narration_timings,
narration_padding=args.narration_padding,
cache_dir=cache_dir,
workers=args.workers,
merge_paragraphs=args.merge_paragraphs,
image_optimize=not args.no_image_optimize,
image_max_dimension=args.image_max_dimension,
@@ -758,61 +750,36 @@ Recorded narration:
pptx_structure=pptx_structure,
native_structure_contract=native_structure_contract,
theme_font_spec=theme_font_spec,
master_text_style_spec=master_text_style_spec,
theme_color_spec=theme_color_spec,
)
success = True
if verbose:
print("PPT Master - SVG to native DrawingML PPTX Tool")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {native_source_dir}")
print(f" Output file: {native_path}")
print()
# --- Native shapes version (primary) ---
if gen_native:
if verbose:
print("PPT Master - SVG to PPTX Tool")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {native_source_dir}")
print(f" Output file: {native_path}")
print()
try:
ok = create_pptx_with_native_svg(
output_path=native_path,
use_native_shapes=True,
svg_files=native_files,
conversion_trace_path=(
native_path.with_name(native_path.name + '.trace.json')
if args.conversion_trace else None
),
**shared_kwargs,
)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
success = success and ok
# --- SVG image reference version ---
if gen_legacy:
if verbose:
if gen_native:
print()
print("-" * 50)
print("PPT Master - SVG to PPTX Tool (SVG Reference)")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {legacy_source_dir}")
print(f" Output file: {legacy_path}")
print()
ok = create_pptx_with_native_svg(
output_path=legacy_path,
use_native_shapes=False,
svg_files=legacy_files,
try:
success = create_pptx_with_native_svg(
output_path=native_path,
use_native_shapes=True,
svg_files=native_files,
conversion_trace_path=(
native_path.with_name(native_path.name + '.trace.json')
if args.conversion_trace else None
),
**shared_kwargs,
)
success = success and ok
except (TemplateStructureError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
# svg_output/ snapshot — runs once per export in default-flow mode,
# decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
# under backup/<ts>/svg_output/ for later inspection / re-export.
# Archive svg_output/ once per default-flow export. This preserves the
# authored SVG sources under backup/<ts>/svg_output/ for inspection and
# deterministic re-export.
if success and backup_dir is not None:
svg_output_src = project_path / "svg_output"
if svg_output_src.is_dir():
@@ -848,16 +815,6 @@ Recorded narration:
if verbose:
print(f" [warn] preserve contract backup skipped: {exc}")
if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
try:
shutil.rmtree(cache_dir)
cache_parent = cache_dir.parent
if cache_parent.is_dir() and cache_parent.name == '.cache' and not any(cache_parent.iterdir()):
cache_parent.rmdir()
except Exception as exc:
if verbose:
print(f" [warn] cache cleanup skipped: {exc}")
return 0 if success else 1
@@ -16,7 +16,7 @@ def find_svg_files(
project_path: Project directory path.
source: SVG source directory alias or name.
- 'output': svg_output (hand-authored source; native default)
- 'final': svg_final (post-processed; legacy SVG-image path source)
- 'final': svg_final (post-processed preview; diagnostic input)
- or any subdirectory name
Returns:
@@ -6,8 +6,25 @@ import base64
import json
import re
import subprocess
from collections.abc import Iterable
from pathlib import Path
from xml.etree import ElementTree as ET
from pptx_transitions import (
AdvanceUpdate,
EnterUpdate,
MAX_OOXML_UNSIGNED_INT,
P14_NS,
PML_NS,
apply_slide_motion_xml,
parse_source_xml,
read_slide_transition_xml,
serialize_source_xml,
)
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
@@ -36,6 +53,22 @@ AUDIO_MARKER_PNG_BYTES = base64.b64decode(
)
for _prefix, _uri in (
("p", PML_NS),
("a", DRAWINGML_NS),
("r", RELATIONSHIPS_NS),
("p14", P14_NS),
):
try:
ET.register_namespace(_prefix, _uri)
except (AttributeError, ValueError):
pass
def _qn(namespace: str, tag: str) -> str:
return f"{{{namespace}}}{tag}"
def _normalize_title(title: str) -> str:
text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
return re.sub(r"_+", "_", text).strip("_").lower()
@@ -102,8 +135,94 @@ def probe_audio_duration(audio_path: Path) -> float | None:
def next_shape_id(slide_xml: str) -> int:
ids = [int(value) for value in re.findall(r'<p:cNvPr[^>]*\sid="(\d+)"', slide_xml)]
return max(ids, default=1) + 1
"""Return the next slide-local non-visual shape id."""
root = parse_source_xml(slide_xml)
if root.tag != _qn(PML_NS, "sld"):
raise ValueError("narration source XML root must be p:sld")
ids = _numeric_ids(
root.iter(_qn(PML_NS, "cNvPr")),
"shape",
minimum=1,
)
next_id = max(ids, default=1) + 1
if next_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError("narration source has no available shape identifiers")
return next_id
def _create_audio_pic_element(
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
) -> ET.Element:
pic = ET.Element(_qn(PML_NS, "pic"))
nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
c_nv_pr = ET.SubElement(
nv_pic_pr,
_qn(PML_NS, "cNvPr"),
{"id": str(shape_id), "name": shape_name},
)
ET.SubElement(
c_nv_pr,
_qn(DRAWINGML_NS, "hlinkClick"),
{
_qn(RELATIONSHIPS_NS, "id"): "",
"action": "ppaction://media",
},
)
c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
ET.SubElement(
c_nv_pic_pr,
_qn(DRAWINGML_NS, "picLocks"),
{"noChangeAspect": "1"},
)
nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
ET.SubElement(
nv_pr,
_qn(DRAWINGML_NS, "audioFile"),
{_qn(RELATIONSHIPS_NS, "link"): audio_rid},
)
ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
extension = ET.SubElement(
ext_list,
_qn(PML_NS, "ext"),
{"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
)
ET.SubElement(
extension,
_qn(P14_NS, "media"),
{_qn(RELATIONSHIPS_NS, "embed"): media_rid},
)
blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
ET.SubElement(
blip_fill,
_qn(DRAWINGML_NS, "blip"),
{_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
)
stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
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, "ext"),
{
"cx": str(AUDIO_MARKER_SIZE_EMU),
"cy": str(AUDIO_MARKER_SIZE_EMU),
},
)
geometry = ET.SubElement(
shape_properties,
_qn(DRAWINGML_NS, "prstGeom"),
{"prst": "rect"},
)
ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
return pic
def create_audio_pic_xml(
@@ -114,51 +233,158 @@ def create_audio_pic_xml(
poster_rid: str,
) -> str:
"""Create a visible audio picture shape carrying narration media."""
return f'''<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="{shape_name}">
<a:hlinkClick r:id="" action="ppaction://media"/>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr>
<a:audioFile r:link="{audio_rid}"/>
<p:extLst>
<p:ext uri="{{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}}">
<p14:media xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" r:embed="{media_rid}"/>
</p:ext>
</p:extLst>
</p:nvPr>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{poster_rid}"/>
<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="{AUDIO_MARKER_SIZE_EMU}" cy="{AUDIO_MARKER_SIZE_EMU}"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
</p:pic>'''
element = _create_audio_pic_element(
shape_id,
shape_name,
audio_rid,
media_rid,
poster_rid,
)
return ET.tostring(element, encoding="unicode")
def _next_timing_id(slide_xml: str) -> int:
ids = [int(value) for value in re.findall(r'<p:cTn[^>]*\sid="(\d+)"', slide_xml)]
return max(ids, default=1) + 1
def _numeric_ids(
elements: Iterable[ET.Element],
label: str,
*,
minimum: int = 0,
maximum: int = MAX_OOXML_UNSIGNED_INT,
) -> list[int]:
ids: list[int] = []
seen: set[int] = set()
for element in elements:
raw_id = element.get("id")
try:
numeric_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
if numeric_id < minimum:
raise ValueError(
f"narration source has {label} id below {minimum}: {numeric_id}"
)
if numeric_id > maximum:
raise ValueError(
f"narration source has {label} id above {maximum}: {numeric_id}"
)
if numeric_id in seen:
raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
ids.append(numeric_id)
seen.add(numeric_id)
return ids
def _create_audio_timing_xml(shape_id: int, ctn_id: int) -> str:
return f'''<p:audio>
<p:cMediaNode vol="80000">
<p:cTn id="{ctn_id}" fill="hold" display="0">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
</p:cMediaNode>
</p:audio>'''
def _create_audio_timing_element(shape_id: int, ctn_id: int) -> ET.Element:
audio = ET.Element(_qn(PML_NS, "audio"))
media_node = ET.SubElement(
audio,
_qn(PML_NS, "cMediaNode"),
{"vol": "80000"},
)
time_node = ET.SubElement(
media_node,
_qn(PML_NS, "cTn"),
{"id": str(ctn_id), "fill": "hold", "display": "0"},
)
start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
ET.SubElement(start_conditions, _qn(PML_NS, "cond"), {"delay": "0"})
target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
return audio
def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
children = [child for child in parent if child.tag == tag]
if len(children) != 1:
raise ValueError(
f"narration source must contain exactly one direct {label}; found {len(children)}"
)
return children[0]
def _existing_timing_root(timing: ET.Element) -> ET.Element:
children = list(timing)
for tag, label in (
(_qn(PML_NS, "tnLst"), "p:tnLst"),
(_qn(PML_NS, "bldLst"), "p:bldLst"),
(_qn(PML_NS, "extLst"), "p:extLst"),
):
if sum(child.tag == tag for child in children) > 1:
raise ValueError(f"narration source timing has multiple {label} elements")
node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
node_index = children.index(node_list)
for tag, label in (
(_qn(PML_NS, "bldLst"), "p:bldLst"),
(_qn(PML_NS, "extLst"), "p:extLst"),
):
sibling = next((child for child in children if child.tag == tag), None)
if sibling is not None and node_index > children.index(sibling):
raise ValueError(f"narration source p:tnLst must precede {label}")
timing_roots = [
element
for element in node_list.iter(_qn(PML_NS, "cTn"))
if element.get("nodeType") == "tmRoot"
]
if len(timing_roots) != 1:
raise ValueError(
"narration source timing must contain exactly one tmRoot; "
f"found {len(timing_roots)}"
)
return timing_roots[0]
def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
timing = ET.Element(_qn(PML_NS, "timing"))
node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
timing_root = ET.SubElement(
parallel,
_qn(PML_NS, "cTn"),
{
"id": str(root_id),
"dur": "indefinite",
"restart": "never",
"nodeType": "tmRoot",
},
)
child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
child_nodes.append(audio_timing)
return timing
def _root_extension_index(slide: ET.Element) -> int | None:
extension_lists = [
index
for index, child in enumerate(slide)
if child.tag == _qn(PML_NS, "extLst")
]
if len(extension_lists) > 1:
raise ValueError("narration source has multiple root p:extLst elements")
if extension_lists and extension_lists[0] != len(slide) - 1:
raise ValueError("narration source root p:extLst is not the last slide child")
return extension_lists[0] if extension_lists else None
def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
children = list(slide)
timing_index = children.index(timing)
for tag, label in (
(_qn(PML_NS, "cSld"), "p:cSld"),
(_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
):
siblings = [index for index, child in enumerate(children) if child.tag == tag]
if len(siblings) > 1:
raise ValueError(f"narration source has multiple root {label} elements")
if siblings and siblings[0] > timing_index:
raise ValueError(f"narration source root p:timing must follow {label}")
extension_index = _root_extension_index(slide)
if extension_index is not None and timing_index > extension_index:
raise ValueError("narration source root p:timing must precede p:extLst")
def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
extension_index = _root_extension_index(slide)
insert_at = extension_index if extension_index is not None else len(slide)
slide.insert(insert_at, timing)
def inject_narration(
@@ -171,34 +397,75 @@ def inject_narration(
poster_rid: str,
) -> str:
"""Inject a hidden narration media shape and slide-entry autoplay timing."""
audio_pic_xml = create_audio_pic_xml(
shape_id=shape_id,
shape_name=shape_name,
audio_rid=audio_rid,
media_rid=media_rid,
poster_rid=poster_rid,
if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
raise ValueError("narration shape_id must be a positive integer")
if shape_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration shape_id exceeds the OOXML unsigned-integer limit: "
f"{shape_id}"
)
root = parse_source_xml(slide_xml)
if root.tag != _qn(PML_NS, "sld"):
raise ValueError("narration source XML root must be p:sld")
common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
shape_tree = _direct_child(
common_slide_data,
_qn(PML_NS, "spTree"),
"p:cSld/p:spTree",
)
slide_xml = slide_xml.replace("</p:spTree>", audio_pic_xml + "\n </p:spTree>", 1)
audio_timing_xml = _create_audio_timing_xml(shape_id, _next_timing_id(slide_xml))
if "<p:timing>" not in slide_xml:
timing_xml = f''' <p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
<p:childTnLst>
{audio_timing_xml}
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>'''
return slide_xml.replace("</p:sld>", timing_xml + "\n</p:sld>", 1)
shape_ids = _numeric_ids(
root.iter(_qn(PML_NS, "cNvPr")),
"shape",
minimum=1,
)
if shape_id in shape_ids:
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
timing_ids = _numeric_ids(root.iter(_qn(PML_NS, "cTn")), "timing node")
next_timing_id = max(timing_ids, default=0) + 1
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError("narration source has no available timing node identifiers")
pattern = re.compile(r'(<p:cTn\s+id="1"[^>]*>\s*<p:childTnLst>)', re.S)
if pattern.search(slide_xml):
return pattern.sub(r"\1\n " + audio_timing_xml, slide_xml, count=1)
return slide_xml.replace("</p:tnLst>", audio_timing_xml + "\n </p:tnLst>", 1)
root_timings = [child for child in root if child.tag == _qn(PML_NS, "timing")]
all_timings = list(root.iter(_qn(PML_NS, "timing")))
if len(all_timings) != len(root_timings):
raise ValueError(
"narration source contains non-root p:timing; only direct p:sld/p:timing "
"can be merged safely"
)
if len(root_timings) > 1:
raise ValueError(
f"narration source has multiple root p:timing elements: {len(root_timings)}"
)
if not root_timings and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration source has no identifiers available for a new timing root"
)
audio_picture = _create_audio_pic_element(
shape_id,
shape_name,
audio_rid,
media_rid,
poster_rid,
)
shape_tree.append(audio_picture)
if root_timings:
_validate_root_timing_position(root, root_timings[0])
timing_root = _existing_timing_root(root_timings[0])
child_nodes = _direct_child(
timing_root,
_qn(PML_NS, "childTnLst"),
"tmRoot/p:childTnLst",
)
child_nodes.append(_create_audio_timing_element(shape_id, next_timing_id))
else:
audio_timing = _create_audio_timing_element(shape_id, next_timing_id + 1)
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
return serialize_source_xml(root, slide_xml).decode("utf-8")
def apply_recorded_timing(
@@ -209,28 +476,20 @@ def apply_recorded_timing(
transition_effect: str | None = "fade",
) -> str:
"""Set slide auto-advance timing so exported video follows narration length."""
adv_ms = max(1, int(advance_after * 1000))
dur_ms = max(1, int(transition_duration * 1000))
transition_match = re.search(r"<p:transition\b[^>]*>", slide_xml)
if transition_match:
tag = transition_match.group(0)
is_self_closing = tag.rstrip().endswith("/>")
base_tag = tag.rstrip()
if is_self_closing:
base_tag = re.sub(r"\s*/>$", ">", base_tag, count=1)
if "advTm=" in base_tag:
new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', base_tag, count=1)
else:
new_tag = base_tag[:-1] + f' advTm="{adv_ms}">'
if is_self_closing:
new_tag = new_tag[:-1] + "/>"
return slide_xml[:transition_match.start()] + new_tag + slide_xml[transition_match.end():]
effect = transition_effect or "fade"
transition_xml = f''' <p:transition p14:dur="{dur_ms}" xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" advTm="{adv_ms}">
<p:{effect}/>
</p:transition>'''
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
summary = read_slide_transition_xml(slide_xml)
if summary.logical_count:
enter = EnterUpdate(policy="preserve")
elif transition_effect is None or transition_effect == "none":
enter = EnterUpdate(policy="none")
else:
enter = EnterUpdate(
policy="replace",
effect=transition_effect,
duration=transition_duration,
)
updated, _uses_timings = apply_slide_motion_xml(
slide_xml,
enter=enter,
advance=AdvanceUpdate(mode="narration", after=advance_after),
)
return updated
@@ -2,13 +2,7 @@
from __future__ import annotations
# Import animation module (optional)
try:
from pptx_animations import create_transition_xml, TRANSITIONS
ANIMATIONS_AVAILABLE = True
except ImportError:
ANIMATIONS_AVAILABLE = False
TRANSITIONS = {}
from pptx_transitions import TRANSITIONS, create_transition_xml
def create_slide_xml_with_svg(
@@ -36,12 +30,14 @@ def create_slide_xml_with_svg(
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
"""
transition_xml = ''
if transition and ANIMATIONS_AVAILABLE:
transition_xml = '\n' + create_transition_xml(
if transition is not None or auto_advance is not None:
transition_fragment = create_transition_xml(
effect=transition,
duration=transition_duration,
advance_after=auto_advance,
)
if transition_fragment:
transition_xml = '\n' + transition_fragment
if use_compat_mode:
blip_xml = f'''<a:blip r:embed="{png_rid}">
@@ -26,6 +26,11 @@ from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from ..geometry_properties import (
GeometryStyleError,
materialize_inline_geometry_properties,
)
_NON_VISUAL_TAGS = frozenset({"defs", "title", "desc", "metadata", "style"})
_STRUCTURE_ATTRS = frozenset({
@@ -51,6 +56,19 @@ _PLACEHOLDERS = frozenset({
"footer",
"slide-number",
})
TEMPLATE_PLACEHOLDER_TYPES = {
"title": "title",
"subtitle": "subTitle",
"body": "body",
"picture": "pic",
"chart": "chart",
"table": "tbl",
"object": "obj",
"media": "media",
"date": "dt",
"footer": "ftr",
"slide-number": "sldNum",
}
_TEXT_PLACEHOLDERS = frozenset({
"title",
"subtitle",
@@ -76,6 +94,7 @@ _LOCK_PAGE_RE = re.compile(r"^P(\d+)$")
PPTX_STRUCTURE_MODES = frozenset({"baseline", "template", "preserve", "flat"})
TEMPLATE_ADHERENCE_MODES = frozenset({"strict", "adaptive"})
NATIVE_STRUCTURE_SCHEMA = "ppt-master.native-structure.v1"
OOXML_UINT32_MAX = (1 << 32) - 1
class TemplateStructureError(RuntimeError):
@@ -199,6 +218,67 @@ class TemplateSlideSpec:
)
@dataclass(frozen=True)
class TemplatePlaceholderBinding:
"""Resolved PowerPoint identity for one template placeholder."""
element: TemplateElementSpec
placeholder_type: str
assigned_idx: int | None
@property
def effective_idx(self) -> int:
"""Return the OOXML idx value after applying its default of zero."""
return self.assigned_idx if self.assigned_idx is not None else 0
def template_placeholder_bindings(
spec: TemplateSlideSpec,
) -> tuple[TemplatePlaceholderBinding, ...]:
"""Assign deterministic, collision-free PowerPoint placeholder identities."""
next_idx = 1
used_indices: dict[int, str] = {}
bindings: list[TemplatePlaceholderBinding] = []
for item in spec.placeholders:
placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
if placeholder_type is None:
raise TemplateStructureError(
f"{spec.svg_path.name}: unsupported placeholder type "
f"{item.placeholder!r}"
)
if item.placeholder == "title" and item.placeholder_idx is None:
assigned_idx = None
else:
assigned_idx = (
item.placeholder_idx
if item.placeholder_idx is not None
else next_idx
)
effective_idx = assigned_idx if assigned_idx is not None else 0
if effective_idx > OOXML_UINT32_MAX:
raise TemplateStructureError(
f"{spec.svg_path.name}: layout {spec.layout_key!r} placeholder "
f"{item.element_id!r} idx exceeds the OOXML UInt32 maximum "
f"{OOXML_UINT32_MAX}"
)
previous = used_indices.get(effective_idx)
if previous is not None:
raise TemplateStructureError(
f"{spec.svg_path.name}: layout {spec.layout_key!r} gives "
f"placeholders {previous!r} and {item.element_id!r} the same "
f"effective idx {effective_idx}; omitted idx defaults to 0 in OOXML"
)
used_indices[effective_idx] = item.element_id
if assigned_idx is not None:
next_idx = max(next_idx, assigned_idx + 1)
bindings.append(TemplatePlaceholderBinding(
element=item,
placeholder_type=placeholder_type,
assigned_idx=assigned_idx,
))
return tuple(bindings)
def _local_tag(elem: ET.Element) -> str:
return elem.tag.rsplit("}", 1)[-1] if isinstance(elem.tag, str) else ""
@@ -656,7 +736,13 @@ def _parse_placeholder_idx(
f"{svg_path.name}: {element_id} data-pptx-placeholder-idx must be "
"a non-negative integer"
)
return int(value)
parsed = int(value)
if parsed > OOXML_UINT32_MAX:
raise TemplateStructureError(
f"{svg_path.name}: {element_id} data-pptx-placeholder-idx must be "
f"at most {OOXML_UINT32_MAX}"
)
return parsed
def _validate_placeholder_element(
@@ -705,6 +791,13 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec:
f"{svg_path.name}: unable to parse SVG structure metadata: {exc}"
) from exc
try:
materialize_inline_geometry_properties(root)
except GeometryStyleError as exc:
raise TemplateStructureError(
f"{svg_path.name}: invalid inline geometry: {exc}"
) from exc
if _local_tag(root) != "svg":
raise TemplateStructureError(f"{svg_path.name}: root element must be <svg>")
@@ -933,6 +1026,7 @@ def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]:
by_layout.setdefault(spec.layout_key, []).append(spec)
for layout_key, layout_specs in by_layout.items():
prototype = layout_specs[0]
template_placeholder_bindings(prototype)
for spec in layout_specs[1:]:
if spec.layout_name != prototype.layout_name:
raise TemplateStructureError(
@@ -0,0 +1,793 @@
#!/usr/bin/env python3
"""
PPT Master - Template Package Validation
Read a generated template-mode PPTX back and verify its reusable Layout graph.
Usage:
Imported by svg_to_pptx.pptx_package.builder.
Examples:
validate_pptx_template_package(Path("output.pptx"), template_specs)
Dependencies:
None (only uses standard library and local modules)
"""
from __future__ import annotations
import posixpath
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlsplit
from xml.etree import ElementTree as ET
if __name__ == "__main__":
if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]):
print(__doc__)
raise SystemExit(0)
print(
"Use this validator through the template-mode SVG-to-PPTX exporter.",
file=sys.stderr,
)
raise SystemExit(1)
from ..drawingml.utils import EMU_PER_PX
from .template_structure import (
OOXML_UINT32_MAX,
TemplatePlaceholderBinding,
TemplateSlideSpec,
TemplateStructureError,
template_placeholder_bindings,
)
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
)
SLIDE_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
)
SLIDE_LAYOUT_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
)
PRESENTATION_COLLECTION_ID_MIN = 1 << 31
@dataclass(frozen=True)
class _Relationship:
rel_id: str
rel_type: str
target: str
target_mode: str | None
@dataclass(frozen=True)
class _Placeholder:
shape: ET.Element
placeholder_type: str
raw_type: str | None
idx: int
raw_idx: int | None
class _PackageReader:
"""Cache package XML while accumulating deterministic read-back errors."""
def __init__(self, package: zipfile.ZipFile, errors: list[str]) -> None:
self.package = package
self.errors = errors
self.names = frozenset(package.namelist())
self._xml_cache: dict[str, ET.Element | None] = {}
self._rels_cache: dict[str, tuple[_Relationship, ...]] = {}
def xml(self, part: str) -> ET.Element | None:
if part in self._xml_cache:
return self._xml_cache[part]
if part not in self.names:
self.errors.append(f"missing package part {part}")
self._xml_cache[part] = None
return None
try:
root = ET.fromstring(self.package.read(part))
except (KeyError, ET.ParseError) as exc:
self.errors.append(f"{part} is not valid XML: {exc}")
root = None
self._xml_cache[part] = root
return root
def relationships(self, source_part: str) -> tuple[_Relationship, ...]:
if source_part in self._rels_cache:
return self._rels_cache[source_part]
rels_part = _relationships_part_for(source_part)
root = self.xml(rels_part)
relationships: list[_Relationship] = []
seen_ids: set[str] = set()
if root is not None:
for elem in root:
attrs = {
key.rsplit("}", 1)[-1]: value
for key, value in elem.attrib.items()
}
rel_id = attrs.get("Id", "")
rel_type = attrs.get("Type", "")
target = attrs.get("Target", "")
if not rel_id or not rel_type or not target:
self.errors.append(
f"{rels_part} contains a relationship without Id, Type, or Target"
)
continue
if rel_id in seen_ids:
self.errors.append(f"{rels_part} repeats relationship id {rel_id}")
continue
seen_ids.add(rel_id)
relationships.append(_Relationship(
rel_id=rel_id,
rel_type=rel_type,
target=target,
target_mode=attrs.get("TargetMode"),
))
result = tuple(relationships)
self._rels_cache[source_part] = result
return result
def _relationships_part_for(source_part: str) -> str:
directory = posixpath.dirname(source_part)
filename = posixpath.basename(source_part)
return posixpath.join(directory, "_rels", f"{filename}.rels")
def _resolve_relationship_target(
source_part: str,
relationship: _Relationship,
errors: list[str],
context: str,
) -> str | None:
target_mode = (relationship.target_mode or "Internal").lower()
if target_mode != "internal":
errors.append(f"{context} relationship must be internal")
return None
raw_target = relationship.target
if "\\" in raw_target or any(ord(char) <= 0x20 for char in raw_target):
errors.append(f"{context} has invalid target {raw_target!r}")
return None
try:
parsed = urlsplit(raw_target)
except ValueError:
errors.append(f"{context} has invalid target {raw_target!r}")
return None
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
errors.append(f"{context} has invalid target {raw_target!r}")
return None
decoded = unquote(parsed.path)
if not decoded:
errors.append(f"{context} has an empty target")
return None
if decoded.startswith("/"):
candidate = decoded[1:]
else:
candidate = posixpath.join(posixpath.dirname(source_part), decoded)
normalized = posixpath.normpath(candidate)
if (
normalized in {"", ".", ".."}
or normalized.startswith("../")
or normalized.startswith("/")
):
errors.append(f"{context} target escapes the package: {raw_target!r}")
return None
return normalized
def _single_relationship_target(
reader: _PackageReader,
source_part: str,
rel_type: str,
context: str,
) -> tuple[_Relationship, str] | None:
matches = [
relationship
for relationship in reader.relationships(source_part)
if relationship.rel_type == rel_type
]
if len(matches) != 1:
reader.errors.append(
f"{context} must have exactly one {rel_type.rsplit('/', 1)[-1]} "
f"relationship, found {len(matches)}"
)
return None
target = _resolve_relationship_target(
source_part,
matches[0],
reader.errors,
context,
)
if target is None:
return None
return matches[0], target
def _placeholder_for_shape(shape: ET.Element) -> ET.Element | None:
paths = {
f"{{{PML_NS}}}sp": (
f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
),
f"{{{PML_NS}}}pic": (
f"{{{PML_NS}}}nvPicPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
),
f"{{{PML_NS}}}graphicFrame": (
f"{{{PML_NS}}}nvGraphicFramePr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
),
}
path = paths.get(shape.tag)
return shape.find(path) if path else None
def _read_placeholders(
root: ET.Element,
context: str,
errors: list[str],
) -> dict[int, _Placeholder]:
sp_tree = root.find(f"{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
if sp_tree is None:
errors.append(f"{context} has no p:cSld/p:spTree")
return {}
placeholders: dict[int, _Placeholder] = {}
for shape in sp_tree:
ph = _placeholder_for_shape(shape)
if ph is None:
continue
raw_idx_value = ph.get("idx")
try:
raw_idx = int(raw_idx_value) if raw_idx_value is not None else None
except ValueError:
errors.append(f"{context} contains invalid placeholder idx {raw_idx_value!r}")
continue
idx = raw_idx if raw_idx is not None else 0
if not 0 <= idx <= OOXML_UINT32_MAX:
errors.append(
f"{context} placeholder idx {idx} is outside the OOXML UInt32 range"
)
continue
if idx in placeholders:
errors.append(f"{context} repeats effective placeholder idx {idx}")
continue
raw_type = ph.get("type")
placeholders[idx] = _Placeholder(
shape=shape,
placeholder_type=raw_type or "obj",
raw_type=raw_type,
idx=idx,
raw_idx=raw_idx,
)
return placeholders
def _validate_placeholder_roster(
root: ET.Element,
bindings: tuple[TemplatePlaceholderBinding, ...],
context: str,
errors: list[str],
*,
is_layout: bool,
) -> dict[int, _Placeholder]:
actual = _read_placeholders(root, context, errors)
expected = {binding.effective_idx: binding for binding in bindings}
if set(actual) != set(expected):
errors.append(
f"{context} placeholder idx roster is {sorted(actual)}, "
f"expected {sorted(expected)}"
)
for idx in sorted(set(actual).intersection(expected)):
placeholder = actual[idx]
binding = expected[idx]
if placeholder.placeholder_type != binding.placeholder_type:
errors.append(
f"{context} placeholder idx {idx} has type "
f"{placeholder.placeholder_type!r}, expected "
f"{binding.placeholder_type!r}"
)
if binding.assigned_idx is None:
if placeholder.raw_idx is not None:
errors.append(
f"{context} title placeholder must use the omitted idx=0 form"
)
elif placeholder.raw_idx != binding.assigned_idx:
errors.append(
f"{context} placeholder idx {idx} must serialize explicit idx "
f"{binding.assigned_idx}"
)
if is_layout and placeholder.raw_type is None:
errors.append(
f"{context} Layout placeholder idx {idx} must serialize its type"
)
if (
not is_layout
and binding.placeholder_type != "obj"
and placeholder.raw_type is None
):
errors.append(
f"{context} Slide placeholder idx {idx} must serialize type "
f"{binding.placeholder_type!r}"
)
return actual
def _validate_layout_header_footer(
root: ET.Element,
bindings: tuple[TemplatePlaceholderBinding, ...],
context: str,
errors: list[str],
) -> None:
placeholder_roles = {
binding.element.placeholder for binding in bindings
}
expected = {
"hdr": False,
"dt": "date" in placeholder_roles,
"ftr": "footer" in placeholder_roles,
"sldNum": "slide-number" in placeholder_roles,
}
header_footer = root.find(f"{{{PML_NS}}}hf")
if header_footer is None:
if any(expected.values()):
errors.append(
f"{context} has date/footer/slide-number placeholders but no p:hf"
)
return
for attr, expected_value in expected.items():
# CT_HeaderFooter boolean attributes default to true when omitted.
raw_value = header_footer.get(attr, "1").lower()
if raw_value not in {"0", "1", "false", "true"}:
errors.append(f"{context} p:hf@{attr} is not a valid boolean")
continue
actual_value = raw_value in {"1", "true"}
if actual_value != expected_value:
errors.append(
f"{context} p:hf@{attr} is {raw_value!r}, expected "
f"{'1' if expected_value else '0'}"
)
def _shape_bounds(
shape: ET.Element,
context: str,
errors: list[str],
) -> tuple[int, int, int, int] | None:
if shape.tag == f"{{{PML_NS}}}graphicFrame":
xfrm = shape.find(f"{{{PML_NS}}}xfrm")
else:
xfrm = shape.find(f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm")
if xfrm is None:
errors.append(f"{context} has no direct placeholder transform")
return None
off = xfrm.find(f"{{{DML_NS}}}off")
ext = xfrm.find(f"{{{DML_NS}}}ext")
if off is None or ext is None:
errors.append(f"{context} placeholder transform has no a:off/a:ext")
return None
try:
bounds = (
int(off.attrib["x"]),
int(off.attrib["y"]),
int(ext.attrib["cx"]),
int(ext.attrib["cy"]),
)
except (KeyError, ValueError):
errors.append(f"{context} placeholder transform is invalid")
return None
if bounds[2] <= 0 or bounds[3] <= 0:
errors.append(f"{context} placeholder width/height must be positive")
return bounds
def _direct_text_body(shape: ET.Element) -> ET.Element | None:
return shape.find(f"{{{PML_NS}}}txBody")
def _first_run_size(shape: ET.Element) -> str | None:
text_body = _direct_text_body(shape)
if text_body is None:
return None
run_props = text_body.find(f".//{{{DML_NS}}}rPr")
return run_props.get("sz") if run_props is not None else None
def _level_one_default_size(shape: ET.Element) -> str | None:
text_body = _direct_text_body(shape)
if text_body is None:
return None
default_props = text_body.find(
f"{{{DML_NS}}}lstStyle/"
f"{{{DML_NS}}}lvl1pPr/"
f"{{{DML_NS}}}defRPr"
)
return default_props.get("sz") if default_props is not None else None
def _content_type_overrides(
reader: _PackageReader,
) -> dict[str, list[str]]:
root = reader.xml("[Content_Types].xml")
overrides: dict[str, list[str]] = {}
if root is None:
return overrides
for elem in root:
if elem.tag.rsplit("}", 1)[-1] != "Override":
continue
part_name = elem.get("PartName", "")
content_type = elem.get("ContentType", "")
if part_name and content_type:
overrides.setdefault(part_name, []).append(content_type)
return overrides
def _validate_uint32_id_roster(
elements: list[ET.Element],
context: str,
errors: list[str],
*,
min_value: int = 0,
) -> set[int]:
values: set[int] = set()
for element in elements:
raw_value = element.get("id")
try:
value = int(raw_value) if raw_value is not None else -1
except ValueError:
value = -1
if not min_value <= value <= OOXML_UINT32_MAX:
errors.append(
f"{context} contains id {raw_value!r} outside the OOXML range "
f"{min_value}..{OOXML_UINT32_MAX}"
)
continue
if value in values:
errors.append(f"{context} repeats numeric id {value}")
continue
values.add(value)
return values
def _validate_registered_part_roster(
reader: _PackageReader,
source_part: str,
rel_type: str,
entries: list[ET.Element],
expected_targets: set[str],
context: str,
) -> set[int]:
relationships = [
relationship
for relationship in reader.relationships(source_part)
if relationship.rel_type == rel_type
]
targets_by_id: dict[str, str] = {}
ids_by_target: dict[str, list[str]] = {}
for relationship in relationships:
target = _resolve_relationship_target(
source_part,
relationship,
reader.errors,
context,
)
if target is None:
continue
targets_by_id[relationship.rel_id] = target
ids_by_target.setdefault(target, []).append(relationship.rel_id)
for target, rel_ids in sorted(ids_by_target.items()):
if len(rel_ids) > 1:
reader.errors.append(
f"{context} targets {target} through multiple relationships: "
+ ", ".join(rel_ids)
)
actual_targets = set(targets_by_id.values())
if actual_targets != expected_targets:
missing = sorted(expected_targets - actual_targets)
extra = sorted(actual_targets - expected_targets)
reader.errors.append(
f"{context} registered target roster differs; missing={missing}, "
f"extra={extra}"
)
entry_rel_ids: list[str] = []
for entry in entries:
rel_id = entry.get(f"{{{REL_NS}}}id")
if not rel_id:
reader.errors.append(f"{context} contains an entry without r:id")
continue
entry_rel_ids.append(rel_id)
if len(entry_rel_ids) != len(set(entry_rel_ids)):
reader.errors.append(f"{context} repeats an entry r:id")
if set(entry_rel_ids) != {relationship.rel_id for relationship in relationships}:
reader.errors.append(
f"{context} entry r:id roster does not match its relationships"
)
return _validate_uint32_id_roster(
entries,
context,
reader.errors,
min_value=PRESENTATION_COLLECTION_ID_MIN,
)
def _validate_presentation_master_registration(
reader: _PackageReader,
master_parts: set[str],
) -> None:
presentation_part = "ppt/presentation.xml"
presentation_root = reader.xml(presentation_part)
if presentation_root is None:
return
master_id_entries = presentation_root.findall(
f"{{{PML_NS}}}sldMasterIdLst/{{{PML_NS}}}sldMasterId"
)
_validate_registered_part_roster(
reader,
presentation_part,
SLIDE_MASTER_REL_TYPE,
master_id_entries,
master_parts,
"Presentation p:sldMasterIdLst",
)
def validate_pptx_template_package(
pptx_path: str | Path,
specs: list[TemplateSlideSpec],
) -> None:
"""Validate a finished template-mode PPTX against its explicit SVG contract."""
if not specs:
raise ValueError("template package validation requires at least one slide spec")
specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
bindings_by_layout: dict[str, tuple[TemplatePlaceholderBinding, ...]] = {}
try:
for spec in specs:
specs_by_layout.setdefault(spec.layout_key, []).append(spec)
for layout_key, layout_specs in specs_by_layout.items():
bindings_by_layout[layout_key] = template_placeholder_bindings(
layout_specs[0]
)
except TemplateStructureError as exc:
raise ValueError(str(exc)) from exc
errors: list[str] = []
path = Path(pptx_path)
try:
with zipfile.ZipFile(path) as package:
reader = _PackageReader(package, errors)
overrides = _content_type_overrides(reader)
layout_parts_by_key: dict[str, str] = {}
keys_by_layout_part: dict[str, str] = {}
slide_roots: dict[int, ET.Element] = {}
for spec in specs:
slide_part = f"ppt/slides/slide{spec.slide_num}.xml"
slide_root = reader.xml(slide_part)
if slide_root is not None:
slide_roots[spec.slide_num] = slide_root
relationship_target = _single_relationship_target(
reader,
slide_part,
SLIDE_LAYOUT_REL_TYPE,
f"Slide {spec.slide_num}",
)
if relationship_target is None:
continue
_relationship, layout_part = relationship_target
if not (
layout_part.startswith("ppt/slideLayouts/")
and layout_part.endswith(".xml")
):
errors.append(
f"Slide {spec.slide_num} targets non-Layout part {layout_part}"
)
continue
reader.xml(layout_part)
previous_part = layout_parts_by_key.setdefault(
spec.layout_key,
layout_part,
)
if previous_part != layout_part:
errors.append(
f"layout key {spec.layout_key!r} targets both {previous_part} "
f"and {layout_part}"
)
previous_key = keys_by_layout_part.setdefault(
layout_part,
spec.layout_key,
)
if previous_key != spec.layout_key:
errors.append(
f"layout keys {previous_key!r} and {spec.layout_key!r} both "
f"target {layout_part}"
)
used_master_parts: set[str] = set()
layout_parts_by_master: dict[str, set[str]] = {}
for layout_key, layout_specs in specs_by_layout.items():
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")
continue
layout_root = reader.xml(layout_part)
if layout_root is None:
continue
if layout_root.tag != f"{{{PML_NS}}}sldLayout":
errors.append(f"{layout_part} is not a p:sldLayout part")
if layout_root.get("type") != "cust":
errors.append(f"{layout_part} must have type='cust'")
if layout_root.get("preserve") != "1":
errors.append(f"{layout_part} must have preserve='1'")
if layout_root.get("showMasterSp", "1").lower() not in {
"1",
"true",
}:
errors.append(f"{layout_part} must show its Master shape tree")
c_sld = layout_root.find(f"{{{PML_NS}}}cSld")
expected_name = layout_specs[0].layout_name
actual_name = c_sld.get("name") if c_sld is not None else None
if actual_name != expected_name:
errors.append(
f"{layout_part} has picker name {actual_name!r}, expected "
f"{expected_name!r}"
)
override_values = overrides.get(f"/{layout_part}", [])
if override_values != [SLIDE_LAYOUT_CONTENT_TYPE]:
errors.append(
f"[Content_Types].xml must declare {layout_part} exactly once "
f"as {SLIDE_LAYOUT_CONTENT_TYPE}"
)
bindings = bindings_by_layout[layout_key]
layout_placeholders = _validate_placeholder_roster(
layout_root,
bindings,
f"Layout {layout_key!r}",
errors,
is_layout=True,
)
_validate_layout_header_footer(
layout_root,
bindings,
f"Layout {layout_key!r}",
errors,
)
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
slide_placeholders[spec.slide_num] = _validate_placeholder_roster(
slide_root,
bindings,
f"Slide {spec.slide_num}",
errors,
is_layout=False,
)
prototype = layout_specs[0]
prototype_placeholders = slide_placeholders.get(
prototype.slide_num,
{},
)
for binding in bindings:
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:
continue
context = (
f"Layout {layout_key!r} placeholder "
f"{binding.element.element_id!r}"
)
actual_bounds = _shape_bounds(
layout_placeholder.shape,
context,
errors,
)
if binding.element.placeholder_bounds is not None:
expected_bounds = tuple(
round(value * EMU_PER_PX)
for value in binding.element.placeholder_bounds
)
else:
expected_bounds = _shape_bounds(
prototype_placeholder.shape,
f"Slide {prototype.slide_num} placeholder "
f"{binding.element.element_id!r}",
errors,
)
if (
actual_bounds is not None
and expected_bounds is not None
and actual_bounds != expected_bounds
):
errors.append(
f"{context} bounds are {actual_bounds}, expected "
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)
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}"
)
master_target = _single_relationship_target(
reader,
layout_part,
SLIDE_MASTER_REL_TYPE,
f"Layout {layout_key!r}",
)
if master_target is None:
continue
_relationship, master_part = master_target
if not (
master_part.startswith("ppt/slideMasters/")
and master_part.endswith(".xml")
):
errors.append(
f"Layout {layout_key!r} targets non-Master part {master_part}"
)
continue
reader.xml(master_part)
used_master_parts.add(master_part)
layout_parts_by_master.setdefault(master_part, set()).add(
layout_part
)
layout_id_owners: dict[int, str] = {}
for master_part, layout_parts in sorted(layout_parts_by_master.items()):
master_root = reader.xml(master_part)
if master_root is None:
continue
layout_id_entries = master_root.findall(
f"{{{PML_NS}}}sldLayoutIdLst/"
f"{{{PML_NS}}}sldLayoutId"
)
layout_ids = _validate_registered_part_roster(
reader,
master_part,
SLIDE_LAYOUT_REL_TYPE,
layout_id_entries,
layout_parts,
f"Master {master_part} p:sldLayoutIdLst",
)
for layout_id in sorted(layout_ids):
previous_owner = layout_id_owners.setdefault(
layout_id,
master_part,
)
if previous_owner != master_part:
errors.append(
f"Slide Layout numeric id {layout_id} appears in both "
f"{previous_owner} and {master_part}"
)
_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}")
@@ -1,4 +1,4 @@
"""In-memory expansion of ``<use data-icon="lib/name">`` elements.
"""In-memory expansion of project icons and static local ``<use>`` elements.
The icon placeholder ``<use data-icon="...">`` is a project-internal SVG
extension; standard renderers (browsers, PowerPoint's SVG parser) and our
@@ -12,6 +12,11 @@ Public API:
Walk the SVG element tree, replace every ``<use data-icon="...">``
with its expanded ``<g>`` group of primitive shapes, and return
the number of replacements made.
expand_local_use_references(root) -> int
Materialize same-document ``href="#id"`` references, including
``<symbol>`` viewBox mapping, and return the number of instances.
expand_local_use_references_in_file(svg_path) -> int
Apply the same expansion to an SVG file in place.
The heavy lifting (icon resolution, color application, scaling) is
delegated to ``svg_finalize.embed_icons`` so the two pipelines stay
@@ -20,12 +25,440 @@ behaviourally aligned.
from __future__ import annotations
import copy
import math
import re
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
_LOCAL_HREF_RE = re.compile(r'^#([^#\s]+)$')
_LENGTH_RE = re.compile(
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(?:px)?\s*$'
)
_VIEWBOX_SPLIT_RE = re.compile(r'[\s,]+')
_URL_REF_RE = re.compile(r'''url\(#([^#\s()'"\\]+)\)''')
_URL_FUNCTION_RE = re.compile(r'\burl\s*\([^)]*\)', re.IGNORECASE)
_URL_FUNCTION_START_RE = re.compile(r'\burl\s*\(', re.IGNORECASE)
_MAX_LOCAL_USE_DEPTH = 64
_MAX_LOCAL_USE_INSTANCES = 10_000
_NON_REUSABLE_METADATA_PREFIXES = (
'data-pptx-layer',
'data-pptx-native',
'data-pptx-placeholder',
)
_REFERENCE_TAGS = frozenset({
'symbol', 'g', 'use',
'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',
'text', 'image',
})
class UseExpansionError(ValueError):
"""Raised when a static local ``<use>`` cannot be expanded safely."""
def _local_tag(elem: ET.Element) -> str:
"""Return an ElementTree node's local tag name."""
return elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
def _qualified_tag(elem: ET.Element, local: str) -> str:
"""Return ``local`` in the same namespace as ``elem``."""
tag = str(elem.tag)
if tag.startswith('{') and '}' in tag:
return f'{tag.split("}", 1)[0]}}}{local}'
return local
def _fmt_number(value: float) -> str:
"""Format a finite SVG transform number without negative zero."""
if abs(value) < 1e-12:
value = 0.0
return f'{value:.12g}'
def _numeric_length(
elem: ET.Element,
name: str,
default: float | None = None,
) -> float | None:
"""Parse a unitless/px local-use geometry value."""
raw = elem.get(name)
if raw is None:
return default
match = _LENGTH_RE.fullmatch(raw)
if match is None:
raise UseExpansionError(
f'<use> {name} must be a finite unitless or px value, got {raw!r}'
)
value = float(match.group(1))
if not math.isfinite(value):
raise UseExpansionError(f'<use> {name} must be finite, got {raw!r}')
return value
def _parse_viewbox(symbol: ET.Element) -> tuple[float, float, float, float]:
"""Parse a positive four-number symbol viewBox."""
raw = symbol.get('viewBox', '')
parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw.strip()) if part]
if len(parts) != 4:
raise UseExpansionError(
'<symbol> referenced by <use> must define a four-number viewBox'
)
try:
values = tuple(float(part) for part in parts)
except ValueError as exc:
raise UseExpansionError(
f'<symbol> viewBox contains a non-numeric value: {raw!r}'
) from exc
if not all(math.isfinite(value) for value in values):
raise UseExpansionError(f'<symbol> viewBox must be finite, got {raw!r}')
min_x, min_y, width, height = values
if width <= 0 or height <= 0:
raise UseExpansionError(
f'<symbol> viewBox width/height must be positive, got {raw!r}'
)
return min_x, min_y, width, height
def _symbol_viewport_transform(symbol: ET.Element, use_elem: ET.Element) -> str:
"""Map one symbol viewBox into the use element's explicit viewport."""
for attr in ('refX', 'refY'):
if symbol.get(attr) is not None:
raise UseExpansionError(
f'<symbol> {attr} is not supported by local <use> expansion'
)
min_x, min_y, view_width, view_height = _parse_viewbox(symbol)
width = _numeric_length(use_elem, 'width')
height = _numeric_length(use_elem, 'height')
if width is None or height is None or width <= 0 or height <= 0:
raise UseExpansionError(
'<use> referencing <symbol> requires positive numeric width and height'
)
raw_aspect = symbol.get('preserveAspectRatio', 'xMidYMid meet').strip()
parts = raw_aspect.split()
if parts and parts[0] == 'defer':
parts.pop(0)
align = parts[0] if parts else 'xMidYMid'
mode = parts[1] if len(parts) > 1 else 'meet'
if len(parts) > 2 or mode not in {'meet', 'slice'}:
raise UseExpansionError(
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
)
if align == 'none':
if len(parts) > 1:
raise UseExpansionError(
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
)
scale_x = width / view_width
scale_y = height / view_height
translate_x = -min_x * scale_x
translate_y = -min_y * scale_y
else:
alignments = {
'xMinYMin': (0.0, 0.0), 'xMidYMin': (0.5, 0.0), 'xMaxYMin': (1.0, 0.0),
'xMinYMid': (0.0, 0.5), 'xMidYMid': (0.5, 0.5), 'xMaxYMid': (1.0, 0.5),
'xMinYMax': (0.0, 1.0), 'xMidYMax': (0.5, 1.0), 'xMaxYMax': (1.0, 1.0),
}
if align not in alignments:
raise UseExpansionError(
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
)
if mode == 'slice':
raise UseExpansionError(
'symbol preserveAspectRatio="... slice" requires viewport clipping, '
'which local <use> expansion does not approximate'
)
scale = min(width / view_width, height / view_height)
scale_x = scale_y = scale
align_x, align_y = alignments[align]
translate_x = (width - view_width * scale) * align_x - min_x * scale
translate_y = (height - view_height * scale) * align_y - min_y * scale
return (
f'matrix({_fmt_number(scale_x)} 0 0 {_fmt_number(scale_y)} '
f'{_fmt_number(translate_x)} {_fmt_number(translate_y)})'
)
class _LocalUseExpander:
"""Materialize static same-document SVG use references."""
def __init__(self, root: ET.Element):
self.root = root
self.targets: dict[str, ET.Element] = {}
self.duplicate_ids: set[str] = set()
self.used_ids: set[str] = set()
self.instance_index = 0
self.instances_started = 0
self.expanded = 0
for elem in root.iter():
elem_id = elem.get('id')
if not elem_id:
continue
self.used_ids.add(elem_id)
if elem_id in self.targets:
self.duplicate_ids.add(elem_id)
else:
self.targets[elem_id] = elem
def expand(self) -> int:
"""Expand every visible non-data-icon use node in the document."""
self._expand_children(self.root, ())
return self.expanded
def _expand_children(self, parent: ET.Element, stack: tuple[str, ...]) -> None:
for index, child in enumerate(list(parent)):
if _local_tag(child) == 'defs':
continue
if _local_tag(child) == 'use' and not child.get('data-icon'):
replacement = self._materialize_use(child, stack)
parent.remove(child)
parent.insert(index, replacement)
continue
self._expand_children(child, stack)
def _materialize_use(
self,
use_elem: ET.Element,
stack: tuple[str, ...],
) -> ET.Element:
self.instances_started += 1
if self.instances_started > _MAX_LOCAL_USE_INSTANCES:
raise UseExpansionError(
'Local <use> expansion exceeds the 10000-instance safety limit'
)
href = use_elem.get('href')
xlink_href = use_elem.get(f'{{{XLINK_NS}}}href')
if href is not None and xlink_href is not None:
if href != xlink_href:
raise UseExpansionError(
'Conflicting href and xlink:href values on local <use>'
)
if href is None:
href = xlink_href
match = _LOCAL_HREF_RE.fullmatch(href or '')
if match is None:
raise UseExpansionError(
'<use> must reference a same-document fragment with href="#id"; '
f'got {href!r}'
)
ref_id = match.group(1)
if len(stack) >= _MAX_LOCAL_USE_DEPTH:
chain = ' -> '.join((*stack, ref_id))
raise UseExpansionError(
f'Local <use> expansion exceeds the 64-reference depth limit: {chain}'
)
if ref_id in self.duplicate_ids:
raise UseExpansionError(
f'<use href="#{ref_id}"> is ambiguous because the id is duplicated'
)
use_id = use_elem.get('id')
if use_id and use_id in self.duplicate_ids:
raise UseExpansionError(
f'Local <use> instance id {use_id!r} is duplicated in this SVG'
)
target = self.targets.get(ref_id)
if target is None:
raise UseExpansionError(
f'<use href="#{ref_id}"> has no matching element in this SVG'
)
if ref_id in stack:
chain = ' -> '.join((*stack, ref_id))
raise UseExpansionError(f'Circular local <use> reference: {chain}')
target_tag = _local_tag(target)
if target_tag not in _REFERENCE_TAGS:
raise UseExpansionError(
f'<use href="#{ref_id}"> references unsupported <{target_tag}>'
)
self._reject_structural_metadata(use_elem, 'instance')
self._reject_structural_metadata(target, f'target #{ref_id}')
self._validate_fragment_reference_syntax(use_elem, 'instance')
self._validate_fragment_reference_syntax(target, f'target #{ref_id}')
target_ids = {
elem_id
for elem in target.iter()
if (elem_id := elem.get('id'))
}
ambiguous_ids = sorted(target_ids & self.duplicate_ids)
if ambiguous_ids:
joined = ', '.join(ambiguous_ids)
raise UseExpansionError(
f'<use href="#{ref_id}"> references a subtree with duplicate id(s): '
f'{joined}'
)
next_stack = (*stack, ref_id)
target_clone = copy.deepcopy(target)
if target_tag == 'use':
clone = self._materialize_use(target_clone, next_stack)
else:
clone = target_clone
if target_tag == 'symbol':
clone.tag = _qualified_tag(clone, 'g')
viewport_transform = _symbol_viewport_transform(target, use_elem)
existing_transform = clone.get('transform', '').strip()
clone.set(
'transform',
f'{existing_transform} {viewport_transform}'.strip(),
)
for attr in ('viewBox', 'preserveAspectRatio', 'x', 'y', 'width', 'height'):
clone.attrib.pop(attr, None)
self._expand_children(clone, next_stack)
self._rewrite_clone_ids(clone, self._next_instance_prefix(clone))
wrapper = self._build_wrapper(use_elem)
wrapper.append(clone)
self.expanded += 1
return wrapper
@staticmethod
def _reject_structural_metadata(elem: ET.Element, label: str) -> None:
"""Reject reusable template/native markers parsed before expansion."""
for candidate in elem.iter():
for attr in candidate.attrib:
if attr.startswith(_NON_REUSABLE_METADATA_PREFIXES):
raise UseExpansionError(
f'Local <use> {label} cannot carry structural {attr} metadata'
)
def _validate_fragment_reference_syntax(
self,
elem: ET.Element,
label: str,
) -> None:
"""Reject URL fragment forms the clone rewriter cannot preserve."""
for candidate in elem.iter():
for value in candidate.attrib.values():
starts = list(_URL_FUNCTION_START_RE.finditer(value))
if not starts:
continue
functions = list(_URL_FUNCTION_RE.finditer(value))
if len(functions) != len(starts):
raise UseExpansionError(
f'Local <use> {label} has malformed url(...) reference {value!r}'
)
for function in functions:
raw = function.group(0)
match = _URL_REF_RE.fullmatch(raw)
if match is None:
raise UseExpansionError(
f'Local <use> {label} requires exact url(#id) fragments; '
f'got {raw!r}'
)
ref_id = match.group(1)
if ref_id in self.duplicate_ids:
raise UseExpansionError(
f'Local <use> {label} has ambiguous url(#{ref_id}); '
'the referenced id is duplicated'
)
if ref_id not in self.targets:
raise UseExpansionError(
f'Local <use> {label} has unresolved url(#{ref_id})'
)
def _next_instance_prefix(self, clone: ET.Element) -> str:
"""Reserve a deterministic clone prefix that cannot collide."""
clone_ids = {
elem_id
for elem in clone.iter()
if (elem_id := elem.get('id'))
}
while True:
self.instance_index += 1
prefix = f'use-instance-{self.instance_index}-'
generated_ids = {f'{prefix}{elem_id}' for elem_id in clone_ids}
if not generated_ids & self.used_ids:
self.used_ids.update(generated_ids)
return prefix
@staticmethod
def _rewrite_clone_ids(clone: ET.Element, prefix: str) -> None:
"""Make materialized IDs instance-local and rewrite fragment refs."""
id_map: dict[str, str] = {}
for elem in clone.iter():
elem_id = elem.get('id')
if elem_id:
id_map[elem_id] = f'{prefix}{elem_id}'
if not id_map:
return
for elem in clone.iter():
elem_id = elem.get('id')
if elem_id in id_map:
elem.set('id', id_map[elem_id])
for attr, value in list(elem.attrib.items()):
if attr in {'href', f'{{{XLINK_NS}}}href'} and value.startswith('#'):
ref_id = value[1:]
if ref_id in id_map:
elem.set(attr, f'#{id_map[ref_id]}')
continue
rewritten = _URL_REF_RE.sub(
lambda match: f'url(#{id_map.get(match.group(1), match.group(1))})',
value,
)
if rewritten != value:
elem.set(attr, rewritten)
@staticmethod
def _build_wrapper(use_elem: ET.Element) -> ET.Element:
"""Create an inheriting group for use styles and instance geometry."""
wrapper = ET.Element(_qualified_tag(use_elem, 'g'))
skipped = {
'href', f'{{{XLINK_NS}}}href',
'x', 'y', 'width', 'height', 'preserveAspectRatio', 'transform',
}
for attr, value in use_elem.attrib.items():
if attr not in skipped:
wrapper.set(attr, value)
x = _numeric_length(use_elem, 'x', 0.0) or 0.0
y = _numeric_length(use_elem, 'y', 0.0) or 0.0
transforms = []
use_transform = use_elem.get('transform', '').strip()
if use_transform:
transforms.append(use_transform)
if x or y:
transforms.append(f'translate({_fmt_number(x)} {_fmt_number(y)})')
if transforms:
wrapper.set('transform', ' '.join(transforms))
return wrapper
def expand_local_use_references(root: ET.Element) -> int:
"""Expand static same-document ``<use href="#id">`` references."""
return _LocalUseExpander(root).expand()
def validate_local_use_references(root: ET.Element) -> list[str]:
"""Return expansion errors without mutating the caller's SVG tree."""
try:
expand_local_use_references(copy.deepcopy(root))
except UseExpansionError as exc:
return [str(exc)]
return []
def expand_local_use_references_in_file(svg_path: Path) -> int:
"""Expand local use references in one SVG file in place."""
tree = ET.parse(str(svg_path))
count = expand_local_use_references(tree.getroot())
if count:
ET.register_namespace('', SVG_NS)
ET.register_namespace('xlink', XLINK_NS)
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
return count
def _import_embed_icons():
@@ -1,4 +1,4 @@
"""analyze: read a PPTX as a reusable slide library of text / table / chart slots."""
"""Analyze a PPTX as reusable text, table, chart, and SmartArt source facts."""
from __future__ import annotations
@@ -8,6 +8,7 @@ from typing import Any
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 .ooxml import (
CHART_REL_TYPE,
NS,
@@ -143,26 +144,36 @@ def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]:
}
def _fill_risk(tables: list[dict[str, Any]], charts: list[dict[str, Any]]) -> dict[str, Any] | None:
def _fill_risk(
tables: list[dict[str, Any]],
charts: list[dict[str, Any]],
diagrams: list[dict[str, Any]],
) -> dict[str, Any] | None:
"""Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace.
Only tables and charts are checked; no new dependencies are introduced.
Returns None when the slide has no such content.
Tables and charts may be covered by explicit edits. SmartArt is inventory-only:
template-fill preserves it unchanged, so its source text may show through.
"""
kinds: list[str] = []
if tables:
kinds.append("table")
if charts:
kinds.append("chart")
if diagrams:
kinds.append("smartart")
if not kinds:
return None
kind_str = "/".join(kinds)
guidance: list[str] = []
if tables or charts:
guidance.append("cover tables/charts with explicit edits")
if diagrams:
guidance.append("review preserved SmartArt source text")
guidance_text = "; ".join(guidance)
return {
"has_non_text_content": True,
"reason": (
f"has non-text content ({kind_str}) that text-fill cannot replace; "
"provide table_edits/chart_edits or pick another source slide"
),
"kinds": kinds,
"reason": f"has non-text content ({kind_str}) that text-fill does not replace automatically; {guidance_text}",
}
@@ -202,7 +213,16 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
tables = _analyze_tables(slide_root, slide_ref.index)
charts = _analyze_charts(zf, slide_root, slide_ref)
slide_text = "\n".join(slot["text"] for slot in slots if slot["text"])
diagrams = read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index)
slide_text = "\n".join(
[slot["text"] for slot in slots if slot["text"]]
+ [
str(text)
for diagram in diagrams
for text in diagram.get("text_items", [])
if text
]
)
slide: dict[str, Any] = {
"slide_index": slide_ref.index,
"page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots),
@@ -210,8 +230,9 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
"slots": slots,
"tables": tables,
"charts": charts,
"diagrams": diagrams,
}
risk = _fill_risk(tables, charts)
risk = _fill_risk(tables, charts, diagrams)
if risk is not None:
slide["fill_risk"] = risk
slides.append(slide)
@@ -6,11 +6,23 @@ and rebuilds the presentation slide list, relationships, and content types.
from __future__ import annotations
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from pptx_animations import (
object_animation_fingerprint,
validate_pptx_animation_package,
)
from pptx_transitions import (
parse_source_xml,
serialize_source_xml,
set_package_use_timings,
validate_pptx_transition_package,
)
from .chart_fill import (
_apply_chart_edits_to_slide_package,
_max_chart_part_number,
@@ -76,6 +88,7 @@ def apply_plan(
next_chart_number = _max_chart_part_number(entries)
next_embedding_number = _max_embedding_part_number(entries)
allocate_part = _make_part_allocator(entries)
wrote_auto_advance = False
for offset, item in enumerate(plan_slides):
source_slide = int(item.get("source_slide", 0))
@@ -87,7 +100,11 @@ def apply_plan(
new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels"
new_rid = f"rId{next_rel_number + offset}"
slide_root = ET.fromstring(entries[source_ref.part_name])
source_slide_xml = entries[source_ref.part_name]
source_animation_fingerprint = object_animation_fingerprint(
source_slide_xml
)
slide_root = parse_source_xml(source_slide_xml)
replacements = item.get("replacements", [])
if not isinstance(replacements, list):
raise RuntimeError(f"Slide {source_slide} replacements must be a list")
@@ -109,12 +126,14 @@ def apply_plan(
default_effect=transition,
default_duration=transition_duration,
)
_set_slide_transition(
slide_has_auto_advance = _set_slide_transition(
slide_root,
effect=slide_effect,
duration=slide_duration,
advance_after=slide_advance,
)
if slide_advance is not None and slide_has_auto_advance:
wrote_auto_advance = True
source_rels = entries.get(source_ref.rels_name)
slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root()
@@ -139,7 +158,21 @@ def apply_plan(
next_chart_number=next_chart_number,
next_embedding_number=next_embedding_number,
)
entries[new_part] = _xml_bytes(slide_root)
try:
serialized_slide = serialize_source_xml(
slide_root,
source_slide_xml,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
if (
object_animation_fingerprint(serialized_slide)
!= source_animation_fingerprint
):
raise RuntimeError(
f'Slide {source_slide} object animations changed during template fill'
)
entries[new_part] = serialized_slide
notes_text = str(item.get("notes") or item.get("speaker_notes") or "")
entries[new_rels], note_entries = _slide_rels_with_notes(
_xml_bytes(slide_rels_root),
@@ -171,8 +204,41 @@ def apply_plan(
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root)
_prune_unreferenced_parts(entries, content_root)
entries["[Content_Types].xml"] = _xml_bytes(content_root)
if wrote_auto_advance:
try:
set_package_use_timings(entries)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as out:
for name, data in entries.items():
out.writestr(name, data)
with tempfile.TemporaryDirectory(
prefix="template-fill-pptx-",
dir=output_path.parent,
) as temp_dir:
candidate_path = Path(temp_dir) / output_path.name
with zipfile.ZipFile(
candidate_path,
"w",
compression=zipfile.ZIP_DEFLATED,
) as out:
for name, data in entries.items():
out.writestr(name, data)
try:
validate_pptx_transition_package(
candidate_path,
require_use_timings=wrote_auto_advance,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX transition package validation failed: {exc}"
) from exc
try:
validate_pptx_animation_package(
candidate_path,
require_supported_effects=False,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX animation/timing package validation failed: {exc}"
) from exc
candidate_path.replace(output_path)
@@ -1,4 +1,4 @@
"""check-plan: compare planned text / table / chart edits against source capacity."""
"""Check planned text/table/chart edits and preserved SmartArt source risks."""
from __future__ import annotations
@@ -463,9 +463,8 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
}
)
# --- Guardrail 2: source slides with non-text content not covered by edits ---
# For each plan slide, if the source slide has tables/charts in the library
# but the plan slide provides no matching table_edits/chart_edits, warn that
# text-fill will silently leave the original template content in place.
# Tables/charts may be covered by explicit edits. SmartArt is preserve-only,
# so selecting a source slide that contains it always needs a content review.
lib_slides = _library_slide_index(library)
for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
source_slide = int(slide.get("source_slide", 0))
@@ -474,7 +473,8 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
continue
lib_tables = lib_slide.get("tables", [])
lib_charts = lib_slide.get("charts", [])
if not lib_tables and not lib_charts:
lib_diagrams = lib_slide.get("diagrams", [])
if not lib_tables and not lib_charts and not lib_diagrams:
continue
# Check whether the plan slide provides edits covering the non-text content.
has_table_edits = bool(slide.get("table_edits"))
@@ -484,9 +484,16 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
uncovered_kinds.append("table")
if lib_charts and not has_chart_edits:
uncovered_kinds.append("chart")
if lib_diagrams:
uncovered_kinds.append("smartart")
if not uncovered_kinds:
continue
kind_str = "/".join(uncovered_kinds)
guidance = "add table_edits/chart_edits, or pick another source slide"
if lib_diagrams:
guidance = "SmartArt remains unchanged, so pick another source slide or accept this warning"
if any(kind in uncovered_kinds for kind in ("table", "chart")):
guidance = f"add table/chart edits where supported; {guidance}"
summary["warn"] += 1
results.append(
{
@@ -494,11 +501,16 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
"code": "non_text_content_unedited",
"plan_slide": plan_slide_index,
"source_slide": source_slide,
"uncovered_kinds": uncovered_kinds,
"diagram_ids": [
diagram.get("diagram_id")
for diagram in lib_diagrams
if diagram.get("diagram_id")
],
"message": (
f"source slide {source_slide} has non-text content ({kind_str}) "
"with no matching edits in the plan; text-fill leaves it untouched "
"and original template content may show through "
"(add table_edits/chart_edits, or pick another source slide)"
"outside the plan's supported edits; template-fill leaves it untouched "
f"and original template content may show through ({guidance})"
),
}
)
@@ -0,0 +1,569 @@
"""Read SmartArt content and structure from DrawingML diagram parts.
The reader exposes source facts only. It does not edit DiagramML or promise
native SmartArt regeneration; generated decks continue to redraw the extracted
content through the SVG-to-DrawingML shape pipeline.
"""
from __future__ import annotations
import zipfile
from typing import Any
from xml.etree import ElementTree as ET
from .ooxml import (
MC_NS,
NS,
_container_geometry,
_normalize_part,
_paragraph_texts,
_qn,
_read_xml,
_rels_name_for_part,
_shape_identity,
_slide_relationships,
)
DIAGRAM_NS = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
DIAGRAM_DRAWING_NS = "http://schemas.microsoft.com/office/drawing/2008/diagram"
DIAGRAM_URI = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
_DIAGRAM_REL_SUFFIXES = {
"colors": "diagramColors",
"data": "diagramData",
"layout": "diagramLayout",
"quick_style": "diagramQuickStyle",
}
_REL_ATTRS = {
"colors": "cs",
"data": "dm",
"layout": "lo",
"quick_style": "qs",
}
_CONTENT_POINT_TYPES = {"asst", "node"}
DIAGRAM_NS_MAP = {
**NS,
"dgm": DIAGRAM_NS,
"dsp": DIAGRAM_DRAWING_NS,
"mc": MC_NS,
}
def _relationship_matches(rel_type: str, suffix: str) -> bool:
return rel_type.rsplit("/", 1)[-1] == suffix
def _read_optional_xml(
zf: zipfile.ZipFile,
part_name: str | None,
) -> tuple[ET.Element | None, str | None]:
if not part_name:
return None, "missing-part-reference"
try:
return _read_xml(zf, part_name), None
except RuntimeError:
return None, "missing-part"
except ET.ParseError:
return None, "invalid-xml"
def _diagram_containers(slide_root: ET.Element) -> list[ET.Element]:
containers: list[ET.Element] = []
for frame in slide_root.findall(".//p:graphicFrame", DIAGRAM_NS_MAP):
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
if graphic_data is not None and graphic_data.attrib.get("uri") == DIAGRAM_URI:
containers.append(frame)
return containers
def _fallback_preview_shape_ids(slide_root: ET.Element) -> set[str]:
shape_ids: set[str] = set()
for alternate in slide_root.findall(".//mc:AlternateContent", DIAGRAM_NS_MAP):
frame = alternate.find("mc:Choice//p:graphicFrame", DIAGRAM_NS_MAP)
if frame is None:
continue
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
if graphic_data is None or graphic_data.attrib.get("uri") != DIAGRAM_URI:
continue
if alternate.find("mc:Fallback//p:pic", DIAGRAM_NS_MAP) is None:
continue
shape_id, _shape_name = _shape_identity(frame, len(shape_ids) + 1)
shape_ids.add(shape_id)
return shape_ids
def _diagram_parts(
rel_ids: ET.Element | None,
relationships: dict[str, dict[str, str]],
slide_part: str,
) -> tuple[dict[str, str], str | None]:
if rel_ids is None:
return {}, "missing-rel-ids"
parts: dict[str, str] = {}
data_error: str | None = None
for key, attr_name in _REL_ATTRS.items():
rel_id = rel_ids.attrib.get(_qn(NS["r"], attr_name), "")
if not rel_id:
if key == "data":
data_error = "missing-data-relationship"
continue
relationship = relationships.get(rel_id)
if relationship is None:
if key == "data":
data_error = "missing-data-relationship"
continue
if not _relationship_matches(
relationship.get("type", ""),
_DIAGRAM_REL_SUFFIXES[key],
):
if key == "data":
data_error = "invalid-data-relationship"
continue
parts[key] = _normalize_part(relationship["target"], slide_part)
return parts, data_error
def _persisted_drawing_part(
zf: zipfile.ZipFile,
data_part: str | None,
data_root: ET.Element | None,
slide_part: str,
slide_relationships: dict[str, dict[str, str]],
) -> tuple[str | None, list[str]]:
if not data_part or data_root is None:
return None, []
data_model_ext = data_root.find(".//dsp:dataModelExt", DIAGRAM_NS_MAP)
rel_id = data_model_ext.attrib.get("relId", "") if data_model_ext is not None else ""
if not rel_id:
return None, []
warnings: list[str] = []
relationship = slide_relationships.get(rel_id)
relationship_owner = slide_part
if relationship is not None and not _relationship_matches(
relationship.get("type", ""),
"diagramDrawing",
):
relationship = None
if relationship is None:
try:
data_relationships = _slide_relationships(zf, _rels_name_for_part(data_part))
except ET.ParseError:
data_relationships = {}
warnings.append("invalid-persisted-drawing-relationships")
relationship = data_relationships.get(rel_id)
relationship_owner = data_part
if relationship is not None and not _relationship_matches(
relationship.get("type", ""),
"diagramDrawing",
):
relationship = None
if relationship is None:
warnings.append("unresolved-persisted-drawing-relationship")
return None, warnings
part_name = _normalize_part(relationship["target"], relationship_owner)
if part_name not in zf.namelist():
warnings.append("missing-persisted-drawing-part")
return None, warnings
return part_name, warnings
def _layout_info(
layout_root: ET.Element | None,
data_root: ET.Element | None,
) -> dict[str, Any]:
unique_id = layout_root.attrib.get("uniqueId") if layout_root is not None else None
if not unique_id and data_root is not None:
document_properties = data_root.find(
".//dgm:pt[@type='doc']/dgm:prSet",
DIAGRAM_NS_MAP,
)
if document_properties is not None:
unique_id = document_properties.attrib.get("loTypeId")
title = layout_root.find("dgm:title", DIAGRAM_NS_MAP) if layout_root is not None else None
name = title.attrib.get("val") if title is not None else None
if not name and unique_id:
name = unique_id.rstrip("/").rsplit("/", 1)[-1]
categories = (
[
category.attrib["type"]
for category in layout_root.findall("dgm:catLst/dgm:cat", DIAGRAM_NS_MAP)
if category.attrib.get("type")
]
if layout_root is not None
else []
)
return {
"name": name,
"unique_id": unique_id,
"categories": categories,
}
def _integer_or_none(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def _point_text(point: ET.Element) -> str:
text_body = point.find("dgm:t", DIAGRAM_NS_MAP)
if text_body is None:
return ""
return "\n".join(_paragraph_texts(text_body)).strip()
def _connections(data_root: ET.Element) -> list[dict[str, Any]]:
connections: list[dict[str, Any]] = []
for connection in data_root.findall(
".//dgm:cxnLst/dgm:cxn",
DIAGRAM_NS_MAP,
):
source_id = connection.attrib.get("srcId", "")
destination_id = connection.attrib.get("destId", "")
if not source_id or not destination_id:
continue
connections.append(
{
"type": connection.attrib.get("type") or "parOf",
"source_id": source_id,
"destination_id": destination_id,
"source_order": _integer_or_none(connection.attrib.get("srcOrd")),
"destination_order": _integer_or_none(connection.attrib.get("destOrd")),
}
)
return connections
def _nearest_content_parent(
node_id: str,
parent_by_id: dict[str, str],
content_ids: set[str],
) -> str | None:
current = parent_by_id.get(node_id)
visited = {node_id}
while current:
if current in content_ids:
return current
if current in visited:
return None
visited.add(current)
current = parent_by_id.get(current)
return None
def _break_parent_cycles(
parent_by_node: dict[str, str | None],
source_order: dict[str, int],
) -> list[str]:
"""Break cycles in the Markdown tree projection while retaining raw connections."""
warnings: list[str] = []
for start_id in sorted(parent_by_node, key=lambda node_id: source_order[node_id]):
path: list[str] = []
path_index: dict[str, int] = {}
current_id: str | None = start_id
while current_id is not None and current_id in parent_by_node:
if current_id in path_index:
cycle = path[path_index[current_id] :]
root_id = min(cycle, key=lambda node_id: source_order[node_id])
parent_by_node[root_id] = None
warnings.append(f"parent-cycle-broken-at:{root_id}")
break
path_index[current_id] = len(path)
path.append(current_id)
current_id = parent_by_node.get(current_id)
return warnings
def _ordered_nodes(
data_root: ET.Element,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
raw_points: list[dict[str, Any]] = []
for source_index, point in enumerate(
data_root.findall(".//dgm:ptLst/dgm:pt", DIAGRAM_NS_MAP),
):
model_id = point.attrib.get("modelId", "")
point_type = point.attrib.get("type") or "node"
text = _point_text(point)
if not model_id or point_type not in _CONTENT_POINT_TYPES:
continue
raw_points.append(
{
"id": model_id,
"type": point_type,
"text": text,
"source_index": source_index,
}
)
all_connections = _connections(data_root)
parent_by_id: dict[str, str] = {}
order_by_id: dict[str, int | None] = {}
for connection in all_connections:
if connection["type"] != "parOf":
continue
destination_id = connection["destination_id"]
parent_by_id.setdefault(destination_id, connection["source_id"])
order_by_id.setdefault(destination_id, connection["source_order"])
content_ids = {point["id"] for point in raw_points}
source_order = {point["id"]: int(point["source_index"]) for point in raw_points}
parent_by_node = {
point["id"]: _nearest_content_parent(
point["id"],
parent_by_id,
content_ids,
)
for point in raw_points
}
structure_warnings = _break_parent_cycles(parent_by_node, source_order)
nodes_by_id: dict[str, dict[str, Any]] = {}
for point in raw_points:
node = {
"id": point["id"],
"type": point["type"],
"text": point["text"],
"parent_id": parent_by_node[point["id"]],
"order": order_by_id.get(point["id"]),
"depth": 0,
"_source_index": point["source_index"],
}
nodes_by_id[point["id"]] = node
children: dict[str | None, list[dict[str, Any]]] = {}
for node in nodes_by_id.values():
children.setdefault(node["parent_id"], []).append(node)
def sort_key(node: dict[str, Any]) -> tuple[int, int]:
order = node["order"]
return (
order if isinstance(order, int) else 1_000_000,
int(node["_source_index"]),
)
for siblings in children.values():
siblings.sort(key=sort_key)
ordered: list[dict[str, Any]] = []
visited: set[str] = set()
def visit(node: dict[str, Any], depth: int) -> None:
node_id = str(node["id"])
if node_id in visited:
return
visited.add(node_id)
node["depth"] = depth
ordered.append(node)
for child in children.get(node_id, []):
visit(child, depth + 1)
for root in children.get(None, []):
visit(root, 0)
for node in sorted(nodes_by_id.values(), key=sort_key):
if node["id"] not in visited:
visit(node, 0)
for node in ordered:
node.pop("_source_index", None)
visible_connections = [
connection
for connection in all_connections
if connection["type"] == "parOf"
and connection["source_id"] in content_ids
and connection["destination_id"] in content_ids
]
return ordered, visible_connections, structure_warnings
def _read_diagram_container(
zf: zipfile.ZipFile,
container: ET.Element,
*,
slide_part: str,
slide_index: int,
order: int,
relationships: dict[str, dict[str, str]],
relationship_error: str | None,
fallback_shape_ids: set[str],
) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(container, order)
graphic_data = container.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
rel_ids = (
graphic_data.find("dgm:relIds", DIAGRAM_NS_MAP)
if graphic_data is not None
else None
)
parts, diagram_relation_error = _diagram_parts(
rel_ids,
relationships,
slide_part,
)
data_root, data_error = _read_optional_xml(zf, parts.get("data"))
layout_root, layout_error = _read_optional_xml(zf, parts.get("layout"))
metadata_warnings = [f"layout:{layout_error}"] if layout_error else []
nodes: list[dict[str, Any]] = []
connections: list[dict[str, Any]] = []
structure_warnings: list[str] = []
if data_root is not None:
nodes, connections, structure_warnings = _ordered_nodes(data_root)
status = relationship_error or diagram_relation_error or data_error
if status is None and structure_warnings:
status = "structure-cycle"
persisted_drawing, drawing_warnings = _persisted_drawing_part(
zf,
parts.get("data"),
data_root,
slide_part,
relationships,
)
text_items = [node["text"] for node in nodes if node["text"]]
return {
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"layout": _layout_info(layout_root, data_root),
"root_ids": [node["id"] for node in nodes if node["parent_id"] is None],
"nodes": nodes,
"connections": connections,
"text_items": text_items,
"node_count": len(nodes),
"text_count": len(text_items),
"connection_count": len(connections),
"max_depth": max((int(node["depth"]) for node in nodes), default=0),
"text_extracted": data_root is not None,
"has_persisted_drawing": persisted_drawing is not None,
"has_fallback_preview": shape_id in fallback_shape_ids,
"status": status or "ok",
"warnings": metadata_warnings + structure_warnings + drawing_warnings,
}
def _failed_diagram(
container: ET.Element,
*,
slide_index: int,
order: int,
error: Exception,
fallback_shape_ids: set[str],
) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(container, order)
return {
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"layout": {"name": None, "unique_id": None, "categories": []},
"root_ids": [],
"nodes": [],
"connections": [],
"text_items": [],
"node_count": 0,
"text_count": 0,
"connection_count": 0,
"max_depth": 0,
"text_extracted": False,
"has_persisted_drawing": False,
"has_fallback_preview": shape_id in fallback_shape_ids,
"status": "diagram-read-error",
"warnings": [f"{type(error).__name__}:{error}"],
}
def read_smartart_diagrams(
zf: zipfile.ZipFile,
slide_part: str,
slide_index: int,
) -> list[dict[str, Any]]:
"""Return SmartArt source facts for one slide part."""
slide_root = _read_xml(zf, slide_part)
relationship_error: str | None = None
try:
relationships = _slide_relationships(zf, _rels_name_for_part(slide_part))
except ET.ParseError:
relationships = {}
relationship_error = "invalid-slide-relationships"
fallback_shape_ids = _fallback_preview_shape_ids(slide_root)
diagrams: list[dict[str, Any]] = []
for order, container in enumerate(_diagram_containers(slide_root), start=1):
try:
diagram = _read_diagram_container(
zf,
container,
slide_part=slide_part,
slide_index=slide_index,
order=order,
relationships=relationships,
relationship_error=relationship_error,
fallback_shape_ids=fallback_shape_ids,
)
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError, KeyError, ValueError) as exc:
diagram = _failed_diagram(
container,
slide_index=slide_index,
order=order,
error=exc,
fallback_shape_ids=fallback_shape_ids,
)
diagrams.append(diagram)
return diagrams
def smartart_to_markdown(diagram: dict[str, Any]) -> str:
"""Render one extracted SmartArt diagram as hierarchical Markdown."""
name = str(diagram.get("shape_name") or diagram.get("diagram_id") or "SmartArt")
layout = diagram.get("layout") or {}
layout_name = str(layout.get("name") or "") if isinstance(layout, dict) else ""
heading = f"### SmartArt: {name}"
if layout_name and layout_name.lower() not in name.lower():
heading += f"{layout_name}"
lines = [heading, ""]
nodes = diagram.get("nodes") or []
text_nodes = [node for node in nodes if str(node.get("text") or "").strip()]
if text_nodes:
nodes_by_id = {
str(node.get("id")): node
for node in nodes
if node.get("id") is not None
}
rendered_depths: dict[str, int] = {}
def rendered_depth(node: dict[str, Any]) -> int:
node_id = str(node.get("id") or "")
if node_id in rendered_depths:
return rendered_depths[node_id]
parent = nodes_by_id.get(str(node.get("parent_id") or ""))
if parent is None:
depth = 0
else:
depth = rendered_depth(parent)
if str(parent.get("text") or "").strip():
depth += 1
rendered_depths[node_id] = depth
return depth
for node in text_nodes:
text = " / ".join(str(node.get("text") or "").splitlines()).strip()
indent = " " * rendered_depth(node)
lines.append(f"{indent}- {text}")
elif nodes:
lines.append(f"> [SmartArt structure has {len(nodes)} node(s), but no text]")
else:
status = str(diagram.get("status") or "content-unavailable")
if diagram.get("text_extracted") and status == "ok":
lines.append("> [SmartArt data is readable but has no semantic nodes]")
else:
lines.append(f"> [SmartArt content unavailable: {status}]")
return "\n".join(lines).rstrip()
@@ -2,8 +2,8 @@
Native templates usually ship an empty ``<p:transition/>`` that renders as no
motion, so ``apply`` injects a default transition unless told to ``keep`` the
source or set ``none``. Effects come from the shared ``pptx_animations``
vocabulary so they match the SVG export path.
source or set ``none``. Effects and OOXML mutation come from the shared
``pptx_transitions`` core so every PPTX path uses the same writer.
"""
from __future__ import annotations
@@ -11,12 +11,13 @@ from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from .ooxml import NS, P14_NS, _qn
try:
from pptx_animations import TRANSITIONS
except ImportError:
TRANSITIONS = {}
from pptx_transitions import (
TRANSITIONS,
AdvanceUpdate,
EnterUpdate,
apply_slide_motion,
validate_seconds,
)
# Default page transition injected by `apply` when neither the CLI flag nor a
# per-slide plan field asks for something else. Use `keep` to preserve the
@@ -28,59 +29,52 @@ KEEP_TRANSITION = "keep"
_UNSET = object()
def _build_transition_element(
effect: str,
duration: float,
advance_after: float | None = None,
) -> ET.Element:
"""Build a populated <p:transition> element from the shared TRANSITIONS vocabulary."""
info = TRANSITIONS[effect]
transition = ET.Element(_qn(NS["p"], "transition"))
transition.set(_qn(P14_NS, "dur"), str(int(float(duration) * 1000)))
if advance_after is not None:
transition.set("advTm", str(int(float(advance_after) * 1000)))
child = ET.SubElement(transition, _qn(NS["p"], info["element"]))
for key, value in info.get("attrs", {}).items():
child.set(key, str(value))
return transition
def _set_slide_transition(
slide_root: ET.Element,
*,
effect: str | None,
duration: float,
advance_after: float | None = None,
) -> None:
"""Replace the cloned slide's transition element (empty in most templates).
) -> bool:
"""Apply a legacy template-fill transition through the shared core.
``effect`` of ``None`` or ``"keep"`` leaves the source transition untouched;
``"none"`` strips it so the slide advances with no animation. OOXML requires
the transition to sit after ``p:clrMapOvr`` and before ``p:timing``.
``None`` and ``keep`` preserve the source transition. ``none`` removes the
visual transition while retaining an explicitly requested auto-advance.
Legacy ``advance_after`` allowed both click and timed advance, so it maps to
``both`` rather than the stricter ``after`` mode. The return value reports
whether the resulting slide contains an automatic advance.
"""
if effect is None or effect == KEEP_TRANSITION:
return
enter = EnterUpdate(policy="preserve")
advance = AdvanceUpdate(
mode="preserve" if advance_after is None else "both",
after=advance_after,
)
elif effect == "none":
enter = EnterUpdate(policy="none", effect=None, duration=duration)
advance = AdvanceUpdate(
mode="click" if advance_after is None else "both",
after=advance_after,
)
else:
enter = EnterUpdate(
policy="replace",
effect=effect,
duration=duration,
)
advance = AdvanceUpdate(
mode="click" if advance_after is None else "both",
after=advance_after,
)
new_element = None if effect == "none" else _build_transition_element(effect, duration, advance_after)
existing = slide_root.find("p:transition", NS)
if existing is not None:
index = list(slide_root).index(existing)
slide_root.remove(existing)
if new_element is not None:
slide_root.insert(index, new_element)
return
if new_element is None:
return
timing = slide_root.find("p:timing", NS)
if timing is not None:
slide_root.insert(list(slide_root).index(timing), new_element)
return
clr_map_ovr = slide_root.find("p:clrMapOvr", NS)
if clr_map_ovr is not None:
slide_root.insert(list(slide_root).index(clr_map_ovr) + 1, new_element)
return
slide_root.append(new_element)
try:
return apply_slide_motion(
slide_root,
enter=enter,
advance=advance,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
def _resolve_slide_transition(
@@ -98,11 +92,19 @@ def _resolve_slide_transition(
duration = raw.get("duration", default_duration)
advance_after = raw.get("advance_after")
else:
effect = str(raw)
effect = None if raw is None else str(raw)
duration = default_duration
advance_after = None
if effect is not None and effect not in ("none", KEEP_TRANSITION) and effect not in TRANSITIONS:
raise RuntimeError(
f"Unknown transition effect '{effect}'. Valid: {', '.join(sorted(TRANSITIONS))}, none, {KEEP_TRANSITION}"
)
return effect, float(duration), advance_after
try:
resolved_duration = validate_seconds(
duration,
"transition duration",
allow_zero=False,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
return effect, resolved_duration, advance_after
@@ -5,11 +5,10 @@
## 0. 上游规范引用
本文档 **图表模板专用** 的美学与实现规范。所有图表同时必须遵守项目级通用技术约束:
> **[`references/shared-standards.md`](../../references/shared-standards.md)** — SVG 禁用特性黑名单、PPT 兼容性替代、Canvas 格式、tspan 内联规则、分组规范、阴影/叠加技术、后处理管线
以下章节摘录了 shared-standards 中与图表模板最密切相关的条目。完整细节(如 marker 条件约束、clipPath 条件约束、弧线路径计算公式等)请查阅上游文档。
本文档只定义 **图表模板专用** 的美学与实现配方。项目级 SVG
创作、兼容性例外与条件映射统一以
[`references/shared-standards.md`](../../references/shared-standards.md)
为权威;本指南不摘录、不放宽该合同。
---
@@ -75,7 +74,6 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
```
- 纯英文场景可省略 `'PingFang SC', 'Microsoft YaHei'`
- **禁止** 使用 `@font-face`、外部字体、`<style>` 标签
### 2.2 字号层级
@@ -90,23 +88,9 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
> **最小字号下限:12px**。所有文本不得小于 12px。
### 2.3 tspan 规范
### 2.3 同一图表标签的内联格式
所有 `<text>` 元素的文本内容 **必须** 包裹在 `<tspan>`
```xml
<!-- 正确 -->
<text x="60" y="80" font-size="34" fill="#0F172A">
<tspan>图表标题</tspan>
</text>
<!-- 错误 -->
<text x="60" y="80" font-size="34" fill="#0F172A">图表标题</text>
```
### 2.4 内联格式化规则(shared-standards SS4
**单逻辑行 = 单 `<text>`**。同一行内需要多色/多粗细时,用内联 `<tspan>` 实现,**不要**用多个并排 `<text>`
当一个图表标签需要在 PPT 中保持为单一可编辑文本框时,把同一逻辑行写进一个 `<text>`,并用内联 `<tspan>` 表达多色或多粗细。若本来就需要多个独立文本框,则可使用多个 `<text>`
```xml
<!-- 正确:一个 text frame,三个 run -->
@@ -114,15 +98,15 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
实现<tspan fill="#3B82F6" font-weight="bold">10倍</tspan>效率提升
</text>
<!-- 错误:三个独立 text framePPT 中无法作为一行编辑 -->
<!-- 若目标是单一文本框,则不适用:下面会生成三个独立 text frame -->
<text x="100" y="200">实现</text>
<text x="160" y="200" fill="#3B82F6">10倍</text>
<text x="240" y="200">效率提升</text>
```
> 内联 tspan **不得** 携带 `x` / `y` / `dy`,否则后处理会将其拆分为独立 text frame`dx` 可用于微调字距。
> 只有需要留在同一文本框内的 inline tspan 才不带 `x` / `y` / `dy`;带这些定位属性的 tspan 会按独立文本框处理`dx` 可用于微调字距。
### 2.5 数据高亮默认行为
### 2.4 数据高亮默认行为
图表中的关键数据文本应默认高亮:
- **数值结果** — 百分比、倍数、金额 → `<tspan fill="主题色" font-weight="bold">`
@@ -133,7 +117,9 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
## 3. 阴影滤镜
`<filter>` 本身是允许的、且是 PPT 阴影/发光的官方推荐路径(详见本节末尾的"禁用列表"说明)。本节统一阴影 primitive 写法——使用 `feFlood` 方案,**禁止** `<filter>` 内部使用 `<feComponentTransfer>`
本节只规定图表模板库采用的阴影配方,不定义项目级 filter
支持边界。图表阴影统一使用 `feFlood` 方案,本指南不采用
`<feComponentTransfer>`
```xml
<filter id="chartShadow" x="-15%" y="-15%" width="130%" height="130%">
@@ -156,19 +142,20 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
| 中型元素(柱子、箱体) | 2-3 | 1-2 | 0.10-0.15 |
| 轻型元素(底部卡片) | 4-6 | 2-4 | 0.06-0.08 |
### 禁用列表
### 本指南实现约定
- `flood-color="#000000"` → 必须用 `#0F172A`
- `<feComponentTransfer>` + `<feFuncA slope=...>` → 用 `<feFlood flood-color flood-opacity>` 替代
- `flood-opacity > 0.20` → 阴影过重,最大 0.15-0.20
> **被禁的是 sub-element,不是 `<filter>` 本身。** `<filter>` 是 PPT Master 允许的、官方推荐的阴影/发光路径(见 [`shared-standards.md`](../../references/shared-standards.md) §1 黑名单不含 filter、§6 把 filter shadow 列为 drop-shadow 的官方实现),转换器 [`svg_to_pptx/drawingml/styles.py`](../../scripts/svg_to_pptx/drawingml/styles.py) 也主动把 `feGaussianBlur` + `feOffset` + `feFlood` + `feComposite` + `feMerge`(以及 `feDropShadow` 简写)映射成 DrawingML `<a:outerShdw>`
> 这是图表模板的色彩一致性约定,不是项目级 SVG 黑名单。单独排除
> `feComponentTransfer/feFuncA(slope)`,是因为它只能调透明度、无法携带
> 颜色,容易让阴影退回纯黑,与同页使用 `feFlood`
> `flood-color="#0F172A"` 的卡片产生肉眼可见的冷暖色差。
>
> 单独禁 `feComponentTransfer/feFuncA(slope)` 的原因:**它物理上只能调透明度、无法携带颜色**。转换器读到 `feFuncA slope` 时只把它当作 alpha,颜色字段保持默认 `'000000'`——SVG 端看起来阴影颜色正常(因为 SourceAlpha 本身是黑),但导出到 PPTX 后阴影颜色会被定死成纯黑 `#000000`,与同页其他用 `feFlood flood-color="#0F172A"` 的卡片产生肉眼可见的冷暖色差
>
> 简言之:**用 filter 没问题,但 primitive 必须能把"颜色"显式表达出来;只能表达"透明度"的 primitive 是被禁的。**
> 简言之:本库的图表阴影必须显式表达颜色,而不能只表达透明度
### 阴影使用原则shared-standards SS6
### 图表阴影使用原则
> **阴影是美学成分,不是默认处理。** 克制而非丰富才能产生"经过设计"的感觉。 "阴影被感知而非被看见" 是高端美学标准。
@@ -222,7 +209,7 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
## 5. 结构规范
### 5.1 层级分组shared-standards SS4 Grouping
### 5.1 图表层级分组
使用 `<g id="...">` 进行语义分组,便于 PPT 中逐个操作/动画:
@@ -241,7 +228,7 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
</g>
```
**分组单元参考**(来自 shared-standards
**图表模板分组单元**
| 分组单元 | 包含内容 |
|---------|---------|
@@ -254,8 +241,6 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
**命名约定**:使用描述性 `id`(如 `card-1``step-discover``header``footer`)。
> 只有 `<g opacity="...">` 被禁止(见 SS2)。纯结构 `<g>` 是必需的。
### 5.2 viewBox
固定为 `0 0 1280 720`PPT 16:9),不可修改。
@@ -278,52 +263,11 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
---
## 6. SVG 禁用特性与兼容性(shared-standards SS1-2
## 6. 通用 SVG 技术约束
### 6.1 绝对禁止
| 禁用特性 | 替代方案 |
|---------|---------|
| HTML 命名实体(`&nbsp;` `&mdash;` `&copy;` `&ndash;` `&reg;` `&hellip;` `&bull;` …) | 直接写原生 Unicode 字符(`—` `` `©` `®` `→` NBSP …) |
| 文本/属性值中裸写 `& < > " '` | 必须写成 XML 实体 `&amp;` `&lt;` `&gt;` `&quot;` `&apos;` |
| `<style>` / `class` | 内联属性(`id``<defs>` 内合法) |
| `<foreignObject>` | `<text>` + `<tspan>` |
| `mask` | 叠加遮罩矩形 / gradient overlay |
| `<symbol>` + `<use>` | 直接写出完整元素 |
| `textPath` | 手动排列 `<text>` |
| `@font-face` | 系统字体栈 |
| `<animate*>` / `<set>` | 无(PPT 侧处理动画) |
| `<script>` / event 属性 | 无 |
| `<iframe>` | 无 |
### 6.2 PPT 兼容性替代
| 禁止语法 | 正确替代 |
|---------|----------|
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
| `<g opacity="0.2">...</g>` | 在每个子元素上单独设置 `fill-opacity` / `stroke-opacity` |
| `<image opacity="0.3"/>` | 在 image 后叠加 `<rect fill="背景色" opacity="0.7"/>` |
### 6.3 条件允许
| 特性 | 条件 | 转换结果 |
|------|------|----------|
| `marker-start` / `marker-end` | `<marker>``<defs>` 中,`orient="auto"`,形状为三角/菱形/圆 | DrawingML `<a:headEnd>` / `<a:tailEnd>` |
| `clipPath` on `<image>` | `<clipPath>``<defs>` 中,单子元素,**仅用于 image** | DrawingML `<a:prstGeom>` / `<a:custGeom>` |
| `stroke-dasharray` | 使用预设值 `4,4` / `2,2` / `8,4` / `8,4,2,4` | PPTX `<a:prstDash>` |
| `text-decoration` | `underline` / `line-through` | PPTX 原生文本格式 |
| `transform="rotate(...)"` | 所有元素类型均支持 | PPTX `<a:xfrm rot="...">` |
> 完整条件约束见 [`shared-standards.md`](../../references/shared-standards.md) SS1.1marker 约束)和 SS1.2clipPath 约束)。
### 6.4 虚线预设对照
| SVG 值 | PPTX 预设 | 适用场景 |
|--------|-----------|---------|
| `4,4` | Dash | 通用虚线、分隔线 |
| `2,2` | Dot (sysDot) | 占位轮廓、细边框 |
| `8,4` | Long dash | 时间线连接、流程箭头 |
| `8,4,2,4` | Long dash-dot | 技术图纸、尺寸线 |
本指南不定义或摘录项目级 SVG 允许项、禁用项与条件映射。当前合同统一见
[`shared-standards.md`](../../references/shared-standards.md);新增或修改模板时,
必须对目标文件运行 `svg_quality_checker.py` 并通过校验。
---
@@ -413,16 +357,12 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
### 排版
- [ ] 无 `font-size < 12` 的文本
- [ ] 所有 `<text>` 内容包裹 `<tspan>`
- [ ] 同一行多格式用内联 `<tspan>`**非**多个并排 `<text>`
- [ ] 内联 `<tspan>` 不携带 `x` / `y` / `dy`
- [ ] 需要保持为单一 PPT 文本框的多格式标签使用 inline `<tspan>`;只有这类 inline run 不携带 `x` / `y` / `dy`
- [ ] 标题 34px、副标题 18px、来源 14px
### 结构
- [ ] 主要元素有语义化 `<g id="...">`
- [ ] `<style>``class``<foreignObject>``mask``rgba()`
- [ ] `<g>` 标签无 `opacity` 属性
- [ ] 文本字符为原生 Unicode`—` `©` `→` NBSP 等),无 HTML 命名实体(`&nbsp;` `&mdash;` `&copy;` 等);裸 `& < >` 已转义为 `&amp; &lt; &gt;`
- [ ] `svg_quality_checker.py` 对目标模板通过;通用 SVG 合同不在本清单复述
### 阴影
- [ ] 使用 `feFlood` 方案(非 `feComponentTransfer`
@@ -640,4 +580,3 @@ echo "Small fonts:" && grep -c 'font-size="[0-9]"' "skills/ppt-master/templates/
| §11.5 倾斜虚线连接箭头 | `matrix_2x2.svg` |
| §11.6 接地椭圆 | `team_roster.svg` |
| §11.7 双向交互箭头 | `client_server_flow.svg` |
@@ -4,7 +4,8 @@
Purpose: Qualitative assessment, scoring, and vendor comparison
Use cases: Vendor evaluations, project status, skill matrices
Design Principles: Left-aligned features, centered Harvey balls, clean minimalist consulting grid
Note: Harvey ball shapes are inlined (no <use>/<defs>) so the SVG-to-PPTX converter renders them.
Note: Harvey ball shapes stay inlined for direct per-cell editing.
Static same-document <use> is supported, but PPTX import does not reconstruct reuse structure.
-->
<rect width="1280" height="720" fill="#FFFFFF"/>
<!-- ==================== Header ==================== -->

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -42,7 +42,7 @@ primary_color: "#XXXXXX"
## VIII. SVG Page Roster # Structure — file list + per-file purpose
```
Decks may include additional supporting sections (Voice & Tone, Icon Style, Layout Modes, Spacing Specification, SVG Technical Constraints, Placeholder Specification, Asset Specification, Usage Notes). Use them when meaningful for the replica.
Decks may include additional supporting sections (Voice & Tone, Icon Style, Layout Modes, Spacing Specification, Placeholder Specification, Asset Specification, Usage Notes). Put a template-specific required / forbidden / conditional boundary inside the personality section it qualifies. General SVG/PPT authoring rules belong only in [`shared-standards.md`](../../references/shared-standards.md); do not add a generic technical-rules section or pointer to a deck spec.
---
@@ -99,6 +99,7 @@ primary_color: "#C00000"
- Chapter pages are cleaner section-divider pages and should not inherit the content-page header ribbon.
- Each page should contain at most one formal logo mark; sidebars should rely on slogan and skyline imagery instead of repeated logo lockups.
- The content page remains open-canvas by default and should not reserve a large fixed sidebar.
- Repeated telecom motifs use one reusable structure family rather than unrelated decorative variants.
---
@@ -180,18 +181,7 @@ primary_color: "#C00000"
---
## X. SVG Technical Constraints
1. `viewBox` must remain `0 0 1280 720`
2. Do not use `mask`, `<style>`, `class`, `foreignObject`, or `rgba()`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
3. Use plain vector geometry and `<image>` references to packaged assets only
4. Transparency must use `fill-opacity` / `stroke-opacity`
5. Text wrapping should be handled with `<tspan>` if needed
6. Avoid PPT-fragile decorative complexity; simplify repeated motifs into reusable structures
---
## XI. Placeholder Specification
## X. Placeholder Specification
| Placeholder | Purpose | Applicable Page |
| --- | --- | --- |
@@ -214,8 +204,9 @@ primary_color: "#C00000"
---
## XII. Usage Guide
## XI. Usage Guide
1. Reuse `logo.png` and `footer_ribbon.png` as fixed brand assets; `slogan_red.png` and `skyline_bg.png` should be used selectively and only on the cover / ending pages.
2. Keep generated text inside the documented safe areas; only chapter pages use a strong left/right split.
3. Prefer red emphasis only for structure and key figures; do not over-saturate the content area.
4. Reference only assets packaged with this template.
@@ -98,6 +98,8 @@ primary_color: "#00418D"
| **Content** | y=100, h=560px | Main content area |
| **Footer** | y=680, h=40px | Page number, company name, bottom decorative line |
**Required background layer**: Every page retains the template's full-canvas background layer.
---
## VI. Page Types
@@ -178,30 +180,7 @@ primary_color: "#00418D"
---
## X. SVG Technical Constraints
### Mandatory Rules
1. viewBox fixed at `0 0 1280 720`
2. Background must include a full-screen `<rect>`
3. Text wrapping via `<tspan>`
4. Opacity must use `fill-opacity` / `stroke-opacity`
5. `marker-start` / `marker-end` conditionally allowed — see shared-standards.md §1.1 (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval)
### Forbidden Elements (Blacklist)
- `mask` (masking); `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
- `<style>`, `class` (stylesheets; `id` within `<defs>` is allowed)
- `foreignObject` (foreign objects)
- `textPath` (text on path)
- `animate`, `animateTransform`, `set` (animations)
- `rgba()` color format (must use hex + opacity)
- `<g opacity="...">` (group opacity — set individually on each element)
---
## XI. Placeholder Specification
## X. Placeholder Specification
| Placeholder | Description |
| -------------------- | ------------------ |
@@ -222,7 +201,7 @@ primary_color: "#00418D"
---
## XII. Usage Notes (Recommended)
## XI. Usage Notes (Recommended)
1. **Logo Adaptation**: Cover and ending pages use inverted (white) Logo; content page upper-right uses color or inverted Logo.
2. **Image Assets**: Ensure the `images/` folder under the template directory contains necessary Logo files.
@@ -183,27 +183,7 @@ primary_color: "#004098"
---
## X. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Text wrapping via `<tspan>` (no `<foreignObject>`)
4. Opacity via `fill-opacity` / `stroke-opacity`, no `rgba()`
5. Forbidden: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Forbidden: `textPath`, `animate*`, `script`
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
### PPT Compatibility Rules
- No `<g opacity="...">` (group opacity) — set opacity on each child element individually
- Use overlay layers for image transparency
- Inline styles only — no external CSS or `@font-face`
---
## XI. Placeholder Specification
## X. Placeholder Specification
Templates use `{{PLACEHOLDER}}` format. Common placeholders:
@@ -226,7 +206,7 @@ Templates use `{{PLACEHOLDER}}` format. Common placeholders:
---
## XII. Usage Notes (Recommended)
## XI. Usage Notes (Recommended)
1. **Template Deployment**: Copy the template to your project directory.
2. **Asset Replacement**: Replace `大型 logo.png` (592×238) and `右上角 logo.png` (113×50) in the `images` directory.
@@ -140,15 +140,7 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
| **Title to Body** | 44px |
| **Footer Offset** | 32px from bottom |
## X. SVG Technical Constraints
1. `viewBox` must stay `0 0 1280 720`
2. No `mask`, `<style>`, `class`, `foreignObject`, `textPath`, or animation tags. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
3. Use plain hex colors with `fill-opacity` / `stroke-opacity`
4. Keep image assets semantic and minimal
5. Prefer vector reconstruction over embedding PPT-export fragments
## XI. Placeholder Specification
## X. Placeholder Specification
| Placeholder | Description |
| --- | --- |
@@ -184,7 +176,7 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
| `{{CONTACT_EMAIL}}` | Ending email address |
| `{{CONTACT_PHONE}}` | Ending phone number |
## XII. Asset Specification
## XI. Asset Specification
### Core Assets
@@ -206,7 +198,10 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
Core assets are wired into SVG templates. `logo_dark.png` is used on light pages (TOC, content); `logo_white.png` and `cover_bg.png` on dark pages (cover, chapter, ending). Optional assets are available for project-specific customization.
## XIII. Chart Specifications
- Keep bundled image assets semantic and minimal.
- Reconstruct editable template geometry instead of embedding fragments exported from PowerPoint.
## XII. Chart Specifications
### Recommended Chart Dimensions
@@ -228,7 +223,7 @@ Core assets are wired into SVG templates. `logo_dark.png` is used on light pages
| Negative indicator | `#E74C3C` |
| Neutral | `#666666` |
## XIV. Usage Instructions
## XIII. Usage Instructions
1. Copy the template directory to the project `templates/` folder
2. Read this design specification to understand the visual system
@@ -196,6 +196,8 @@ Small circle elements representing the nighttime lights of the Mountain City:
- Content page header: Width 160-200px
- Ending page: Width 320-400px
Use the packaged university logo assets through the standard project image pipeline.
---
## IX. Spacing Specification
@@ -210,26 +212,7 @@ Small circle elements representing the nighttime lights of the Mountain City:
---
## X. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Define gradients using `<linearGradient>` inside `<defs>`
3. Use `fill-opacity` / `stroke-opacity` for transparency
4. Use `<tspan>` for text wrapping
5. Use Base64 inline or `<image>` reference for logos
### Prohibited Elements
- `mask`, `<style>`, `class`; `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
- `foreignObject`, `textPath`, `animate*`
- `rgba()` color format
- `<g opacity="...">` (group opacity)
---
## XI. Placeholder Specification
## X. Placeholder Specification
| Placeholder | Description |
| -------------------- | ---------------------- |
@@ -81,7 +81,7 @@
<stop offset="100%" stop-color="#[secondary accent]"/>
</linearGradient>
<!-- Background decorative gradient (note: rgba forbidden, use stop-opacity) -->
<!-- Background decorative gradient (explicit stop-opacity keeps the palette reusable) -->
<radialGradient id="bgDecor" cx="80%" cy="20%" r="50%">
<stop offset="0%" stop-color="#[primary]" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#[primary]" stop-opacity="0"/>
@@ -349,25 +349,3 @@ One speaker note file per page, saved to `notes/`:
- **Filename**: match SVG name (e.g., `01_cover.md`)
- **Content**: script key points, timing cues, transition phrases
---
## XI. Technical Constraints Reminder
### SVG Generation Must Follow:
1. viewBox: `{canvas_info['viewbox']}`
2. Background uses `<rect>` elements
3. Text wrapping uses `<tspan>` (`<foreignObject>` FORBIDDEN)
4. Transparency uses `fill-opacity` / `stroke-opacity`; `rgba()` FORBIDDEN
5. FORBIDDEN: `mask`, `<style>`, `class`, `foreignObject`
6. FORBIDDEN: `textPath`, `animate*`, `script`
7. Text characters: write typography & symbols as raw Unicode (em dash `—`, en dash ``, `©`, `®`, `→`, NBSP, etc.); HTML named entities (`&nbsp;`, `&mdash;`, `&copy;`, `&reg;` …) are FORBIDDEN. XML reserved chars in text MUST be escaped as `&amp;` `&lt;` `&gt;` `&quot;` `&apos;` (e.g. `R&amp;D`, `error &lt; 5%`). See shared-standards.md §1.0
7. `marker-start` / `marker-end` conditionally allowed: `<marker>` must be in `<defs>`, `orient="auto"`, shape must be triangle / diamond / circle (see shared-standards.md §1.1)
8. `clipPath` conditionally allowed **only on `<image>` elements**: `<clipPath>` in `<defs>`, single shape child (circle / ellipse / rect with rx,ry / path / polygon). Do NOT apply to shapes / groups / text — draw the target geometry directly with the matching native element (`<circle>` / `<ellipse>` / `<rect rx>` / `<polygon>` / `<path>`). See shared-standards.md §1.2
### PPT Compatibility Rules:
- `<g opacity="...">` FORBIDDEN (group opacity); set on each child element individually
- Image transparency uses overlay mask layer (`<rect fill="bg-color" opacity="0.x"/>`)
- Inline styles only; external CSS and `@font-face` FORBIDDEN
@@ -39,7 +39,7 @@ page_types: [cover, toc, chapter, content, ending]
## V. SVG Page Roster # File list + per-file purpose
```
Layouts may include additional supporting sections (Layout Patterns, Spacing Guidelines, SVG Technical Constraints, Placeholder Specification, Usage Notes). Do **not** include Color Scheme or Typography sections — those are identity-segment fields owned by `templates/brands/` and `templates/decks/`.
Layouts may include additional supporting sections (Layout Patterns, Spacing Guidelines, Placeholder Specification, Usage Notes). Put a layout-specific required / forbidden / conditional boundary inside the personality section it qualifies. Do **not** include Color Scheme or Typography sections — those are identity-segment fields owned by `templates/brands/` and `templates/decks/`. General SVG/PPT authoring rules belong only in [`shared-standards.md`](../../references/shared-standards.md); do not add a generic technical-rules section or pointer to a layout spec.
---
@@ -75,6 +75,6 @@ The register step updates [`layouts_index.json`](./layouts_index.json) — the s
---
## SVG technical constraints
## SVG technical authority
See [`shared-standards.md`](../../references/shared-standards.md) for the authoritative ban list (PPT incompatibilities, raw-character rules, clipPath conditional allowance, etc.). Layouts must comply.
[`shared-standards.md`](../../references/shared-standards.md) is authoritative for general SVG/PPT required, forbidden, and conditional rules. Layout specs add only constraints unique to that layout.
@@ -142,27 +142,7 @@ page_types: [cover, toc, chapter, content, ending]
---
## VIII. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
4. Use `fill-opacity` / `stroke-opacity` for transparency; no `rgba()`
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Prohibited: `textPath`, `animate*`, `script`
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
### PPT Compatibility Rules
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
- Use overlay layers for image transparency
- Inline styles only; no external CSS or `@font-face`
---
## IX. Placeholder Specification
## VIII. Placeholder Specification
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
@@ -194,7 +174,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## X. Component Specifications
## IX. Component Specifications
### 1. Tag
@@ -227,7 +207,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## XI. Usage Instructions
## X. Usage Instructions
1. Copy the template to the project directory
2. Select the appropriate page template based on defense content needs
@@ -256,30 +256,7 @@ This template emulates the visual language of telecom technical reports. The cor
---
## X. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Use `<tspan>` for text wrapping (**`<foreignObject>` is strictly prohibited**)
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject` (`id` inside `<defs>` is allowed). `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Prohibited: `textPath`, `animate*`, `script`
7. Prohibited: `<symbol>+<use>`, `<iframe>`, `@font-face`
8. Prohibited: `<g opacity="...">` (group opacity) — set opacity on each child element individually
9. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
10. Use only system fonts and inline styles
### PPT Compatibility Rules
- Use overlay layers instead of image opacity
- Define gradients using `<linearGradient>` inside `<defs>`
- Use `rx`/`ry` attributes for rounded rectangles (post-processing converts to Path)
---
## XI. Placeholder Specification
## X. Placeholder Specification
The template uses `{{PLACEHOLDER}}` format placeholders:
@@ -304,7 +281,7 @@ The template uses `{{PLACEHOLDER}}` format placeholders:
---
## XII. Usage Notes
## XI. Usage Notes
1. Copy this template directory to the project `templates/` directory
2. Review `reference_style.svg` to understand the core visual style
@@ -315,7 +292,7 @@ The template uses `{{PLACEHOLDER}}` format placeholders:
---
## XIII. Design Highlights
## XII. Design Highlights
- **Telecom DNA**: Derived from real telecom AI operations architecture reports, naturally suited for telecom/enterprise presentation styles
- **High Information Density**: A single page can accommodate a complete architecture view (objectives → results → scenarios → orchestration → foundational capabilities)
@@ -140,27 +140,7 @@ page_types: [cover, toc, chapter, content, ending]
---
## VIII. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Prohibited: `textPath`, `animate*`, `script`
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
### PPT Compatibility Rules
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
- Use overlay layers instead of image opacity
- Use inline styles only; external CSS and `@font-face` are prohibited
---
## IX. Placeholder Specification
## VIII. Placeholder Specification
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
@@ -184,7 +164,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## X. Usage Instructions
## IX. Usage Instructions
1. Copy the template to the project directory
2. Replace logo files in the images directory (if applicable)
@@ -194,7 +174,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## XI. Design Highlights
## X. Design Highlights
- **Tech Gradient**: Bright-to-deep blue gradient reflects a modern tech aesthetic
- **Geometric Decorative Elements**: Circles and grids add a tech atmosphere
@@ -135,27 +135,7 @@ page_types: [cover, toc, chapter, content, ending]
---
## VIII. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Prohibited: `textPath`, `animate*`, `script`
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
### PPT Compatibility Rules
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
- Use overlay layers instead of image opacity
- Use inline styles only; external CSS and `@font-face` are prohibited
---
## IX. Placeholder Specification
## VIII. Placeholder Specification
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
@@ -178,7 +158,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## X. Usage Instructions
## IX. Usage Instructions
1. Copy the template to the project directory
2. Replace logo files in the images directory (if applicable)
@@ -188,7 +168,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## XI. Design Highlights
## X. Design Highlights
- **Dual-Color Gradient Top Decoration**: Red-blue gradient reflects a government style
- **Gold Accent Elements**: Adds a sense of dignity
@@ -142,34 +142,7 @@ page_types: [cover, toc, chapter, content, ending]
---
## VIII. SVG Technical Constraints
### Mandatory Rules
- viewBox fixed at `0 0 1280 720`
- Use `<rect>` elements for backgrounds
- Use `<tspan>` for text wrapping
- All colors in HEX format (no rgba)
- Use `fill-opacity` / `stroke-opacity` for transparency
### Prohibited Elements (PPT Incompatible)
| Prohibited Item | Alternative |
| -------------------- | ------------------------------ |
| `clipPath` | Allowed only on `<image>` under `shared-standards.md` §1.2 |
| `mask` | Do not use masking |
| `<style>` | Use inline styles |
| `class` | Use inline attributes (`id` inside `<defs>` is allowed) |
| `foreignObject` | Use `<tspan>` for wrapping |
| `textPath` | Use standard `<text>` |
| `animate*` / `set` | Do not use animations |
| `<g opacity>` | Set opacity on each element individually |
> `marker-start` / `marker-end` are conditionally allowed — see `shared-standards.md` §1.1 (marker must be in `<defs>`, `orient="auto"`, shape = triangle / diamond / oval). The converter maps them to native DrawingML arrow heads.
---
## IX. Placeholder Specification
## VIII. Placeholder Specification
| Placeholder | Usage |
| ------------------- | ---------------------------- |
@@ -197,7 +170,7 @@ page_types: [cover, toc, chapter, content, ending]
---
## X. Usage Notes
## IX. Usage Notes
### 1. Copy Template to Project
@@ -213,7 +186,7 @@ cp templates/layouts/medical_university/* projects/<project>/templates/
---
## XI. Medical Content-Specific Components
## X. Medical Content-Specific Components
### Data Card (Vital Signs)
@@ -173,7 +173,7 @@ Apply glow filters to key text/elements:
<text filter="url(#glowGreen)" fill="#39FF14">Glowing Text</text>
```
> **Note**: `filter` effects are typically ignored in PPT, but render well in SVG-compatible viewers.
> **Conditional enhancement**: Glow filters may enrich the retro treatment, but the core pixel composition must remain legible without them.
### Emoji Usage
@@ -192,28 +192,7 @@ Apply glow filters to key text/elements:
---
## IX. SVG Technical Constraints
### Mandatory Rules
1. viewBox: `0 0 1280 720`
2. Use `<rect>` elements for backgrounds
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
6. Prohibited: `textPath`, `animate*`, `script`
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
### PPT Compatibility Rules
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
- Use overlay layers instead of image opacity
- Use inline styles only; external CSS and `@font-face` are prohibited
- `filter` effects serve as enhancements (allowed) and do not affect baseline display
---
## X. Placeholder Specification
## IX. Placeholder Specification
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
@@ -235,7 +214,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## XI. Usage Instructions
## X. Usage Instructions
1. Copy the template to the project `templates/` directory
2. Select the appropriate page template based on content requirements
@@ -246,7 +225,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
---
## XII. Color Quick Reference
## XI. Color Quick Reference
```
Background Layer:
@@ -269,37 +269,7 @@ Use `tabler-outline` as the stylistic icon library for this template. It matches
---
## IX. SVG Technical Constraints
### viewBox Specification
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
```
### Prohibited Features (Blocklist)
| Category | Prohibited Items |
| ------------------ | --------------------------------------- |
| **Clipping/Masking** | `mask` is forbidden; `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2 |
| **Style System** | `<style>`, `class` (`id` inside `<defs>` is allowed) |
| **Structure/Nesting** | `<foreignObject>` |
| **Text/Font** | `textPath`, `@font-face` |
| **Animation/Interaction** | `<animate*>`, `<set>`, `on*` |
> `marker-start` / `marker-end` are conditionally allowed — see `shared-standards.md` §1.1 (marker must be in `<defs>`, `orient="auto"`, shape = triangle / diamond / oval).
### PPT Compatibility Rules
| Prohibited | Correct Alternative |
| ---------------------------------- | ------------------------------------------------------ |
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
| `stroke="rgba(0,0,0,0.5)"` | `stroke="#000000" stroke-opacity="0.5"` |
| `<g opacity="0.2">...</g>` | Set `opacity` / `fill-opacity` on each child element individually |
---
## X. Placeholder Specification
## IX. Placeholder Specification
| Placeholder | Usage |
| -------------------- | -------------------- |
@@ -322,7 +292,7 @@ Use `tabler-outline` as the stylistic icon library for this template. It matches
---
## XI. Usage Notes
## X. Usage Notes
### Template Usage Steps
@@ -75,6 +75,8 @@
>
> **Size slots are anchors, not a closed menu.** Common slots (`title` / `subtitle` / `annotation`) cover frequent cases. Add role-specific slots (e.g. `cover_title: 88`, `hero_number: 56`, `subheading: 32`, `lead: 30`, `footnote: 16`, `chart_annotation: 16`) for the roles the deck actually uses — common for cover-heavy decks, consulting-style hero numbers, dense pages. **Mandatory — scan `§IX` and declare a slot for every role that recurs across pages, not just the four defaults.** A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** and **page numbers / source credits / footnotes** → declare `lead` and `footnote` for them. `subheading` and `lead` sit between `subtitle` and `body` (their bands overlap `subtitle`) — pick by role, not size — and the core-message `lead` is a **primary** line, **always ≥ `body`**, never smaller. Leaving a recurring lead / footnote undeclared forces the Executor to improvise an unlocked size (and a core line improvised below `body` inverts the hierarchy). **Structural roles (title / body / subtitle / annotation / footnote) render at their locked size on every page — one role, one size, deck-wide.** Intermediate in-band sizes are for special / feature elements only (hero number, display title, one-off emphasis); declare a recurring one as its own slot so it stays consistent too.
>
> **Template Master defaults**: `pptx_structure.mode: template` requires both unitless `title` and `body`. Native export writes `title` into every Master `titleStyle` default and `body` into every `bodyStyle` / `otherStyle` default, changing only `a:defRPr@sz`. Direct page-run sizes and role-specific Layout placeholder prototype sizes remain unchanged. Other structure modes do not rewrite Master text-style sizes.
>
> **⚠️ PPT-safe stack discipline (HARD rule).** Native `baseline` / `template` export maps `title_family` to the PowerPoint theme major font and `body_family` (or `font_family`) to the theme minor font. Runs whose resolved face matches either role use `+mj-*` / `+mn-*` theme tokens; other role families remain concrete per-run typefaces. Every exported Latin / EA face MUST therefore resolve to cross-platform pre-installed fonts: `"Microsoft YaHei"` / `SimSun` / `Arial` / `"Times New Roman"` / `Consolas`. Stacks that resolve to non-preinstalled typefaces (Inter / Google Fonts / brand typefaces) may be used only when the Design Spec notes the font-install or embedding requirement. `preserve` keeps the source template theme; `flat` keeps concrete fonts for diagnostics.
>
> **Stack length discipline.** 3-4 fonts per stack is the sweet spot. Converter only writes the **first** Latin and **first** CJK font into PPTX — everything after is silently dropped. macOS-only families (`Songti SC`, `Menlo`, `Monaco`, `Helvetica`) are auto-mapped to Windows equivalents via `FONT_FALLBACK_WIN` (see `scripts/svg_to_pptx/drawingml/utils.py`); stacking both is redundant. Lead with Windows-preinstalled fonts (`Microsoft YaHei` / `SimSun` / `Arial` / `Georgia` / `Consolas`); keep at most **one** macOS-exclusive family (typically `"PingFang SC"`) as a browser-preview nicety.
@@ -191,7 +193,7 @@
## forbidden
- Mixing icon libraries
- rgba()
- `<style>`, `class`, `<foreignObject>`, `textPath`, `@font-face`, `<animate*>`, `<script>`, `<iframe>`, `<symbol>`+`<use>`
- `<g opacity>` (set opacity on each child element individually)
- HTML named entities in text (`&nbsp;`, `&mdash;`, `&copy;`, `&ndash;`, `&reg;`, `&hellip;`, `&bull;` …) — write as raw Unicode (`—`, `©`, `→`, NBSP, etc.); XML reserved chars `& < > " '` must be escaped as `&amp; &lt; &gt; &quot; &apos;`. See shared-standards.md §1.0
- `mask`, `<style>`, `class`, external CSS, `<foreignObject>`, `textPath`, `@font-face`, `<animate*>`, `<set>`, `<script>` / event attributes, `<iframe>`
- HTML named entities in text; write typography as raw Unicode and escape XML reserved characters
> **Execution reminder — not authoring authority**: the baseline blacklist above is intentionally terse. Add only deck-specific execution locks. General SVG required / forbidden / conditional rules are owned by [`shared-standards.md`](../references/shared-standards.md); do not copy its feature matrix or parameter contracts into `spec_lock.md`.
@@ -114,19 +114,21 @@ python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/<stem
--images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json
```
If `images/image_manifest.json` does not exist because the source deck has no extracted pictures, omit `--images`. The script joins per slide: `text_blocks` (slot text + geometry), `tables` (cell grid) / `charts` (categories + series values) — the **frozen data values inlined**, so the inventory is a self-contained contract, not a pointer back to `slide_library.json` — and `images` (bound via `image_manifest` `occurrences[].slide_index`, with geometry / `usage_count`). It emits `ignored` and `needs_confirmation` as **empty arrays** — fill them with judgment before Step 5:
If `images/image_manifest.json` does not exist because the source deck has no extracted pictures, omit `--images`. The script joins per slide: `text_blocks` (slot text + geometry), `tables` (cell grid), `charts` (categories + series values), `diagrams` (SmartArt nodes + hierarchy/connections + source layout), and `images` (bound via `image_manifest` `occurrences[].slide_index`, with geometry / `usage_count`). The **frozen source values are inlined**, so the inventory is a self-contained contract, not a pointer back to `slide_library.json`. It emits `ignored` and `needs_confirmation` as **empty arrays** — fill them with judgment before Step 5:
| Field | Fill with |
|---|---|
| `ignored` | hidden slides / shapes, master-only text, image crop / opacity / rotation / mask (not captured upstream) |
| `needs_confirmation` | combo / dual-axis / waterfall charts (only the first plot type is captured), merged-cell or multi-header tables, density-outlier pages — **either** overcrowded **or** near-empty / title-only (e.g. a divider page with a heading and no body) |
| `needs_confirmation` | unreadable SmartArt data; combo / dual-axis / waterfall charts; merged-cell or multi-header tables; density-outlier pages — **either** overcrowded **or** near-empty / title-only |
**SmartArt output boundary**: Preserve its extracted wording and semantic relationships, then redraw it through SVG as ordinary editable PowerPoint shapes. Do not attempt to regenerate a native SmartArt object or reuse persisted-drawing text as a second content source.
```markdown
## ✅ Extraction Complete
- [x] `sources/<stem>.md` (from Step 3) holds every source slide's text, in order; extracted pictures, if any, are in `images/` + `images/image_manifest.json`
- [x] `analysis/<stem>.identity.json` has theme + observed identity + canvas aspect
- [x] `analysis/<stem>.slide_library.json` holds chart + table data for regeneration
- [x] `analysis/<stem>.slide_library.json` holds chart + table data and SmartArt semantic structure for regeneration
- [x] `analysis/source_profile.json` (multi-deck index) summarizes the source facts in its `decks[]` entry
- [x] `analysis/beautify_inventory.json` ledgers per-slide text / images / data + ignored + needs-confirmation
- [ ] **Next**: Step 5 — Beautify Plan (recommend & confirm)
@@ -145,7 +147,7 @@ This step has two halves:
| Plan item | Recommend from | Default lean |
|---|---|---|
| Identity source | `<stem>.identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` | all text verbatim; data values frozen; pictures reused |
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` / `diagrams` | all text verbatim; data values and SmartArt relationships frozen; pictures reused |
| Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) |
| Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle |
| Verification level | deck size / risk | recommend the Step 7 per-page checks; user sets strictness |
@@ -6,11 +6,13 @@ description: Generate a new layout or deck template based on existing project fi
> **Role invoked**: [Template_Designer](../references/template-designer.md)
Generate a complete set of reusable PPT templates for the **global template library**.
Generate a complete layout/deck template package for either the **global template library** or one initialized project.
> This workflow is for **library asset creation**, not project-level one-off customization. The output must be reusable by future PPT projects and discoverable from the appropriate index file.
**Default — library scope**: Preserve the existing reusable-library behavior. Write `skills/ppt-master/templates/<kind>/<id>/` and register it in the matching discovery index.
> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable `templates/<kind>/<id>/` package. To generate a deck from that package, the user must provide the resulting template directory path to the main SKILL.md pipeline.
**Project scope**: Write a thin template bundle directly into `<project>/templates/` for the named project. Do not add a nested template-ID directory and do not register any global index.
> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable template contract at the selected scope. To generate a deck from a library package, return its directory path to the main SKILL.md pipeline. A project-scoped package is already installed at that project's Step 3 path and is consumed in place.
> **Companion workflow**: identity-only locking (colors / typography / logo / voice without SVG pages) is handled by [`create-brand.md`](./create-brand.md). Use that when the user wants brand identity but free page layout; use this when fixed page structures are required.
@@ -18,17 +20,34 @@ Generate a complete set of reusable PPT templates for the **global template libr
This workflow produces one of two kinds of templates depending on whether the source PPT carries a specific brand identity:
| Kind | When | Output dir | What `design_spec.md` writes |
| Kind | When | Library-scope output dir | What `design_spec.md` writes |
|---|---|---|---|
| **deck** (default) | Source is a specific organization's branded PPT (e.g. company report, university defense template); the visual identity is part of the replica | `templates/decks/<id>/` | Full segments: identity + structure + middle |
| **layout** | Source is a generic stylistic template (no specific brand); only the structural skeleton should be reusable; color / typography decided per-deck downstream | `templates/layouts/<id>/` | Structure segments only (canvas / page structure / page types / SVG roster); identity segment omitted |
Default to **deck** unless the user explicitly says "structure only" / "layout only" / "no brand identity". When in doubt, lean deck — losing identity later is easy; reconstructing it from a layout-mode strip is not. See [`docs/zh/templates-architecture.md`](../../../docs/zh/templates-architecture.md) for the full kind / schema / fusion model.
## Output scope — library (default) vs project
Output scope is a workflow execution choice, not a new template kind or PPTX structure mode. Surface it in the Step 2 brief; do not invent a CLI flag or persist `output_scope` / `target_project` into portable `design_spec.md` frontmatter.
| Scope | Final output | Asset routing | Registration |
|---|---|---|---|
| `library` (default) | `skills/ppt-master/templates/<kind_dir>/<template_id>/` | Keep the existing self-contained package: SVG/spec/assets under the package, including `icons/` when present | Run `register_template.py` against the matching global index |
| `project` | `<target_project>/templates/` **root** | `design_spec.md`, template SVGs, and non-bitmap package assets → `templates/`; bitmaps → `images/` and SVG references → `../images/<name>`; extracted icon assets → both `templates/icons/` (package/validation copy) and `icons/` (runtime copy) | Do not update any global index; bundle belongs only to this target project |
For `project`, `target_project` is required and must be an existing project initialized by `project_manager.py init`. Before the first final-output write, run one complete preflight:
1. Confirm the target project and its `templates/`, `images/`, and `icons/` directories exist.
2. Confirm `<target_project>/templates/` is empty.
3. Resolve every final bitmap and extracted-icon filename, then confirm none would overwrite an existing file in `<target_project>/images/` or `<target_project>/icons/`.
Any failed check aborts before writing `design_spec.md`, SVGs, images, or icons. Do not merge into a non-empty template root and do not overwrite a name conflict. Temporary Step 1 analysis workspaces remain allowed because they are not final project outputs.
## Process Overview
```
Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Create Directory + Invoke Template_Designer -> Validate Assets -> Register Index -> Output
Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Preflight + Invoke Template_Designer -> Validate Assets -> [Register Library Index] -> Output
```
The first three steps derive the brief from facts, not guesses. **No final template directory may be created and no template SVG / `design_spec.md` may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates produced by `pptx_template_import.py` (typically under `/tmp/pptx_template_import/`) are explicitly **not** subject to this gate — they are temporary workspaces feeding Step 2.
@@ -202,6 +221,8 @@ Compose a single message that surfaces every Required brief item to the user, **
| Field | Must show |
|---|---|
| Output scope | Recommended `library` (existing default) plus `project`; explain that `project` writes directly into one initialized project's template root and skips global registration |
| Target project | Required only for `project`; show the exact initialized project directory path, not a project nickname |
| Template kind | Recommended localized label with English ID, plus both options and the rule for choosing |
| Category | Recommended localized category with English ID, plus `brand` / `general` / `scenario` / `government` / `special` with localized explanations |
| Theme mode | Recommended localized mode with English ID, plus available modes such as `light` / `dark` / `mixed` with localized explanations |
@@ -215,7 +236,9 @@ Items to surface:
| Item | Required | Provenance by input type |
|------|----------|--------------------------|
| New template ID | Yes | `[decision]`user chooses ASCII slug; if Chinese brand name, must be filesystem-safe and match the target kind's index exactly |
| Output scope | Yes | `[decision]``library` (default, globally reusable and indexed) or `project` (direct thin bundle for one initialized project) |
| Target project | Yes for `project`; N/A for `library` | `[decision]` — explicit path to the initialized target project; validate it during the Step 4 preflight |
| New template ID | Yes | `[decision]` — user chooses ASCII slug; if Chinese brand name, it must be filesystem-safe. In library scope it also becomes the matching index key |
| Template display name | Yes | `[decision]` (often the source deck title — `[suggested]` from `summary.md` for type A) |
| Category | Yes | `[decision]` — one of `brand` / `general` / `scenario` / `government` / `special` |
| Applicable scenarios | Yes | `[suggested]` from analysis; user confirms |
@@ -241,7 +264,7 @@ For type A, also include in this message:
The user replies with corrections, additions, or "all good".
> **Persist the brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed brief (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, `source_viewbox`, `replication_mode`, `native_structure_mode`, etc.). `register_template.py` reads this in Step 6, so the brief flows directly into the index without the AI re-deriving it from prose. See Step 6 for the recommended frontmatter shape.
> **Persist the portable brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed portable fields (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, `source_viewbox`, `replication_mode`, `native_structure_mode`, etc.). Do not persist the execution-only `output_scope` or `target_project` fields. In library scope, `register_template.py` reads this frontmatter in Step 6 so the brief flows directly into the index without the AI re-deriving it from prose.
---
@@ -260,28 +283,38 @@ Skipping this gate — including silently inferring values from the reference so
- [ ] Every finite-option field has shown a recommended value, other available options, and unavailable options with reasons when applicable
- [ ] User-facing labels and option explanations match the user's language; English enum IDs appear only as precision aids
- [ ] User has replied with values or explicit acceptance of suggested defaults
- [ ] The template is clearly positioned as a **global library template**
- [ ] Output scope is confirmed; `library` remains the default, while `project` includes an explicit initialized target-project path
- [ ] The canvas format is fixed before SVG generation
- [ ] Replication mode is consistent with the input type (`fidelity` / `mirror` allowed for A and B with B's caveats noted; forbidden for C / D)
- [ ] Basic template norms from prior content have been surfaced and accepted, or explicitly marked N/A when no reference exists
- [ ] Metadata is complete enough to register into the relevant index
- [ ] For `library`, metadata is complete enough to register into the relevant index; for `project`, the portable template metadata is complete and no global registration is planned
- [ ] Marker `[TEMPLATE_BRIEF_CONFIRMED]` emitted on its own line after the echoed brief
Step 4 MUST NOT run until `[TEMPLATE_BRIEF_CONFIRMED]` has been emitted in the current conversation.
---
## Step 4: Create Template Directory + Invoke Template_Designer
## Step 4: Preflight Output + Invoke Template_Designer
> **Precondition**: `[TEMPLATE_BRIEF_CONFIRMED]` was emitted in Step 3. If not, return to Step 3.
Create the final template directory:
Select the final target from the confirmed output scope:
```bash
# library scope (default)
mkdir -p "skills/ppt-master/templates/<kind_dir>/<template_id>"
# project scope
# Do not create a nested template-ID directory. The initialized project already
# owns <target_project>/templates/, images/, and icons/.
```
> **Output location**: Templates go to `skills/ppt-master/templates/<kind>/<template_id>/`. The generated directory name must match the final template ID used in the relevant index.
| Scope | Template target | Required action before generation |
|---|---|---|
| `library` | `skills/ppt-master/templates/<kind_dir>/<template_id>/` | Preserve the existing directory creation behavior; the generated directory name matches the final template ID used in the relevant index |
| `project` | `<target_project>/templates/` | Run the full project preflight from "Output scope" above before any final-output write; stop on a non-empty template root or image/icon filename conflict |
The project preflight is atomic at workflow level: discover and settle every output filename first, check all destinations together, then begin generation. Do not partially write a project bundle and discover a later collision.
**Switch to the Template_Designer role** and generate per role definition. The role input is the finalized brief from Step 3 plus the analysis bundle from Step 1, including the accepted basic template norms.
@@ -318,42 +351,50 @@ Downstream, both template-adherence choices stay on `pptx_structure.mode: templa
2. **Renames each file** using the source-order-first convention `<NNN>_<page_type>.svg`, where `<NNN>` is the source-order index zero-padded to 3 digits and `<page_type>` is typically `cover` / `toc` / `chapter` / `content` / `ending` (fall back to `content` when the type cannot be confidently classified). Examples: `001_cover.svg`, `002_toc.svg`, `003_content.svg`, ..., `050_ending.svg`.
- Type A: derive `<page_type>` from `manifest.json.pageTypeCandidates`
- Type B: derive `<page_type>` from the source filename when it follows the PPT Master convention (`01_cover.svg``cover`, `03a_content_two_col.svg``content`); otherwise infer from page content or fall back to `content`
3. **Copies bundled assets** into the template directory and rewrites the `<image href="...">` paths inside each copied SVG to point at the local copies. Asset filenames may be renamed to semantic names (`brand_emblem.png` instead of `image3.png`) when it improves readability — but the rewrite must be consistent across every page.
3. **Routes bundled assets** according to the selected scope and rewrites every `<image href="...">` consistently. Asset filenames may be renamed to semantic names (`brand_emblem.png` instead of `image3.png`) when it improves readability.
- Type A: assets come from `<import_workspace>/assets/`
- Type B: resolve relative paths in source `<image href="...">` against the source SVG location and copy each unique asset; if the source already follows PPT Master conventions (assets co-located with SVGs in the same directory), copy the whole asset set and then rewrite paths
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
- Library scope: keep bundled assets inside the template package and point SVG references at those package-local files.
- Project scope: write bitmap assets to `<target_project>/images/`, point SVG references at `../images/<name>`, and keep other package assets under `<target_project>/templates/`.
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Library scope keeps one package copy under the template directory. Project scope writes the package/validation copy to `<target_project>/templates/icons/` and an identical runtime copy to `<target_project>/icons/`. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
5. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1. The §V Page Roster remains the content-fit index; explicit SVG metadata is the native Master/Layout contract. Mirror is only the template-creation replication mode; downstream generation still treats the finished package as a selectable / reusable roster, not as a forced 1:1 slide sequence.
Mirror mode does not simplify the visual page, but it still reconstructs layer ownership. The sprite-sheet preservation rule applies because flat SVGs already contain the original crop wrappers; do not flatten them when annotating the page.
**Expected outputs from this step** (full spec → [template-designer.md](../references/template-designer.md)):
1. `design_spec.md`**personality only**. Required sections: Template Overview, Color Scheme, Signature Design Elements, Page Roster (matching the actual SVG files on disk). Skip Typography / Assets / Placeholder Overrides when they would just restate defaults. Declare brief frontmatter for `register_template.py`. **Do not** restate generic SVG constraints, layout pattern libraries, font-size ratio bands, the canonical placeholder table, or content methodology — those are sourced from `shared-standards.md` / `design_spec_reference.md` / `strategist.md` and are already in the downstream reader's context. Full scope rule and skeleton: [template-designer.md §1](../references/template-designer.md#1-must-generate-design_specmd).
1. `design_spec.md`**personality only**. Required sections: Template Overview, Color Scheme, Signature Design Elements, Page Roster (matching the actual SVG files on disk). Skip Typography / Assets / Placeholder Overrides when they would just restate defaults. Declare portable brief frontmatter; `register_template.py` consumes it only in library scope. **Do not** restate generic SVG constraints, layout pattern libraries, font-size ratio bands, the canonical placeholder table, or content methodology — those are sourced from `shared-standards.md` / `design_spec_reference.md` / `strategist.md` and are already in the downstream reader's context. Full scope rule and skeleton: [template-designer.md §1](../references/template-designer.md#1-must-generate-design_specmd).
2. Page roster — see [Page Roster](../references/template-designer.md#page-roster) for `standard` / `fidelity` / `mirror` mode rosters, variant naming, and TOC handling
3. Placeholder vocabulary — pages should adopt the conventional names (`{{TITLE}}`, `{{CONTENT_AREA}}`, ...) when they fit. Full reference: [Placeholder Reference](../references/template-designer.md#4-placeholder-reference-canonical-convention-overridable-per-template). When a template style legitimately needs different vocabulary (consulting → `{{KEY_MESSAGE}}`, branded cover → `{{BRAND_LOGO}}`), declare a `placeholders:` block in `design_spec.md` frontmatter so the registrar and quality checker treat it as the template's authoritative contract. **Avoid** one-off indexed families such as `{{CHAPTER_01_TITLE}}` — use the indexed TOC pattern instead.
- `{{...}}` placeholders are the authoring vocabulary used to generate final slide content. Each emitted SVG also carries the native reconstruction contract: root `data-pptx-layout` / `data-pptx-layout-name`, direct Master/Layout layers, and direct semantic `data-pptx-placeholder` markers. Minimal structural `data-pptx-role` hints are added only when those specialized markers cannot express required behavior. Both strict and adaptive downstream use set `mode: template` and require `page_layouts` plus `pptx_layouts` for every page.
4. Template assets (optional) — Logos / PNG / JPG / reference SVG bundled with the template package
4. Template assets (optional) — library scope bundles Logos / PNG / JPG / reference SVG inside the package; project scope applies the `templates/` / `images/` / dual-icon routing defined above
---
## Step 5: Validate Template Assets
Set `<template_target>` to the selected output directory:
| Scope | `<template_target>` |
|---|---|
| `library` | `skills/ppt-master/templates/<kind_dir>/<template_id>` |
| `project` | `<target_project>/templates` |
```bash
# Replace <kind_dir> with "decks" or "layouts" depending on the kind decided above
ls -la "skills/ppt-master/templates/<kind_dir>/<template_id>"
ls -la "<template_target>"
```
Run SVG validation on the template directory:
```bash
python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/templates/<kind_dir>/<template_id>" --template-mode --format <canvas_format>
python3 skills/ppt-master/scripts/svg_quality_checker.py "<template_target>" --template-mode --format <canvas_format>
```
`--template-mode` makes the checker:
- glob `*.svg` in the template directory directly (templates do not live under `svg_output/`)
- skip `spec_lock.md` drift checks (templates do not ship a spec_lock)
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break the target kind's index)
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break the template contract and, in library scope, the target kind's index)
- emit advisory **warnings** when a page lacks a conventional placeholder — these are hints, not failures. Declare a `placeholders:` block in `design_spec.md` frontmatter to silence them when your template intentionally uses a different vocabulary
- require every SVG root to declare an output Layout and every SVG to contain at least one direct Master/Layout/placeholder declaration
- validate cross-page Master equality plus same-key Layout/placeholder equality
@@ -367,18 +408,25 @@ python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/temp
- [ ] `design_spec.md` frontmatter declares `canvas_format`, `canvas_width`, `canvas_height`, and `canvas_viewbox`; PPTX/SVG-backed templates also declare `source_canvas_width`, `source_canvas_height`, and `source_viewbox`
- [ ] SVG `viewBox` matches the declared canvas dimensions, not just the aspect ratio (for `ppt169`: `0 0 1280 720`; for `banner`: `0 0 1920 1080`); `width` / `height`, if written, equal it
- [ ] Placeholder names follow the canonical convention where applicable; templates with intentionally different vocabularies (e.g. `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`) should declare a `placeholders:` frontmatter block to silence advisory warnings
- [ ] Asset files referenced by SVGs actually exist in the template package
- [ ] Asset files referenced by SVGs exist at their resolved paths. In project scope, bitmap references resolve through `../images/`; no bitmap remains accidentally stranded in `templates/`
- [ ] `design_spec.md` frontmatter declares `native_structure_mode: template`; no `native_structure.json` or `source_template.pptx` is packaged
- [ ] Every SVG root declares `data-pptx-layout` and `data-pptx-layout-name`; direct Master/Layout elements and semantic placeholders obey the explicit paint-order contract. Structural `data-pptx-role` is used only when specialized metadata cannot express required package/page-number/animation behavior
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists directly under the package's `icons/` directory; do not add a separate illustration embedding script
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists under the package's `icons/` directory. Project scope also has the identical runtime copy under `<target_project>/icons/`; do not add a separate illustration embedding script
- [ ] For `fidelity` mode: every sprite-sheet asset retains its nested `<svg viewBox=...>` crop wrapper; no image whose file aspect differs from its on-page aspect was flattened to a bare `<image>`
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/<kind_dir>/<id>/*_*.svg | wc -l` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/<kind_dir>/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
- [ ] For `mirror` mode: file count equals source page count (type A: `<template_target>/*_*.svg` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg` count; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" "<template_target>"/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
This step is a **hard gate**. Do not register a template into the library index until validation passes.
This step is a **hard gate**. In library scope, do not register until validation passes. In project scope, do not hand the in-place bundle to the main pipeline until validation passes.
---
## Step 6: Register Template in Library Index
## Step 6: Register Template in Library Index (Library Scope Only)
Branch on the confirmed output scope:
| Scope | Action |
|---|---|
| `library` | Run the registrar below after Step 5 passes |
| `project` | Skip the registrar entirely. Do not edit `decks_index.json`, `layouts_index.json`, or any library README; continue to Step 7 with index status `Not registered (project-local)` |
Run the unified registrar with the kind flag; it derives the corresponding index entry from `design_spec.md` (frontmatter when present, prose fallback otherwise) plus the actual SVG file list:
@@ -454,7 +502,7 @@ README files describe each kind in prose only — they do not list templates. Di
## Step 7: Output Confirmation
`register_template.py` already printed a "Template Creation Complete" card during Step 6 — copy it verbatim into the conversation. The card includes the template name, path, category, primary color, index status, and the full SVG file roster (auto-collected from disk, so `fidelity`-mode variant pages and TOC pages are listed correctly without manual editing).
For `library`, `register_template.py` already printed a "Template Creation Complete" card during Step 6 — copy it verbatim into the conversation. The card includes the template name, path, category, primary color, index status, and the full SVG file roster (auto-collected from disk, so `fidelity`-mode variant pages and TOC pages are listed correctly without manual editing).
For a standard-mode template the card looks like:
@@ -478,6 +526,29 @@ For a standard-mode template the card looks like:
| `04_ending.svg` | Done |
```
For `project`, produce the same evidence-driven file roster from `<target_project>/templates/*.svg`, but use this scope-aware card:
```markdown
## Template Creation Complete
**Template Name**: <template_id> (<display_name>)
**Kind**: deck | layout
**Output Scope**: project
**Template Path**: `<target_project>/templates/`
**Bitmap Path**: `<target_project>/images/`
**Runtime Icon Path**: `<target_project>/icons/`
**Index Registration**: Not registered (project-local)
### Files Included
| File | Status |
|------|--------|
| `design_spec.md` | Done |
| `<actual-template>.svg` | Done |
```
The next main-pipeline Step 3 input is the exact `<target_project>/templates/` path. It is an in-place bundle: do not copy it onto itself and do not move its `../images/` assets again. Do not reuse this root from another project; choose library scope when cross-project portability is required.
---
## Color Scheme Quick Reference
@@ -496,6 +567,6 @@ For a standard-mode template the card looks like:
1. **SVG technical constraints**: See [shared-standards.md](../references/shared-standards.md) — do not restate them in the template's `design_spec.md`
2. **Color consistency**: All SVG files must use the same color scheme as `design_spec.md §II Color Scheme`
3. **Placeholder convention**: `{{}}` format only; default names listed in [Placeholder Reference](../references/template-designer.md#4-placeholder-reference-canonical-convention-overridable-per-template). Override per template via `placeholders:` frontmatter when needed.
4. **Discovery requirement**: A template directory is only discoverable after `register_template.py` has been run against it (Step 6)
4. **Discovery requirement**: A library template is discoverable only after `register_template.py` has been run against it (Step 6). A project-scoped template intentionally stays out of global discovery and is consumed by its explicit `<project>/templates/` path.
> **Full role specification**: [template-designer.md](../references/template-designer.md)
@@ -28,13 +28,17 @@ description: Customize default PPTX animations with per-slide and per-object tim
python3 skills/ppt-master/scripts/animation_config.py list-groups <project_path>
```
Output is one line per slide: `<slide_basename>: id1, id2, id3`chrome
groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` / `watermark` /
`logo` / `pagenumber`) are excluded because the exporter already pins them
to `none`. Use this as the source of truth when planning §3 and editing §4
Output is one line per slide: `<slide_basename>: id1, id2, id3`default
chrome groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` /
`watermark` / `logo` / `pagenumber`) are excluded from the ordinary target
list. Use this as the source of truth when planning §3 and editing §4
— **do not read the full scaffold file unless you need it as an editing
starting point**.
An explicit sidecar entry may override only the marker-free legacy id-name
heuristic. A group carrying `data-pptx-layer` or an explicit static
role/placeholder marker can never animate, even when it is named explicitly.
If `animations.json` does not exist and you want a starting file to edit:
```bash
@@ -99,7 +103,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| Effect | Behavior |
|---|---|
| `none` | Disable page transition |
| `none` | Remove visual page transition; timed advance may remain |
| `fade` | Neutral default for technical decks |
| `push` | Directional slide entry |
| `wipe` | Directional reveal |
@@ -112,9 +116,9 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| Field | Behavior |
|---|---|
| `effect` | One supported page transition effect |
| `duration` | Transition duration in seconds |
| `auto_advance` | Optional seconds before automatic slide advance |
| `effect` | One supported page transition effect; `none` removes only the visual effect |
| `duration` | Finite transition duration in seconds; must be greater than zero |
| `auto_advance` | Optional finite non-negative seconds before automatic slide advance; click remains enabled, and this field is valid with `effect: none` |
### 3.2 Supported In-Slide Animations
@@ -124,7 +128,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| `appear` | Visibility flip without motion |
| `fade` | Neutral entrance |
| `fly` | Fly in from bottom |
| `cut` | Cut in from left |
| `cut` | Legacy compatibility key; preserve its registered tuple exactly |
| `zoom` | Scale/zoom entrance |
| `wipe` | Wipe entrance |
| `split` | Split/barn entrance |
@@ -145,7 +149,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| `swivel` | Swivel entrance |
| `auto` | Map effect from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (hero/figure-/image/img-/kpi) cycle zoom/dissolve/circle/box/diamond/wheel for visual variation; unmatched ids cycle fade/wipe/fly/zoom |
| `mixed` | Legacy 16-effect cycle by group order (first group fades, rest cycle the larger pool) |
| `random` | Random effect per animated group, sampled from the legacy pool |
| `random` | Stable seeded effect per animated group; `--conversion-trace` records resolved effects when diagnostics are enabled |
**Start modes**:
@@ -166,18 +170,25 @@ even when the values match `defaults`. This makes per-page rhythm visible
at a glance without mentally merging the inheritance chain. Group-level
overrides remain opt-in — list only the groups that genuinely diverge from
the slide's `animation` block. Chrome groups stay out (the exporter pins
them to `none`).
them to `none` by default). Name a legacy chrome-like id only when the user
explicitly wants that content animated and the SVG has no explicit structural
layer, role, or placeholder marker.
`defaults` is still required: it supplies values for any slide not yet
present in `slides` (rare, e.g. mid-edit drafts) and acts as the single
source for the deck-wide baseline you copy into each slide block.
> Note: version-1 legacy sidecars may omit fields inside a listed slide and
> inherit them from `defaults`; the loader preserves that compatibility. This
> workflow writes complete new slide blocks, and validation still requires
> every current SVG stem to be present under `slides`.
`defaults` is still required: it supplies the legacy inheritance baseline and
the deck-wide values copied into every complete new slide block.
**Forbidden**:
- Omitting a slide that exists in `svg_output/` — every produced slide must appear under `slides`
- Writing a slide block with only `groups` and no `transition`/`animation`
- Enumerating every content group in a slide just to restate the slide-level default effect
- Listing chrome groups (`bg`, `*-header`, `*-footer`, `*-decor`, `nav`, `watermark`, `logo`, `pagenumber`)
- Listing a group with `data-pptx-layer` or an explicit static role/placeholder marker
- Listing a legacy chrome-like id without an explicit, reviewed intent to override the name heuristic
| Field | Behavior |
|---|---|
@@ -190,7 +201,7 @@ source for the deck-wide baseline you copy into each slide block.
| `groups.<id>.effect` | Object-specific entrance effect, `auto`, `mixed`, `random`, or `none` |
| `order` | Animation order only; does not change SVG layer order |
| `delay` | Extra seconds before this group starts in `after-previous` mode |
| `duration` | Per-group entrance duration in seconds; vary when semantic weight or pacing calls for it |
| `duration` | Per-group schedule duration in seconds; `appear` stays a 1ms visibility flip and uses this value only for subsequent `after-previous` spacing |
**Canonical example — every slide carries explicit transition + animation;
groups appear only when they diverge**:
@@ -233,7 +244,7 @@ groups appear only when they diverge**:
Notes:
- `02_agenda` repeats `defaults` verbatim — this is intentional under the new rule so per-page rhythm is auditable in one read.
- `03_market` and `07_hero_quote` only list the groups that diverge; `title`, `footer`, `bg`, `header` etc. are not enumerated.
- Chrome groups are never listed; the exporter pins them to `none`.
- Structural chrome groups are never listed. Legacy id-only chrome groups remain omitted unless an explicit reviewed override is required.
**Forbidden — SVG pollution**: do not add `data-*` animation attributes to SVG files. Animation customization belongs in `animations.json`.
@@ -251,7 +262,11 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
```
**Validation**: the exported native PPTX should reflect the object-level overrides. `--animation none` still disables all per-element animation and overrides `animations.json`.
**Validation**: the exported native PPTX must reflect the object-level overrides. `--animation none` still disables all per-element animation and overrides `animations.json`. Unknown animation effects/modes/triggers; boolean, NaN, or Infinity numeric values; non-positive durations; negative delay/stagger; invalid order; missing slides/groups; and structural-layer targets fail validation. Transition validation remains strict as well. None of these failures substitutes a fallback effect or silently drops a requested target.
Generated export performs semantic read-back per slide, comparing row order, trigger, target, resolved effect tuple, duration, and offset. It then validates timing-tree placement, `p:cTn` ids, and `p:spTgt` references across the packaged PPTX. Stable `random` choices appear in the conversion trace when export enables `--conversion-trace`. Narration merges audio into the existing timing tree and must preserve these rows.
Direct-PPTX routes are preserve-only for object animation: they compare the source object-animation fingerprint before and after allowed edits, run structural package validation, and do not write, normalize, or claim ownership of effects. See [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact compatibility and OOXML contract.
---
@@ -265,3 +280,4 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
- [x] Transition and object durations were chosen intentionally for the deck's pacing
- [x] `animation_config.py validate` passed
- [x] PPTX re-export completed with custom animation overrides
- [x] Generated animation semantic read-back and package validation passed
@@ -149,7 +149,7 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
If `notes_to_audio.py` errors with a missing dependency or missing provider API key, fix the prerequisite and re-run — do NOT swallow the error.
`--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
`--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. Narration changes the slide-advance layer only: the resolved page-transition effect remains unchanged, `-t none` remains visually transition-free, and narration advance disables click while using audio duration plus padding. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
---
@@ -17,7 +17,7 @@ Registry for standalone workflows referenced by `SKILL.md` and [`routing.md`](./
| `topic-research` | [`topic-research.md`](./topic-research.md) | Topic-only request with no substantive source material | Topic or requirements exist | Do not invent facts in the main pipeline | Before `SKILL.md` Step 1 | Source material suitable for Step 1 | Stops if usable source facts cannot be gathered |
| `template-fill-pptx` | [`template-fill-pptx.md`](./template-fill-pptx.md) | Raw PPTX template plus new material/topic; clone/fill native slides | Source PPTX plus content material or topic brief | No SVG generation; no `create-template` unless reusable package is requested | Workflow Step 1 | Native PPTX in project `exports/` | Stops if no PPTX or no fill material |
| `beautify-pptx` | [`beautify-pptx.md`](./beautify-pptx.md) | Existing PPTX should be re-laid out while preserving slide count/order and wording 1:1 | Single source PPTX | Not for merge/split/drop/reorder or re-outline | Workflow Step 1 | Regenerated 1:1 deck through SVG pipeline | Blocks on source-slide mapping and final confirmation gates defined in the workflow |
| `create-template` | [`create-template.md`](./create-template.md) | Build a reusable layout/deck template from a PPTX or design reference | Design source or explicit template-creation request | Not a one-off generated deck | Workflow Step 1 | Template directory with `design_spec.md` and assets | Stops after template creation until user supplies the directory path to main Step 3 |
| `create-template` | [`create-template.md`](./create-template.md) | Build a reusable layout/deck template from a PPTX or design reference, globally or for one initialized project | Design source or explicit template-creation request; project scope also requires an initialized target project | Does not fill final content or emit the generated deck | Workflow Step 1 | Library scope (default): indexed `templates/<kind>/<id>/`; project scope: in-place `<project>/templates/` bundle with bitmaps in `images/` and no global registration | Library scope stops until the user supplies the directory path to main Step 3; project scope may continue with its in-place path after validation |
| `create-brand` | [`create-brand.md`](./create-brand.md) | Build/extract a reusable brand identity preset | Logo/site/branded deck/PDF or explicit brand setup request | Not a layout/deck template roster | Workflow Step 1 | Brand directory under `templates/brands/<id>/` | Blocks on brand evidence and validation defined in the workflow |
| `resume-execute` | [`resume-execute.md`](./resume-execute.md) | Continue an existing split-mode project | Planning-session artifacts exist | Do not re-run planning | Execution-session entry | SVG generation, post-processing, export | Stops if `design_spec.md` or `spec_lock.md` is missing |
| `refine-spec` | [`refine-spec.md`](./refine-spec.md) | User explicitly opts into spec refinement before generation | Strategist confirmation stage completed | Do not enter by default | After Step 4, before Step 5/6 | Revised `design_spec.md` and `spec_lock.md` | Blocks for user review/approval before generation |
@@ -129,6 +129,18 @@ Present the plan to the user before generating notes or audio:
**⛔ BLOCKING**: Stop here and wait for explicit user confirmation. Do not generate notes, generate audio, or patch the PPTX until the user confirms the module plan.
**Transition/timing ownership**:
| Confirmed state | Enter transition | Slide advance |
|---|---|---|
| Transitions enabled with an effect | Replace with that exact effect and duration | Preserve unless timings is enabled |
| Transitions disabled with a non-`none` configured effect | Preserve the source effect, including unknown `AlternateContent` | Preserve unless timings is enabled |
| Explicit `none` | Remove the visual effect | Preserve, or write timing-only advance when timings is enabled |
| Timings enabled with audio | Keep the resolved enter policy | Use audio duration plus narration padding; click disabled |
| Timings disabled | Apply the confirmed enter policy only | Do not run `ffprobe`; do not add/change `advTm` or `useTimings` |
**Hard rule — no silent downgrade**: a requested supported effect must be written with its established direction/variant attributes. Unknown requested effects fail; unknown source effects are preserved when the transition module is disabled.
After confirmation, update `<project>/analysis/enhancement_plan.json`:
```json
@@ -254,9 +266,14 @@ python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "<project>" \
--transition fade \
--transition-duration 0.5 \
--narration-padding 0.4 \
--apply-transition-without-audio \
--overwrite
```
Without `--apply-transition-without-audio` (or the matching confirmed-plan
field), the resolved enter policy is applied while processing slides with
audio; slides without audio keep their source transition.
Patch scope:
| Package area | Append/update |
@@ -266,6 +283,7 @@ Patch scope:
| `ppt/slides/_rels/slideN.xml.rels` | Relationships for notes/audio/media/poster |
| `ppt/media/` | Narration audio and transparent poster |
| `ppt/slides/slideN.xml` | Hidden autoplay audio shape and page timing |
| `ppt/presProps.xml` | `showPr useTimings=1` only when this run writes automatic slide advance |
| `[Content_Types].xml` | Required content types |
**Hard rule**: Do not modify existing slide shapes, text bodies, images, chart data, master/layout parts, or existing non-target relationships.
@@ -291,6 +309,8 @@ Check:
| Notes | Present on intended slides |
| Audio media | Present under `ppt/media/` when generated |
| Auto-play | Narrated slides advance by audio duration |
| Transition | Requested effect remains exact; preserved `AlternateContent` keeps its primary and fallback branches |
| Timings disabled | Source `advTm` and package `useTimings` are not changed |
```markdown
## ✅ Native PPTX Enhancement V1 Complete
@@ -26,7 +26,7 @@ The user **explicitly asks** to refine / review / revise the spec before generat
## Step 1: Produce the full spec
Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§IXI) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it.
Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§IX) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it.
---
@@ -42,7 +42,7 @@ Route selection authority for PPT Master. Use this file before entering the main
| Topic only, no source facts | User supplies only a topic name or requirement and no substantive source material | [`topic-research`](./topic-research.md), then main `SKILL.md` pipeline | Direct main pipeline with invented facts | Web/source gathering is allowed or user supplies facts | Research material becomes source input for Step 1 | Stop if facts cannot be gathered and the user supplies no source |
| Source material can be reworked into a new story | PDF/DOCX/URL/Markdown/text/conversation content, or PPTX treated as content | Main `SKILL.md` pipeline | Direct PPTX edit workflows | Source content exists or is available in conversation | `design_spec.md`, `spec_lock.md`, `svg_output/`, exported PPTX | Stop at Step gates when required artifacts are missing |
| PPTX as re-architectable source | User allows page count/order/outline to change, or asks to split/merge/drop/reorder slides | Main `SKILL.md` pipeline with `ppt_to_md.py` plus PPTX intake | [`beautify-pptx`](./beautify-pptx.md) | PPTX source exists | Markdown content plus `analysis/source_profile.json`; Strategist may re-outline | Stop if user requires exact 1:1 page preservation |
| Explicit template directory path | User provides a directory containing `design_spec.md` with `kind: brand`, `kind: layout`, or `kind: deck` | Main `SKILL.md` Step 3 | Fuzzy template lookup by bare name | Path resolves and frontmatter kind is valid | Template assets copied/fused into `<project>/templates/` | Stop if the path does not exist or lacks valid `design_spec.md` |
| Explicit template directory path | User provides a directory containing `design_spec.md` with `kind: brand`, `kind: layout`, or `kind: deck` | Main `SKILL.md` Step 3 | Fuzzy template lookup by bare name | Path resolves and frontmatter kind is valid | External assets are copied/fused into `<project>/templates/`; the same project's own `templates/` path is consumed in place | Stop if the path does not exist or lacks valid `design_spec.md` |
---
@@ -53,7 +53,7 @@ Route selection authority for PPT Master. Use this file before entering the main
| Raw PPTX template plus new material/topic | "Use this PPT template to generate a PPTX", "fill this deck", "replace copy", native slide shell reuse | [`template-fill-pptx`](./template-fill-pptx.md) | Main SVG pipeline directly from raw PPTX template | Source PPTX plus content material or topic brief | New native PPTX in `exports/`, cloned/patched by OOXML | Stop if user instead wants a reusable template package |
| Existing PPTX, preserve page split and wording | "Beautify", "re-layout", "make more professional" with same slide count/order and verbatim text | [`beautify-pptx`](./beautify-pptx.md) | Main pipeline if page count/order changes | Single source PPTX | Regenerated deck through SVG pipeline, one source slide to one output slide | Stop if user asks to split/merge/drop/reorder |
| Finished PPTX, native enhancement only | Add notes, recorded narration, auto-advance, transitions, or stable-layout metadata | [`native-enhance-pptx`](./native-enhance-pptx.md) | SVG regeneration | Finished PPTX exists; content/layout should stay stable | Patched PPTX through direct OOXML | Stop if user asks for visual redesign |
| PPTX/reference design should become a reusable template | "Create a template", "make reusable", "build template from this deck/design" | [`create-template`](./create-template.md) | `template-fill-pptx` one-off fill | PPTX or design reference exists | Template directory under `templates/<kind>/<id>/` | Stop after creation; main pipeline resumes only when user supplies the generated directory path |
| PPTX/reference design should become a reusable template | "Create a template", "make reusable", "build template from this deck/design", including a template for one named initialized project | [`create-template`](./create-template.md) | `template-fill-pptx` one-off fill | PPTX/design reference exists, or the user gives an explicit template-creation brief; project output additionally requires an initialized target project | Library scope (default): `templates/<kind>/<id>/` plus index registration; project scope: direct `<project>/templates/` thin bundle with no global registration | Stop after library creation until the user supplies its directory path; project scope may resume main Step 3 with the in-place `<project>/templates/` path |
**Hard rule**: Raw PPTX template plus "generate PPTX" routes to `template-fill-pptx` by default. A raw PPTX is not a Step 3 template until `create-template` has produced a reusable template directory.
@@ -93,6 +93,7 @@ Read `<project_dir>/analysis/<stem>.slide_library.json` (intake prefixes per-dec
| `slides[].slots[].role` | Title / body / label candidate hint |
| `slides[].tables[]` | Native PowerPoint tables with `table_id`, row / column counts, and per-cell coordinates + text |
| `slides[].charts[]` | Native PowerPoint charts with `chart_id` |
| `slides[].diagrams[]` | SmartArt layout, semantic nodes, hierarchy/connections, geometry, and extraction status; inventory-only |
**Selection rule**: Pick pages by content fitness, not by source order alone. A source page is useful only if its visible structure can carry the target message without heavy redesign.
@@ -107,6 +108,9 @@ A page's layout already encodes a rhetorical shape — a single hero statement,
| `slots[].geometry` | Estimate whether each text slot is a short label, medium title, body block, caption, or decorative number |
| `slots[].text_metrics.font_size_px` | Estimate text capacity together with geometry; larger type means fewer safe characters |
| `slots[].text_summary` | Read the source page's original rhetorical pattern, not its literal placeholder wording |
| `diagrams[].layout` + `nodes` | Understand the SmartArt's source meaning; template-fill preserves it unchanged and cannot map new text into it |
**SmartArt boundary**: A selected source slide keeps its original native SmartArt parts. `check-plan` warns because the fill plan cannot replace SmartArt node text; choose another layout unless the original diagram content is intentionally retained, or explicitly accept the warning.
**Hard rule**: The target story controls output order. Source slides may move forward, move backward, be omitted, or be reused several times when their layout matches multiple target messages. Never treat source slide order as a default outline unless the user explicitly asks to preserve it.
@@ -192,7 +196,7 @@ The plan structure:
| `layout_rationale` | Human review aid for page selection. Include `layout_pattern`, `why_fit`, and `risk`; it is not a mechanical checker gate. |
| `accepted_warnings` | Optional audit trail for warnings the user or agent explicitly accepts. `check-plan` warnings remain non-blocking; errors must be fixed. |
| `notes` | Optional spoken speaker notes for the filled slide — see **Speaker notes** below; write prose, not a copy of the on-slide text |
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to strip it, or `{ "effect": "push", "duration": 0.6 }` |
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to remove the visual effect, `keep` to preserve the source, or an object such as `{ "effect": "push", "duration": 0.6, "advance_after": 5 }` |
| `replacements` | Target by `slot_id` whenever possible; `shape_id` and `shape_name` are fallback selectors |
| `table_edits` | Optional native table cell edits; target by `table_id` whenever possible and use zero-based `row` / `col` |
| `chart_edits` | Optional native chart data edits; target by `chart_id`, set `categories`, and provide one or more `series` |
@@ -249,6 +253,7 @@ Interpret the report:
| Short label exceeds visual width | Rewrite shorter or choose a layout with a larger label slot; do not shrink font by default |
| Title too long | Rewrite first; only use font-size changes as a last resort |
| Body much longer than source slot | Compress, split across another selected page, or choose a larger source page |
| SmartArt source content remains unchanged | Pick another source slide unless the original SmartArt wording is intended; otherwise record the accepted warning |
| Missing target | Fix `slot_id` / `shape_id`; do not apply the plan |
`check-plan` emits stable `code` fields in its JSON results so warnings can be tracked without parsing message text. Warnings are advisory and do not fail the command; record any intentionally accepted warning in `accepted_warnings` when it matters for review. Errors are blocking and must be fixed before apply.
@@ -267,7 +272,7 @@ Run:
python3 skills/ppt-master/scripts/template_fill_pptx.py apply "<project_dir>/sources/<source.pptx>" "<project_dir>/analysis/fill_plan.json" -o "<project_dir>/exports/<output.pptx>"
```
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), because most native templates ship an empty `<p:transition/>` that renders as *no* motion. Override the default with `--transition <effect>` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration <seconds>`; pass `--transition none` for no motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field in the plan overrides whatever the CLI selects for that slide.
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), preserving the v1 route contract. Override it with `--transition <effect>` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration <seconds>`; pass `--transition none` for no visual motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field overrides the CLI. `advance_after` keeps click advance enabled and adds timed advance; it also works with `none` (timing-only transition) and `keep` (source effect preserved, Choice/Fallback timing updated together).
`apply` appends a timestamp automatically. For example, `-o "<project_dir>/exports/demo.pptx"` writes `demo_YYYYMMDD_HHMMSS.pptx`. If the filename already ends with `_YYYYMMDD_HHMMSS`, it is left unchanged.
@@ -278,12 +283,12 @@ The script:
| Clones selected source slides | Original slide design, relationships, images, layouts, and animations are preserved where PowerPoint supports them |
| Replaces text nodes | Text frames remain editable in PowerPoint |
| Writes `notes` fields | Speaker notes are embedded as native PowerPoint notes slides |
| Applies `--transition` / per-slide `transition` | Populates each slide's `<p:transition>` with a native PowerPoint page transition |
| Applies `--transition` / per-slide `transition` | Applies the requested visual-transition and slide-advance policy; `keep` may preserve no carrier and `none` may remove it |
| Rebuilds presentation slide list | Output deck contains only the planned slide sequence |
| Adds timestamp to PPTX filename | Matches the main SVG-to-PPTX export convention |
| Drops orphaned source parts | Output carries only the selected pages and the layouts / media / charts they still reference (reachability prune) |
**Animation policy**: Template-fill preserves each cloned slide's existing object animation XML (the SVG pipeline's generated object animation defaults are not applied here). Page transitions are the one motion layer this workflow writes directly, and `apply` adds a `fade` transition by default so a filled deck is never left with the template's empty no-motion transitions; change it with `apply --transition` / a per-slide `transition` field, or opt out with `--transition keep` (preserve source) or `--transition none`. If the user asks to change object-level animation order / timing / effects, treat that as a separate direct-PPTX animation customization task.
**Animation policy**: Template-fill preserves each cloned slide's existing object animation XML (the SVG pipeline's generated object animation defaults are not applied here). Page transitions are the one motion layer this workflow writes directly, and `apply` adds a `fade` transition by default; change it with `apply --transition` / a per-slide `transition` field, or opt out with `--transition keep` (preserve source) or `--transition none`. `keep` preserves direct and `mc:AlternateContent` transition effects without converting unknown effects to `fade`; explicit replacement removes the old logical carrier before writing one new carrier. If the user asks to change object-level animation order / timing / effects, treat that as a separate direct-PPTX animation customization task.
---
@@ -334,11 +339,13 @@ If the extracted text is correct but visual overflow is likely, reduce the text
| Replace text in existing text frames | Supported |
| Edit native PowerPoint table cell text | Supported |
| Edit native PowerPoint chart categories / series data | Supported |
| Read SmartArt node text / hierarchy / layout | Supported in intake and planning |
| Preserve existing native SmartArt unchanged | Supported by recursive private-part cloning |
| Preserve original visual design | Supported by cloning slide parts directly |
| Page-to-page transitions | Supported via `apply --transition` or per-slide `transition` |
| Replace images | Not in v1 |
| Object-level entrance animations | Not in v1; preserved from source only, set as a separate task |
| Edit chart formatting / axes / legend layout | Not in v1 |
| Edit SmartArt deeply | Not in v1 |
| Edit or generate native SmartArt | Not supported; regenerated visual routes use ordinary editable shapes |
| Automatic visual overflow detection | Not in v1; use text-capacity judgment from the library slots |
| Material-divergence reshaping (§c content strategy) | Not applicable — this workflow fills text into existing slots, it does not author an outline from a source, so the main pipeline's `content_divergence` free-text field has no role here |