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-08-14 00:02:15 +08:00
parent f5f58fcfbc
commit 4d74ee576c
74 changed files with 4482 additions and 469 deletions
@@ -2,8 +2,8 @@
"sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"commit": "613d7f0402fb51bdde0edb6b01853b391a06b765",
"commit": "c72984e4392c7a154e55c11dbf445f01ce5c35d4",
"adapter": "codex-plugin",
"sourcePath": "plugins/caveman",
"syncedAt": "2026-08-13T08:38:10Z"
"syncedAt": "2026-08-13T15:59:59Z"
}
@@ -2,8 +2,8 @@
"sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"commit": "84fdeffd12f2ee307994d1eb6feb48173b6e0502",
"commit": "8b78b531ab965735c5dc74f6f7a219e1e37326df",
"adapter": "skill-collection",
"sourcePath": "skills/productivity",
"syncedAt": "2026-08-13T08:38:10Z"
"syncedAt": "2026-08-13T15:59:59Z"
}
@@ -1,6 +1,7 @@
---
name: grill-me
description: "在用户明确要求严格追问、压力测试或彻底澄清计划、设计、决策或想法时使用。"
disable-model-invocation: true
---
Run a `/grilling` session.
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-08-13T08:44:49Z"
"syncedAt": "2026-08-13T16:02:14Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "fef4c28bba9f080fce9687a7a8ee7ac3784de57e",
"commit": "9e1a290955a9177a97a1d44f5305b1236410216a",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-08-13T08:38:10Z"
"syncedAt": "2026-08-13T15:59:59Z"
}
+9 -5
View File
@@ -55,7 +55,11 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this
<a href="./docs/roadmap.md"><strong>Roadmap</strong></a>
</p>
<h3 align="center">Download the new <a href="https://raw.githubusercontent.com/hugohe3/ppt-master/main/examples/ppt169_attention_is_all_you_need/exports/attention_is_all_you_need_narrated.pptx">narrated <em>Attention Is All You Need</em> deck</a> — play it in PowerPoint and every slide reads itself out loud. That's just the tip of what PPT Master can do.</h3>
<p align="center">
<sub>The narrated deck and gallery below were generated in <strong>May 2026</strong> with Claude Opus 4.7 + <code>gpt-image-2</code> — one pass each, no manual polish.</sub>
</p>
<h3 align="center">Download the <a href="https://raw.githubusercontent.com/hugohe3/ppt-master/main/examples/ppt169_attention_is_all_you_need/exports/attention_is_all_you_need_narrated.pptx">narrated <em>Attention Is All You Need</em> deck</a> — play it in PowerPoint and every slide reads itself out loud. That's just the tip of what PPT Master can do.</h3>
<table>
<tr>
@@ -95,7 +99,7 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this
</table>
<p align="center">
<sub>All examples above were generated in a single pass, with no manual polish (Claude Opus 4.7 + <code>gpt-image-2</code>). Downloading any .pptx and opening it in PowerPoint is the fastest way to see what it can really do.<br/><a href="https://hugohe3.github.io/ppt-master/">Flip through all examples online →</a> · <a href="./examples/"><code>examples/</code> directory</a> · <a href="./docs/why-ppt-master.md">Why PPT Master?</a></sub>
<sub>Downloading any .pptx and opening it in PowerPoint is the fastest way to see what it can really do.<br/><a href="https://hugohe3.github.io/ppt-master/">Flip through all examples online →</a> · <a href="./examples/"><code>examples/</code> directory</a> · <a href="./docs/why-ppt-master.md">Why PPT Master?</a></sub>
</p>
---
@@ -254,7 +258,7 @@ Then install dependencies:
pip install -r requirements.txt
```
**Option B — Download ZIP** (no Git required; best for a quick trial): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip, and install dependencies with `pip install -r requirements.txt`. A ZIP has no Git history, so it can't `git pull` — see Updating Later. If that download is too large or fails, grab the skill-only package `ppt-master-skill-*.zip` (~50 MB, fully functional but without the bundled example decks) from the [Releases](https://github.com/hugohe3/ppt-master/releases) page instead.
**Option B — Download ZIP** (no Git required; best for a quick trial): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip, and install dependencies with `pip install -r requirements.txt`. A ZIP has no Git history, so it can't `git pull` — see Updating Later. If that download is too large or fails, grab the skill-only package `ppt-master-skill-*.zip` (~56 MB, fully functional but without the bundled example decks) from the [Releases](https://github.com/hugohe3/ppt-master/releases) page instead.
#### Updating Later
@@ -322,9 +326,9 @@ The AI handles everything — content analysis, visual design, SVG generation, a
You: Quickly generate a 5-page deck from projects/q3-report/sources/report.pdf — no need to confirm with me
```
Whatever you state explicitly is followed; whatever you leave unspecified the agent decides on its own instead of asking. It still converts sources, fills factual gaps, applies the shared visual baseline, and uses images/icons/native shapes/charts/tables/formulas as needed — it drops interaction and durable planning, not presentation capability. It is one-pass and non-resumable, and there is no `svg_final/` preview. Full guide → [Quick mode](./docs/getting-started.md#quick-mode).
Whatever you state explicitly is followed; whatever you leave unspecified the agent decides on its own instead of asking. It still converts sources, fills factual gaps, applies the shared visual baseline, and uses images/icons/native shapes/charts/tables/PowerPoint-native inline or block formulas as needed — it drops interaction and durable planning, not presentation capability. It is one-pass and non-resumable, and there is no `svg_final/` preview. Full guide → [Quick mode](./docs/getting-started.md#quick-mode).
> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The default Generate flow runs `finalize_svg.py` and produces self-contained previews in `svg_final/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit [quick generation](./skills/ppt-master/workflows/profiles/quick-generate.md) skips Strategist, confirmation, `design_spec.md`, `spec_lock.md`, and `finalize_svg.py`: whatever you state explicitly is followed, and whatever you leave unspecified the agent decides directly in one active context. It still converts sources, researches factual gaps, applies shared mode/style/aesthetic guidance, prepares required images/icons/formulas, considers native shapes and data visualizations, hand-authors SVG, passes the lockless Quick final quality check, and exports the final PPTX. It writes no substitute plan and cannot resume after context loss. Ordinary export capabilities remain available as needed, including native chart/table replacement, notes, motion, narration, and diagnostics; notes, custom object animation, and narration start off, and the agent may enable them when the request or deck needs them. A default-path Quick export writes the normal postflight report and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; an explicit output path keeps the ordinary no-backup behavior. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both chart/table export variants are editable—the distinction is the PowerPoint object model, not editability itself.
> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The default Generate flow runs `finalize_svg.py` and produces self-contained previews in `svg_final/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit [quick generation](./skills/ppt-master/workflows/profiles/quick-generate.md) skips Strategist, confirmation, `design_spec.md`, `spec_lock.md`, and `finalize_svg.py`: whatever you state explicitly is followed, and whatever you leave unspecified the agent decides directly in one active context. It still converts sources, researches factual gaps, applies shared mode/style/aesthetic guidance, prepares required images/icons, authors formulas as native inline or block markers, considers native shapes and data visualizations, hand-authors SVG, passes the lockless Quick final quality check, and exports the final PPTX. It writes no substitute plan and cannot resume after context loss. Formula markers compile their LaTeX payload to editable OMML for PowerPoint 2010+; block groups and inline `<tspan>` runs keep ordinary SVG previews that are replaced during export. Formula rendering and editability in Keynote, WPS, LibreOffice, and other non-PowerPoint clients are not part of this contract. Ordinary export capabilities remain available as needed, including native chart/table replacement, notes, motion, narration, and diagnostics; notes, custom object animation, and narration start off, and the agent may enable them when the request or deck needs them. A default-path Quick export writes the normal postflight report and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; an explicit output path keeps the ordinary no-backup behavior. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both chart/table export variants are editable—the distinction is the PowerPoint object model, not editability itself.
> **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": "bcc5762d6c770025f37ca459f5d9ea96f1783fc1",
"commit": "e8323bfaee249cffe1301ec40fca5875eb544d46",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-08-13T08:38:10Z"
"syncedAt": "2026-08-13T15:59:59Z"
}
@@ -2,7 +2,7 @@
name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata:
version: "4.5.0"
version: "4.7.0"
copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master"
@@ -7,10 +7,11 @@ profile may explicitly omit an artifact without erasing its facts.
**Quick Generate projection**: Quick omits confirmation, Design Spec, and lock.
Its current main agent reads source/analysis facts, keeps routine decisions in
active context, and prepares the selected images/icons/formulas plus required
operational manifests before SVG authoring. It may also realize charts, tables,
native shapes, and other ordinary authoring capabilities directly from that
context. Exact template workspace roots supplied for the run are validated and
active context, and prepares the selected images/icons plus required
operational manifests before SVG authoring. It realizes native formulas directly
from exact mathematical content under [`native-formula.md`](./native-formula.md),
and may also realize charts, tables, native shapes, and other ordinary authoring
capabilities directly from that context. Exact template workspace roots supplied for the run are validated and
installed directly; Quick reads only that project-local state and creates no
Confirm UI selection artifacts. Those artifacts retain their factual/provenance
roles. Quick writes the same final SVG quality provenance and package postflight
@@ -38,8 +39,8 @@ history, or resumable planning state. Context loss restarts the Quick run.
| `spec_lock.md` | Execution anchor and routing contract | Machine-readable stable color/type roles, icons, images, page rhythm, charts, `template_reuse_scope`, and the route's PowerPoint structure mode; mirror/layout template routes additionally own input prototypes, the Master roster, and the complete page-to-Master/Layout mapping | Strategist authors the route-specific anchors from the audited Design Spec plus current project/page/template context. Executor retains the complete lock once per valid execution context; local uncertainty consults that retained copy before the owning Design Spec fragment. Sparse page-local color/font garnish needs no lock row; a recurring semantic role or new adaptive Layout identity requires Strategist repair before reuse. |
| `project_manager.py page-context` stdout | Derived on-demand page context | Read-only model-facing anchor set + current-page delta + fingerprints for large references | Use only for explicit diagnostics/telemetry or an unresolved page/template/chart path-SHA projection. Never edit or persist it as a replacement source of truth, and never run it as a routine pre-page gate. `global` is a bounded anchor set, not a whitelist. `reference_set` carries path/SHA/load policy but never appends reference payloads. |
| `analysis/page-context/P<NN>.usage.json` | Derived optional context telemetry | Measured on-demand page-context size plus hashes of owning inputs/references | `page-context --record-usage` deterministically replaces only the invoked page's snapshot; `page-context-report` summarizes existing snapshots. Telemetry may be partial. Use token data to evaluate context cost, never as content or an execution contract. |
| `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Default Step 5 or Quick Generate resource preparation writes here; `analysis/image_analysis.csv` derives from current contents |
| `images/image_prompts.json`, `image_queries.json`, `image_sources.json`, `formula_manifest.json` | Conditional resource contracts | AI/web/formula execution status and provenance | Create only for a triggered path, including Quick. They guide preparation/attribution, never page design. |
| `images/` | Runtime image pool | User, extracted, AI, web, slice, and EMF/WMF assets | Default Step 5 or Quick Generate resource preparation writes here; `analysis/image_analysis.csv` derives from current contents |
| `images/image_prompts.json`, `image_queries.json`, `image_sources.json` | Conditional resource contracts | AI/web execution status and provenance | Create only for a triggered path, including Quick. They guide preparation/attribution, never page design. |
| `icons/` | Prepared project icon pool | Bundled icons copied by `icon_sync.py` plus user-provided, template, imported, or custom icon SVGs | SVG authoring may choose any icon in this project-local pool per page; `spec_lock.icons.inventory` indexes the default plan's curated synced bundled pool rather than assigning page usage or defining an exhaustive whitelist. Exporter global fallback is legacy compatibility only. |
| `${SKILL_DIR}/templates/{brands,styles,layouts,decks}/*_index.json` | Library discovery indexes | The complete registered option source for Default Stage-1 template selection and chat listing | The UI server or chat branch reads these indexes only to populate the Stage-1 choice, after the communication recommendation is authored. Never scan kind directories to add options or use index summaries as Stage-1 planning evidence. Derive a library root from kind + entry id. Exact unregistered roots remain explicit inputs. Quick does not read the catalog. |
| `templates/` | Project template reference | Stage-1-confirmed non-free selection or Quick direct-input installed specs, one file per selected workspace, optional Layout/Deck SVG prototypes, and non-image assets | Default template-aware Strategist work from Stage 2 onward, Quick's current agent before direct authoring, and every later role read this project-local state only, never the library/external installation root. The active planner reads every installed template Design Spec and an actual SVG roster only for Layout/Deck; Brand and Style are intentionally roster-free. Continuous Executor reuses that context; fresh Executor reads the Design Spec once and each selected complete SVG, when any, only before first use or after its SHA changes. |
@@ -54,7 +55,7 @@ history, or resumable planning state. Context loss restarts the Quick run.
| `confirm_ui/template_options.json`, `template_selection.json`, `template_handoff.json` | Default UI template-selection sidecar | Agent-authored candidate input, user-confirmed selection written beside the Stage-1 result, and agent-authored installation/free-design completion handoff | Step 3 writes options without launching UI. The Stage-1 submission writes `template_selection.json` alongside `result.json`; template choices never enter the Strategist contract. After installation/free-design closure, `--complete-template-selection` writes the bound handoff. Stage 2 is exposed only after that handoff and a fresh recommendation. Chat/delegated flows retain equivalent state without fabricating UI receipts; Quick creates none. |
| `confirm_ui/recommendations.stage1.json`, `.stage2.json` | Confirmation proposals | Template-independent communication contract, then template-aware complete solution plus production mechanics | Author the Stage-1 communication recommendation without using candidate indexes or workspaces as evidence; candidate display state may be prepared independently. Its page confirms communication plus template mode/selection in one submission. Create Stage 2 only after the selection is installed or free design closes and the handoff/equivalent state is ready. `template_application` decides only how to use installed project-local state. The active unconfirmed stage may be overwritten; normal progression leaves confirmed Stage 1 intact. |
| `confirm_ui/result.json` | Confirmation result | Persisted user-confirmed input evidence | Generate Step 4 reads the final object once into active context; Strategist consumes it completely into `design_spec.md`. Normal downstream work does not reopen it; fresh recovery may read it once when no retained final state exists. |
| `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 |
| `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design, including each native formula's exact LaTeX marker and ordinary SVG preview | 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. Formula export replaces only its explicit marker subtree under [`native-formula.md`](./native-formula.md). |
| `notes/total.md` | Conditional speaker-note source | Complete notes before splitting | Step 6 writes only when the effective Speaker Notes outcome is enabled; Step 7.1 splits |
| `notes/slide_*.md` | Conditional split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` only when speaker notes are enabled |
| `svg_final/` | Default-only derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Default rebuilds it from `svg_output/` with `finalize_svg.py`; Quick omits it. Never use it as a supported PPTX source. |
@@ -12,7 +12,9 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
| The mandatory per-page Structure decision finds qualitative geometry | [`executor-structure.md`](./executor-structure.md) |
| Any semantic cell grid, including a table-family reference | [`executor-table.md`](./executor-table.md) |
| A page will use a preset pattern fill or an independent object is explicitly selected as native-ready | [`native-data-interface.md`](./native-data-interface.md) before emitting the pattern or replacement metadata |
| Any image/formula | [`executor-image.md`](./executor-image.md) + [`image-layout-spec.md`](./image-layout-spec.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) + [`svg-image-embedding.md`](./svg-image-embedding.md) |
| Any image | [`executor-image.md`](./executor-image.md) + [`image-layout-spec.md`](./image-layout-spec.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) + [`svg-image-embedding.md`](./svg-image-embedding.md) |
| Any nontrivial mathematical expression | [`native-formula.md`](./native-formula.md) |
| Any external or same-deck click hyperlink | [`native-hyperlinks.md`](./native-hyperlinks.md) |
| Any placed image is `Status: Sourced` or its filename has an `image_sources.json` record | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` |
| Effective Speaker Notes outcome is enabled after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) |
@@ -20,7 +22,7 @@ Evaluate branches from each object's actual information model, not only from a C
> Narrative skeleton and visual aesthetic come from the locked values routed through the [`modes/`](./modes/_index.md) and [`visual-styles/`](./visual-styles/_index.md) indexes. Executor reads one locked preset file or only the exact `*_references` of a custom synthesis; an unreferenced novel custom reads none. [`shared-standards-core.md`](./shared-standards-core.md) supplies the technical boundary plus the fallback visual-quality and leading defaults when those authorities are silent.
**Hard rule — Shape-first page authority**: Every visible object intended for the exported slide MUST exist in the final page SVG or be explicitly referenced by it. Templates and `spec_lock.md` guide construction; they are not export-time overlays for missing visible content. Optional native Chart/Table metadata belongs to an independently selected object and never replaces this visible fallback during authoring; [`native-data-interface.md`](./native-data-interface.md) alone defines that metadata and its export activation.
**Hard rule — Shape-first page authority**: Every visible object intended for the exported slide MUST exist in the final page SVG or be explicitly referenced by it. Templates and `spec_lock.md` guide construction; they are not export-time overlays for missing visible content. Optional native Chart/Table metadata belongs to an independently selected object and never replaces this visible fallback during authoring; [`native-data-interface.md`](./native-data-interface.md) alone defines that metadata and its export activation. Native formula markers require a matching SVG preview; export replaces only it under [`native-formula.md`](./native-formula.md).
**Hard rule — flat PowerPoint structure**: Free-design, brand-only, Style-only, and every `template_reuse_scope: style` project use `pptx_structure.mode: flat`: write no root Master/Layout identity, `data-pptx-layer`, or `data-pptx-placeholder`; every visible object remains Slide-local, and the root declares exactly one canonical `data-pptx-page-role` (`cover` / `toc` / `section` / `content` / `ending`). A Style workspace supplies reusable communication/design direction, composition rhythm, and information-expression defaults without page prototypes. Its identity-adjacent color, typography, icon, and image defaults yield to the final Brand/Deck identity and confirmed project lock. When a Style is installed alongside Layout/Deck, it changes only Direction / method and follows the resolved non-Style structure route. Export materializes one clean project-owned Master plus one Blank Layout from the current lock. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by specialized metadata; the marked element uses a stable unique `id`. See [`semantic-svg.md`](./semantic-svg.md).
@@ -115,7 +117,7 @@ Apply the content-vs-expression contract above within the selected reading mode.
- **Sparse display-size exception**: a short non-structural Hero/Display element may use one undeclared size outside all anchor bands at most twice across the deck without a lock row. The third occurrence makes that size recurring: stop and return to Strategist to name the role in the Design Spec and `spec_lock.md`, then read back and validate the affected fragments before reuse. This exception never applies to titles, body copy, subtitles, annotations, footnotes, captions, data labels, or card copy, and nearby sizes must not be introduced to imitate one recurring treatment.
- **Outside-band recovery**: for structural text, reflow geometry and use the declared role band locally. For a sparse display occurrence, keep the unitless value and verify that its deck-wide count remains at most two. Never flatten a justified distinction or add a role merely to silence the checker. Mirror pages preserve exact source typography as inherited input.
- Images MUST reference files listed under `images`; no invented filenames
- Formula PNGs are images with `Acquire Via: formula`; place a `Rendered` file only from its listed path, use the normal placeholder for `Needs-Manual`, and never recreate the formula as text.
- For math, load [`native-formula.md`](./native-formula.md): simple notation stays text; one-line structural prose uses inline; matrices, multiline derivations, or standalone high structure use block. Keep exact LaTeX plus preview; never use an image.
Return upstream before any derived/accent identity becomes recurring or structural, or when an undeclared display size reaches its third occurrence, then update the retained context under §2.1. Local garnish, same-role `±2`px adjustments, and at most two sparse display-size occurrences need no lock row. Never expand the lock to silence a comparison. New icon acquisition, images, structural fonts, role anchors, and resources keep their preparation/role rules.
@@ -298,7 +300,7 @@ test -f "<project_path>/icons/<lib>/<name>.svg"
## 5. Font Usage
Read typography from `spec_lock.md`: `<role>_family``title_family` / `body_family` → legacy `font_family`; sparse accents follow §2.1 and LaTeX stays PNG.
Read typography from `spec_lock.md`: `<role>_family``title_family` / `body_family` → legacy `font_family`; sparse accents follow §2.1. Under [`native-formula.md`](./native-formula.md), blocks use marker style; inline math inherits size / visible solid fill and exports with the project text language in Cambria Math.
**Default — locked-stack realization (may vary treatment)**: Express the Design Spec Character Reference through scale, weight, spacing, color, and composition; keep the locked family. Put the common stack on root `<svg>`, omit matching descendants, and override at the nearest clear `<g>`, `<text>`, or `<tspan>`.
@@ -118,6 +118,12 @@ The number of markers must equal the number of promoted chart objects, and each
marker must sit under its matching object wrapper. One marker somewhere on a
multi-chart page is insufficient.
**Native layout handoff**: for a native-ready classic chart whose authored plot
rectangle must remain fixed, copy that final absolute slide rectangle into
metadata `plot_area`; omit it only for PowerPoint automatic layout. The marker
comment alone does not affect export; the closed schema stays in
[`native-data-interface.md`](./native-data-interface.md) §2.
Technical SVG/PPT constraints remain in [`shared-standards-core.md`](./shared-standards-core.md).
---
@@ -4,9 +4,9 @@
# Executor Image Branch
Conditional Executor authority for image status handling, placement, crop behavior, formula images, and template-bundled images.
Conditional Executor authority for image status handling, placement, crop behavior, and template-bundled images.
**Trigger**: load for any image/formula in §VIII, the lock, Quick Generate's active-context resource decisions, or a selected template.
**Trigger**: load for any image in §VIII, the lock, Quick Generate's active-context resource decisions, or a selected template.
## 1. Image Handling
@@ -19,7 +19,6 @@ Handle images by status; enum and lifecycle: [`svg-image-embedding.md`](svg-imag
| **Existing** | User-provided | Reference images directly from `../images/` directory |
| **Generated** | Generated by Image_Generator | Reference from `../images/`; manifest-backed files load [`executor-web-image.md`](./executor-web-image.md) |
| **Sourced** | Web-acquired by Image_Searcher | Reference from `../images/`. **Read [`image_sources.json`](image-searcher.md) to decide attribution** — load [`executor-web-image.md`](./executor-web-image.md). |
| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use a legal anchor with `meet` (centered default: `xMidYMid meet`) |
| **Needs-Manual** | Acquisition or suitability remains unresolved | Default uses a placeholder until Step 7. Quick blocks every required row in this status; file presence alone does not bypass it. |
| **Placeholder** | Not yet prepared | Use dashed border placeholder |
@@ -38,8 +37,8 @@ must-use, crop/content, and explicit user/template constraints; expression-only
changes need no upstream rewrite.
**Mandatory — per-page image composition decision**: Using this already-loaded
branch, decide once immediately before geometry on every page containing a
non-formula image and keep the result only in active context:
branch, decide once immediately before geometry on every page containing an
image and keep the result only in active context:
`role → direction generator → parent contour → slots/rhythm → crop →
image-shape action → labels/overlays → depth/continuity`. Derive the relationship
from the communication job, hierarchy, copy, asset ratio/focus, and deck rhythm;
@@ -92,5 +91,3 @@ positions and heights must change the source-unit `x`, `y`, `width`, and
`height` by the same union-relative mapping, so the gaps remove pixels without
rescaling the scene. A compound clip on one `<image>` is pattern `#M1-10`, not a
substitute when the objects must remain independently editable or Morphable.
**Formula images — declared-inference fallback for a missing `no-crop` flag**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop. For a rendered file, use dimensions in this order: current `analysis/image_analysis.csv`, `design_spec.md §VIII`, then `images/formula_manifest.json`. For a `Needs-Manual` row, size the dashed placeholder from the planned dimensions in §VIII, then the manifest; the readiness gate re-analyzes the supplied file and reconciles the container before export. Do not normalize all formulas to one height unless the spec explicitly states that layout choice.
@@ -8,7 +8,7 @@ Shared baseline for both acquisition paths. Path-specific behavior lives in the
## 1. Trigger Condition
Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`, or when any §VIII / Quick active-context resource is a pending prepared derivative. Canonical rows with `user` / `formula` / `placeholder` are tracked but skipped by acquisition roles.
Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`, or when any §VIII / Quick active-context resource is a pending prepared derivative. Canonical rows with `user` / `placeholder` are tracked but skipped by acquisition roles.
| Mode | Trigger |
|---|---|
@@ -28,7 +28,7 @@ Default Generate uses Strategist-owned `design_spec.md §VIII` plus its lock pro
**Required per non-skipped row**: `Acquire Via` and `Status`. `Reference` is required for every `web` / `slice` row, every newly authored `ai` row, and every prepared derivative regardless of source class. An existing `ai` row whose `Reference` is omitted or blank may continue only through the declared inference in [`image-generator.md`](./image-generator.md) §8; no other path may infer it.
**Quick Generate ownership**: explicit user assets, URLs, and path instructions win. Otherwise the main agent chooses required `user` / `ai` / `web` / `slice` / `formula` rows and AI path `auto`, without interaction.
**Quick Generate ownership**: explicit user assets, URLs, and path instructions win. Otherwise the main agent chooses required `user` / `ai` / `web` / `slice` rows and AI path `auto`, without interaction.
**Mandatory — consume the resolved path**: Default consumes Strategist-chosen §VIII rows; Quick resolves once in active context before preparation. This phase never adds or reselects a treatment:
@@ -57,7 +57,7 @@ Choosing `none` is valid. Never bake a native treatment into a derivative.
## 3. Path Dispatch
Classify `Reference: Derived from <canonical bare filename>; treatment=<operation>; ...` before `Acquire Via`. Its distinct, non-derived parent must be `user`, `web`, `ai`, or `slice`; reject formula/placeholder parents, chains, cycles, and self-reference. For each Pending row:
Classify `Reference: Derived from <canonical bare filename>; treatment=<operation>; ...` before `Acquire Via`. Its distinct, non-derived parent must be `user`, `web`, `ai`, or `slice`; reject placeholder parents, chains, cycles, and self-reference. For each Pending row:
| Row kind / Acquire Via | Load reference | Run | Success status |
|---|---|---|---|
@@ -67,7 +67,6 @@ Classify `Reference: Derived from <canonical bare filename>; treatment=<operatio
| `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py` | `Sourced` |
| `slice` | [`image-generator.md`](./image-generator.md) §4.3 | `slice_images.py` after parent AI sheet is `Generated` | `Generated` |
| `user` | — | — | (already `Existing`) |
| `formula` | — | — | (already `Rendered`) |
| `placeholder` | — | — | (already `Placeholder`) |
> Lazy load: an all-`web` deck never reads `image-generator.md`, and vice versa.
@@ -78,7 +77,7 @@ Classify `Reference: Derived from <canonical bare filename>; treatment=<operatio
1. Read the Default Design Spec/lock, or reuse Quick's active-context resource/page decisions.
2. Separate derivatives before grouping canonical rows by `Acquire Via`; ensure `project/images/` exists.
3. Finish user/formula and triggered ai/web/slice canonical preparation.
3. Finish user and triggered ai/web/slice canonical preparation.
4. Materialize only declared derivatives from usable parents, preserve originals, then run `analyze_images.py` once before SVG.
---
@@ -96,7 +95,7 @@ After all rows reach terminal status:
> `Needs-Manual` is terminal for acquisition, not export readiness. A later
> supplied/replaced file must be validated and its row reconciled to
> `Existing`, `Generated`, `Sourced`, or `Rendered` with matching evidence.
> `Existing`, `Generated`, or `Sourced` with matching evidence.
> Quick blocks every required row that still says `Needs-Manual`, regardless of
> whether an unverified candidate file happens to exist. See
> [`image-generator.md`](./image-generator.md) §7.
@@ -670,7 +670,7 @@ Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails)
- Do **not** run `image_gen.py --manifest` in Path B. That command is Path A and may use configured API/proxy backends even when the user confirmed host-native.
- Still run `python3 scripts/image_gen.py --render-md project/images/image_prompts.json` so the human-readable sidecar exists without touching any backend.
- **Batch for speed, mind the rate**: when the host can run independent tool calls in parallel (e.g. Claude Code issues independent calls concurrently), fire several generations together in modest groups — a few rows at a time (~34), not the whole manifest at once — so their latency overlaps without flooding the host's image quota. When the host only runs tools serially, generate one row at a time. This mirrors Path A's default concurrency of 3.
- Outputs **must** land at `project/images/<filename-from-resource-list>`. Match the Image Resource List dimensions when the host supports arbitrary sizes. Hosts with **fixed native resolutions** (common — e.g. ~1672x941 landscape / ~1086x1448 portrait) generate at the closest native size and backfill the actual pixels into the resource list `Dimensions` column — same convention as formula rows ("actual dimensions from formula manifest") and slice rows ("dimensions filled after slicing"). Do **not** upscale the file to fake the requested size (interpolation adds no detail); minor display-side upscaling (up to ~1.3x in practice) may surface as a non-blocking quality-checker warning and requires no acknowledgement.
- Outputs **must** land at `project/images/<filename-from-resource-list>`. Match the Image Resource List dimensions when the host supports arbitrary sizes. Hosts with **fixed native resolutions** (common — e.g. ~1672x941 landscape / ~1086x1448 portrait) generate at the closest native size and backfill the actual pixels into the resource list `Dimensions` column, as slice rows do after slicing. Do **not** upscale the file to fake the requested size (interpolation adds no detail); minor display-side upscaling (up to ~1.3x in practice) may surface as a non-blocking quality-checker warning and requires no acknowledgement.
- Mark each item's `status` `Generated` in the manifest the moment its file lands — as each completes, not in one pass at the end (so an interrupted batch leaves accurate state)
- Executor downstream is path-agnostic — no spec change required between Path A and Path B
@@ -1,6 +1,6 @@
# Image and Formula Layout Pattern Catalog
# Image Layout Pattern Catalog
Compact composition vocabulary for prepared images, illustrations, and rendered formula assets. Use the patterns as options, not as a checklist.
Compact composition vocabulary for prepared images and illustrations. Use the patterns as options, not as a checklist.
---
@@ -238,12 +238,10 @@ If motion is enabled, [`animations.md`](./animations.md) owns its implementation
| A prepared subject should re-layer over its source | `#A2-03`; keep the base and cutout registered, and insert a native middle layer only when it has a distinct job |
| A busy visual needs one focal region | `#M2-05`, or prepared `#A3-01` / `#A3-03` when a native contrast treatment is insufficient |
| A visual argument should build across pages | `#C1-01` + `#P2-01` or `#P2-05`; keep the underlying source and frame stable |
| Formula or technical figure needs explanation | `#P1-12` + `#P2-07` / `#P2-03`; use `#P2-04` only when a second cropped detail is useful, and keep explanatory labels native |
| Technical figure needs explanation | `#P1-12` + `#P2-07` / `#P2-03`; use `#P2-04` only when a second cropped detail is useful, and keep explanatory labels native |
**Registration boundary**: registration-dependent effects succeed only when their declared coordinate relationship remains exact. Preserve registration for `#M1-10`, `#A2-02`, `#A3-01`, `#M1-08`, `#A2-03`, `#A3-02`, `#A3-03`, and `#M1-11`; `#M1-09` is the intentional exception.
**Source-correspondence boundary**: `#P2-04` reuses one exact source but intentionally changes the detail crop, scale, and placement; preserve the selected-region correspondence instead of forcing page-space registration.
**Formula placement**: treat a rendered formula as a prepared visual asset. Use whitespace patterns such as `#P1-11` or `#P1-12` for isolated derivations, `#P1-07`, `#P2-07`, `#P2-03`, or `#P2-04` for annotated formulas, and `#P3-04` or `#P3-03` for comparisons; keep editable explanatory text native.
All compatibility details remain owned by [`shared-standards-core.md`](./shared-standards-core.md) and its routed references.
@@ -2,9 +2,9 @@
# Image Layout Specification
Neutral geometry and review rules for every image or rendered-formula placement. This file calculates the selected composition; it never chooses a resource, pattern, or automatic left/right or top/bottom layout.
Neutral geometry and review rules for every image placement. This file calculates the selected composition; it never chooses a resource, pattern, or automatic left/right or top/bottom layout.
**When to run**: whenever an image or rendered formula will be placed. Use the current page composition to select its region first, then apply the relevant single-item, adjacent, overlay, or multi-item calculation below.
**When to run**: whenever an image will be placed. Use the current page composition to select its region first, then apply the relevant single-item, adjacent, overlay, or multi-item calculation below.
---
@@ -78,18 +78,18 @@ Centered fill uses `ax = ay = 0.5`. SVG realization normally maps this to a lega
| Need | Geometry |
|---|---|
| Complete source, formula, evidence, or edge content | Contain |
| Complete source, evidence, or edge content | Contain |
| Region coverage with a focal-safe crop | Fill |
| Complete source plus a detail view | One contain placement plus a separately justified crop |
| Irregular or repeated source windows | Apply the selected region math first, then load the owning crop/shape reference |
---
## 3. Single Image or Formula
## 3. Single Image
Place a standalone item by applying §2 to its selected region. The region itself comes from the page hierarchy; source ratio determines the item geometry inside it, not the page structure.
For an item adjacent to another region, divide only the available selected region. Let `q_item` and `q_other` be positive visual weights for the image/formula and the other content.
For an item adjacent to another region, divide only the available selected region. Let `q_item` and `q_other` be positive visual weights for the image and the other content.
### 3.1 Horizontal adjacency
@@ -202,22 +202,7 @@ listed structural role; otherwise use the selected region boundary and omit it.
---
## 5. Rendered Formula Geometry
Treat a rendered formula as an aspect-ratio source and apply contain within its selected mathematical region. Centering is the default geometric anchor; align to a nearby baseline or relation only when the page composition defines that relationship.
For `n` vertically stacked formula regions with equal lanes:
```text
lane_height = (H - (n - 1) × g) / n
lane_y[i] = y0 + i × (lane_height + g)
```
Contain each formula independently in its lane. When formulas are visual peers, a common effective scale may improve comparison; otherwise let their selected regions reflect their semantic weight and source ratios.
---
## 6. Composition Checks
## 5. Composition Checks
| Check | Required response |
|---|---|
@@ -232,7 +217,6 @@ Contain each formula independently in its lane. When formulas are visual peers,
| Per-item angles vary without a shared direction or deliberate-disorder rule | Use one parent-group angle, a shared clip-shape direction, curve tangents, or a bounded angle rhythm |
| The parent contour does not affect silhouette, seam, reveal, or attachment | Remove it or reconstruct the carriers from that contour |
| A panel depends on shear, skew, or perspective warping | Replace it with 2D quadrilateral carriers and focal-safe crops |
| Formula symbols become unreadable at the intended viewing size | Enlarge its region or restructure the page |
| Gaps, alignments, or overlaps drift without purpose | Recalculate from the shared region and gap values |
The final geometry must express the active page hierarchy, preserve the selected resource relationships, and remain valid under the conditionally loaded technical contracts.
@@ -92,6 +92,11 @@ inside PowerPoint's 32-bit DrawingML coordinate range; `width` and `height`
must resolve to at least one EMU. Native table frames must additionally resolve
to at least one EMU per resolved row and column.
**Classic plot-area layout**: supported classic charts accept root `plot_area`;
ChartEx rejects it. It contains only finite `x`, `y`, `width`, `height` in
absolute slide px and forms a positive rectangle inside the chart frame. Export
writes `c:manualLayout`; omission keeps automatic layout.
**Validation**: `svg_quality_checker.py` validates replacement marker kind, JSON
metadata, bounds/fallback availability, table rows/columns, supported chart
type, chart data shape, and any imported fallback baseline before export.
@@ -211,6 +216,8 @@ with `unsupported-merge-topology`.
`series[].values`. Pie-family charts (`pie`, `doughnut`, `pieOfPie`, and
`barOfPie`) must have exactly one series; the exporter assigns per-category
slice colors so single-series charts do not collapse into one solid color.
Root `hole_size` is doughnut-only, integer `10..90`, default `75`;
no pie-family rotation or angle field exists.
Column and bar charts may set per-point colors with `series[].point_colors`
or `series[].pointColors`; the list must match `series[].values` length.
Classic category charts may set native PowerPoint data labels with
@@ -259,6 +266,8 @@ area charts and OHLC stock charts; arbitrary date-axis source families are not.
This contract is not a full `AxisSpec`: logarithmic scales, minor units/gridlines,
crossing values, display units, tick skipping, and other unlisted OOXML semantics
remain unsupported and fail closed on import.
Single-plot `bar` accepts `category` at left/right and `value` at bottom/top;
`pie`, `doughnut`, and `pieOfPie` / `barOfPie` reject `axes`.
**Narrow XY-axis schema**: `scatter` and `bubble` may use a closed `axes` object
with only `x` and `y` roles. Both roles have `kind: "value"`; `x.position`
@@ -285,19 +294,15 @@ imply full visual-axis parity.
required only when the native object must preserve typography that cannot be
inferred unambiguously from the visible fallback.
**Chart chrome metadata**: Text that is visually part of the chart must be in
metadata, not only in SVG fallback children; metadata MUST still match visible
fallback chrome. `title` becomes the native chart title on classic charts; it
is not an object name, so use `name` for semantic object naming. `subtitle`
becomes the second rich-text line of that classic chart title. `title`,
`subtitle`, and axis-title values may be strings or objects with `text`,
`font_size`, `font_family`, and `color` when the fallback uses local role
typography. `svg_quality_checker.py` rejects `title`, `subtitle`, or axis-title
metadata whose text is not visible inside the replacement marker's fallback. Direct
`--native-charts-and-tables` export keeps the chart native but omits that inconsistent
chrome with a warning. chartEx keeps PowerPoint's empty `<cx:title>` and emits
the title / subtitle as companion editable text boxes until chartEx rich titles
are validated. Axis
**Chart chrome metadata**: Metadata MUST match fallback chrome. For classic
charts, a string or unbounded-object `title` becomes native `c:title`;
`subtitle` is line two. A title object with complete `x`, `y`,
`width`, and `height` becomes a companion editable text box at those absolute
slide-px bounds; partial bounds or `subtitle` fail. Use `name`, not
`title`, for object naming. `title`, `subtitle`, and axis-title objects may set
`text`, `font_size`, `font_family`, and `color`. The checker rejects title/axis
text absent from the fallback; export omits it with a warning. ChartEx keeps an empty `<cx:title>` and
emits title/subtitle as companion editable text boxes. Axis
titles are optional and explicit: use `axis_titles` with
`category`, `value`, `x`, `y`, or `secondary_value` keys, or the root aliases
`category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, and
@@ -0,0 +1,102 @@
# Native Formula Specification
Shared authoring contract for editable PowerPoint math generated from exact
LaTeX, either inline in Slide-local prose or as a standalone block.
## 1. Trigger and Ownership
**Trigger**: A page contains structural mathematical notation such as a
fraction, radical, integral, n-ary expression, limit, matrix, delimiter
construction, accent, or complex script.
| Layer | Ownership |
|---|---|
| Default Strategist | Record exact mathematical content as a delimiter-free LaTeX expression body; do not classify its implementation |
| Default Executor | Decide ordinary text versus inline native math versus block native math, then author the selected marker and SVG preview |
| Active Quick context | Perform both content and authoring responsibilities directly |
| SVG-to-PPTX exporter | Compile marker LaTeX to editable Office Math and replace only the registered preview |
| Content form | Authoring choice |
|---|---|
| Short variables, percentages, simple assignments, or notation such as `O(n log n)` | Ordinary editable SVG text |
| One-line structural math embedded in prose | Inline native marker |
| Matrix, `cases`, `aligned`, multiline derivation, or standalone high-structure expression | Block native marker |
The Strategist's `Mathematical content` field does not pre-decide this choice.
Formula handling is not a user-confirmed policy, image resource, manifest, or
`spec_lock.md images` entry.
---
## 2. Canonical Markers
### 2.1 Inline formula
```xml
<text x="120" y="240" font-size="28" fill="#173B57">
The ratio <tspan data-pptx-inline-formula="\frac{a_i}{b_i}">aᵢ/bᵢ</tspan> remains stable.
</text>
```
**Hard rule — one leaf run**: Put non-empty, delimiter-free LaTeX directly in
`data-pptx-inline-formula` on a leaf `<tspan>`. Give that `<tspan>` one non-empty
direct preview string with no leading/trailing whitespace, no child element,
and no `x`, `y`, `dx`, `dy`, or paragraph-layout metadata; spacing belongs to
the surrounding text. The marker inherits its computed size and visible solid
fill; exported math uses the project text language and Cambria Math.
**Hard rule — Slide-local ordinary text only**: Do not place an inline marker
inside a structured Layout placeholder, a Master/Layout layer, imported
preserved `txBody`, geometry transport subtree, another inline marker, or any
`data-pptx-replace-with` subtree. Export keeps the surrounding text runs in the
same `a:p` and replaces only the marker run with `a14:m > m:oMath`.
### 2.2 Block formula
```xml
<g id="quadratic-formula" data-pptx-replace-with="formula"
data-pptx-x="190" data-pptx-y="245"
data-pptx-width="900" data-pptx-height="180"
data-pptx-bounds="190 245 900 180">
<metadata type="application/json"><![CDATA[
{"latex":"\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}",
"display":"block","font_size":42,"color":"#173B57","align":"center"}
]]></metadata>
<text x="640" y="345" text-anchor="middle"
font-size="42" fill="#173B57">(-b ± √(b²−4ac)) / 2a</text>
</g>
```
**Hard rule — block metadata is truth**: Write one direct
`<metadata type="application/json">` child with non-empty delimiter-free
`latex`, `display: block`, `font_size` in `(0, 400]`, a visible `color`, and
`align: left|center|right`. Give the group finite `data-pptx-x/y`, positive
`data-pptx-width/height`, and matching root-coordinate `data-pptx-bounds`.
Export replaces the complete group with `a14:m > m:oMathPara > m:oMath`.
**Hard rule — preview is SVG, never fallback**: Make every marker preview
semantically equivalent with ordinary SVG text/shapes/lines/paths. Do not use
`<image>`, `<foreignObject>`, visible raw LaTeX, or another runtime renderer.
The exporter discards the registered preview and emits no picture branch.
---
## 3. Source, Failure, and Validation
**Supported subset**: basic text, numbers, operators, Greek/symbol commands,
fractions, radicals, scripts, `\sum` / `\prod` / `\int` with limits, `\left` /
`\right` delimiters, matrix variants, `cases`, `aligned`, text/math styles,
accents, and spacing. Unknown commands or environments fail closed.
**Hard rule — repair LaTeX upstream**: Unsupported source or an invalid marker
blocks the page. Rewrite within the supported subset without changing the
planned mathematics; otherwise return it to the content owner. Never substitute
a PNG, flatten structural math into ordinary text, hand-write OMML, or leave raw
LaTeX visible.
**Compatibility boundary**: Both forms target Microsoft PowerPoint 2010+ Office
Math. WPS, Keynote, LibreOffice, and other clients receive no embedded formula
fallback and are outside the rendering/editability contract.
**Validation**: The first-page/final SVG checker validates every marker and
compiles its LaTeX before release; native export repeats validation.
@@ -0,0 +1,100 @@
# Native Hyperlink Specification
Shared authoring contract for PowerPoint-native click hyperlinks on complete
objects and inline text runs.
## 1. Trigger and Ownership
**Trigger**: A user instruction, source fact, or page plan requires an external
destination or a jump to another slide in the same deck.
| Layer | Ownership |
|---|---|
| Default Strategist | Record the linked text/object intent and exact target in the applicable §IX page block; never invent or normalize an unknown destination |
| Default Executor | Choose the whole-object or inline carrier and author the canonical SVG anchor |
| Active Quick context | Perform both content and authoring responsibilities directly |
| SVG-to-PPTX exporter | Validate the target, create the native relationship, and attach the click action |
**Hard rule — page content only**: Hyperlinks are not a confirmation field,
resource, manifest, or `spec_lock.md` entry. Missing or ambiguous targets return
upstream; do not substitute a search result or guessed URL.
---
## 2. Canonical SVG
| Intent | Canonical form |
|---|---|
| Whole object, image, button, or group | `<a href="https://example.com"><g>...</g></a>` |
| Inline text | `<text>Read <a href="https://example.com"><tspan>the guide</tspan></a>.</text>` |
| Same-deck jump | `href="#slide-3"` using the 1-based final slide roster |
| Imported shape-plus-run conflict | Importer-only `data-pptx-shape-hyperlink="..."` on the logical `<g>`, with standard inline anchors retained inside |
**Hard rule — one target syntax**: Author SVG 2 `href`. Import may read legacy
`xlink:href`, but generated SVG never writes both. Same-deck destinations use
the exact `#slide-N` form and must resolve inside the final roster. External
destinations are absolute URIs with an explicit scheme; percent-encode spaces.
Relative paths, arbitrary fragments, filesystem paths, and `data:`, `file:`,
`javascript:`, or `vbscript:` destinations fail closed.
**Hard rule — inline run**: Put visible text in one or more `<tspan>` children
inside the anchor. The anchor and its descendants own no `x`, `y`, `dx`, or
`dy`; line positioning belongs to the enclosing line `<tspan>`. A linked inline
formula uses one leaf formula `<tspan>` inside the anchor and retains its native
math contract.
**Hard rule — whole-object hit area**: Wrap at least one visible SVG element;
do not put direct text or a bare `<tspan>` in a shape anchor. A multi-object
anchor links each exported leaf object. Include an explicit background shape
when gaps inside a button or card must also be clickable.
Ordinary entrance, emphasis, motion-path, exit, and Morph animation may target
an outer top-level `<g>`. A hyperlink-bearing group cannot also serve as an
interactive `trigger_shape`; use a separate trigger so one click has one owner.
**Forbidden — ambiguous ownership**: Do not nest `<a>` elements or place an
anchor inside `defs`, metadata, geometry-detail, or a native-replacement
subtree. A complete block formula or native Chart/Table marker may be wrapped
as one whole object; its preview descendants may not contain another anchor.
**Forbidden — authored transport metadata**: Never author
`data-pptx-shape-hyperlink`. PPTX import uses it only when one source shape has
both a whole-shape click and descendant run links, because standard SVG cannot
nest their two anchors. Checker/export accept it only on that logical group
with at least one real inline `<a>` descendant, then restore both native click
levels. Every ordinary whole-object link uses the standard outer `<a href>`.
---
## 3. Native Result and Preservation
| Carrier / target | Native result |
|---|---|
| Inline external link | `a:rPr/a:hlinkClick` plus an external hyperlink relationship |
| Whole-object external link | `p:cNvPr/a:hlinkClick` on each clickable leaf plus one shared external relationship |
| Inline or whole-object slide jump | The same click carrier plus an internal slide relationship and `ppaction://hlinksldjump` |
| Supported PPTX import | Reconstruct the same canonical SVG `<a href>` form |
**Hard rule — Fill Native preservation**: Preserve external links. Retarget a
same-deck jump only when its source target maps unambiguously to one output
slide; omitted or duplicated targets fail closed instead of linking to an
orphan or wrong slide.
**Hard rule — Enhance Native preservation**: Preserve existing hyperlink XML
and relationships unchanged. This route does not use the SVG authoring contract
to add new links.
---
## 4. Exclusions and Validation
**Forbidden — unsupported action settings**: Mouse-over links, custom shows,
first/last/next/previous navigation actions, program or macro execution, OLE or
file actions, and arbitrary `ppaction://` or relationship injection are outside
this contract. An `actionButton*` preset remains visual geometry until wrapped
in an ordinary supported hyperlink anchor.
**Validation**: The final SVG checker validates carrier structure, target
syntax, and slide range. Export validates relationship type/mode and final
presentation-roster membership. Unsupported PPTX click actions produce an
import diagnostic; strict import fails rather than fabricating an SVG link.
@@ -55,7 +55,7 @@ paths or contours, or upgrade ordinary SVG during export.
| Stock bent / curved relationship contour | `bentConnector*`, `curvedConnector*` | Prefer when the contour fits and endpoint attachment is not required. The authored object is an unconnected native Connector, so moving nodes does not reroute it. |
| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | For a brand-specific or custom tail, continue through the Boolean gate; use freeform only if the result still cannot be expressed faithfully. |
| Stock ribbon or scroll | `ribbon*`, `ellipseRibbon*`, `verticalScroll`, `horizontalScroll` | Select only when the stock contour is visually acceptable. |
| Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Inline formulas and prose symbols remain text/formula assets. |
| Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Use only when the symbol itself is a diagram shape; simple notation remains text, while non-trivial inline or block mathematics follows [`native-formula.md`](./native-formula.md). |
| Literal Office symbol | `heart`, `sun`, `moon`, `lightningBolt`, `gear6`, `gear9` | Never replace an icon required by `spec_lock.icons`. |
Use registry search for a less common literal shape:
@@ -472,7 +472,8 @@ as a grouped editable text box. Authoring v1 creates only unconnected
`p:cxnSp`; it does not accept hand-written endpoint/site metadata. An
`actionButton*` preset maps visual geometry only. Preset appearance never
invents connector attachment, action behavior, navigation targets, or
hyperlinks.
hyperlinks. Link and navigation behavior is authored explicitly instead — see
[`native-hyperlinks.md`](./native-hyperlinks.md).
---
@@ -2,9 +2,9 @@
# Strategist Image Planning
Always-on Stage-2 rendering-candidate extension plus conditional formula assets, confirmed image elaboration, and `design_spec.md §VIII` resource planning.
Always-on Stage-2 rendering-candidate extension plus confirmed image elaboration and `design_spec.md §VIII` resource planning.
**Trigger**: Load before every fresh Stage-2 direction set. Apply §2 first from the rendering index, freeze each direction's exact bases, and only then read the deduplicated selected detail files before completing its behavior. [`strategist.md`](./strategist.md) independently owns source recommendation. A confirmed non-`none` source activates the applicable resource-planning sections; confirmed `none` without formulas stops before resources, while a formula-only path applies §3 and §4 formula rows only. Rendering candidates are authored once before confirmation and never backfilled from a later source toggle.
**Trigger**: Load before every fresh Stage-2 direction set. Apply §2 first from the rendering index, freeze each direction's exact bases, and only then read the deduplicated selected detail files before completing its behavior. [`strategist.md`](./strategist.md) independently owns source recommendation. A confirmed non-`none` source activates the applicable resource-planning sections; confirmed `none` stops before resources. Rendering candidates are authored once before confirmation and never backfilled from a later source toggle.
---
@@ -32,41 +32,20 @@ The UI hides these candidates while AI is not selected. If the user adds AI, it
For specialized or regulated paper-figure subjects, preserve the prompt depth required by [`image-generator.md`](./image-generator.md) §4.2 rather than shortening to a generic brief. Scan the outline for genuine image-led pages, list the proposed hero pages in Stage-2 `image_notes` so the user can retain, edit, or remove them in the same confirmation, then mark only the confirmed pages' AI rows `page_role: hero_page`; local is the default. `text_policy: embedded` is reserved for stable figure-internal identifiers or lettering deliberately fused into the artwork; page titles, editable data values/labels, and prose remain SVG. Resolve confirmed provided assets through the context-first boundary above before writing §VIII.
## 3. Formula Asset Policy
## 3. Image Resource List
Formula rendering is a conditional choice surfaced in final Stage 2 production confirmation. Recommend one policy and let the user confirm or override it:
| Policy | Behavior | Use |
|---|---|---|
| `mixed` (default) | Render complex expressions to PNG; keep simple inline math as editable text / Unicode | Most academic, engineering, educational, and technical decks |
| `render-all` | Render every formula-worthy expression to PNG | Formula-heavy teaching / research decks where consistency matters more than editability |
| `text-only` | Keep expressions as editable text / Unicode | Business decks, light technical briefs, or an explicit editability preference |
`$...$` / `$$...$$` in source material are input signals only. Never scan output files for dollar-delimited formulas. Fractions, radicals, integrals, sums, limits, matrices, multiline derivations, and complex super/subscripts are formula-worthy; short variables, simple assignments, percentages, and expressions such as `O(n log n)` normally remain text. Never invent an equation for decoration.
For `mixed` or `render-all`, write selected source expressions to `<project_path>/images/formula_manifest.json` before writing the final spec, then run:
```bash
python3 skills/ppt-master/scripts/latex_render.py <project_path>
python3 skills/ppt-master/scripts/analyze_images.py <project_path>/images
```
Follow `latex_render.py --help` for the manifest fields. The renderer writes dimensions, ratio, file, provider, and status back into it. Formula PNGs default to transparent; use an opaque final background only when the asset requires it.
## 4. Image Resource List
Add §VIII rows only for planned images and every selected formula; permitted unused sources create no row. Fill filename, dimensions/ratio, layout suggestion, crop, purpose/type, acquisition, status, reference, and conditional AI fields. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). Keep any unavailable planned/required asset `Pending` or `Needs-Manual`; never delete or reclassify it to appear complete. After final confirmation, project each placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced source/sheet rows. Preserve exact confirmed `source`/`crop`; keep non-empty `pattern`, including optional catalog ids, as preferred expression rather than locked geometry.
Add §VIII rows only for planned images; permitted unused sources create no row. Fill filename, dimensions/ratio, layout suggestion, crop, purpose/type, acquisition, status, reference, and conditional AI fields. `Acquire Via` is `ai`, `web`, `user`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). Keep any unavailable planned/required asset `Pending` or `Needs-Manual`; never delete or reclassify it to appear complete. After final confirmation, project each placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced source/sheet rows. Preserve exact confirmed `source`/`crop`; keep non-empty `pattern`, including optional catalog ids, as preferred expression rather than locked geometry.
**Prepared derivatives**: Keep canonical; `Reference`: `Derived from <bare filename>; treatment=<operation>;`. Deterministic child: distinct `.png`, inherits acquisition; §4.4 follows `user`/`ai` above. Lock placed children; [`image-base.md`](./image-base.md) §23 owns preparation.
References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words; formula preserves source LaTeX and placement intent. Any subject direction, focal placement, quiet region, or overlay-safety requirement that must affect acquisition/generation belongs in `Reference` or the matching §IX block, not only in `Layout pattern`.
References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words. Any subject direction, focal placement, quiet region, or overlay-safety requirement that must affect acquisition/generation belongs in `Reference` or the matching §IX block, not only in `Layout pattern`.
**Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's EXIF-corrected `Width` / `Height` / native `AspectRatio` in the latest `analysis/image_analysis.csv`; `SourceDisplayRatio` is source-context metadata, not the bitmap crop ratio. Drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, `formula`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
**Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's EXIF-corrected `Width` / `Height` / native `AspectRatio` in the latest `analysis/image_analysis.csv`; `SourceDisplayRatio` is source-context metadata, not the bitmap crop ratio. Drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
**Mandatory**: each placed row, including formulas, gets one executable `Layout pattern`. It is preferred expression, not locked geometry; optional hierarchical ids from the already-read [`image-layout-patterns.md`](./image-layout-patterns.md) must be exact. They are prompt lookup handles for Executor, not exporter effect codes. Executor may adapt the suggestion while preserving resource identity/source, must-use status, crop/content, and explicit user/template constraints; layout-only changes need no upstream rewrite.
**Mandatory**: each placed row gets one executable `Layout pattern`. It is preferred expression, not locked geometry; optional hierarchical ids from the already-read [`image-layout-patterns.md`](./image-layout-patterns.md) must be exact. They are prompt lookup handles for Executor, not exporter effect codes. Executor may adapt the suggestion while preserving resource identity/source, must-use status, crop/content, and explicit user/template constraints; layout-only changes need no upstream rewrite.
**Default — action-bearing image plan (may override when restraint better serves the page)**: For a `hero_page` or other image-led row, name an image/content or image/shape action—not position, size, crop, or legibility scrim alone. Plain split and full bleed remain valid when clearest.
Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, product markings, and formulas are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping. Formula rows use `Type: Latex Formula`, `Acquire Via: formula`, `Crop Policy: no-crop`, and `Rendered` or `Needs-Manual`.
Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, and product markings are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping.
Judge `text_policy` per AI row using [`image-generator.md`](./image-generator.md) §5.3; paper figures, academic schematics, panel comparisons, and data-axis graphics are positive triggers for reconsidering an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and pending `web` rows to Image_Searcher; formula rows bypass both.
Judge `text_policy` per AI row using [`image-generator.md`](./image-generator.md) §5.3; paper figures, academic schematics, panel comparisons, and data-axis graphics are positive triggers for reconsidering an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and pending `web` rows to Image_Searcher.
@@ -34,7 +34,7 @@ solution + production gate:
| Stage | Items | Role |
|---|---|---|
| **1 — communication contract + template choice** | `primary_language` · `c` audience · open-ended communication intent · audience outcome · core message / delivery context (primary + optional secondary) / artifact afterlife · `content_divergence` (all prose fields may be blank) · `a` canvas · explicit `free_design` or `templates` choice and selected roots | confirmed together; candidate workspaces do not influence the communication recommendation |
| **2 — final solution + production** (authored once from the user's *actual* Stage 1) | reading mode (`delivery_purpose`, PPT only) · `d` mode + visual style · `b` page count · `e` color · `f` icon · `g` typography · `h` image source + generated-image rendering · conditional natural-language template application · formula policy · conditional AI-image acquisition path · generation mode · refine-spec toggle · proactive speaker notes / custom animations / narration audio | derived as one coherent plan from the confirmed contract; internal template exporter modes remain hidden |
| **2 — final solution + production** (authored once from the user's *actual* Stage 1) | reading mode (`delivery_purpose`, PPT only) · `d` mode + visual style · `b` page count · `e` color · `f` icon · `g` typography · `h` image source + generated-image rendering · conditional natural-language template application · conditional AI-image acquisition path · generation mode · refine-spec toggle · proactive speaker notes / custom animations / narration audio | derived as one coherent plan from the confirmed contract; internal template exporter modes remain hidden |
Do not force communication intent into one catalog label; Stage 1 records composite intent in prose. Editable prose fields are recommendation drafts, not required inputs: confirmation preserves current text and blanks; never repopulate a cleared field. Stage 2 confirms narrative spine, reading density, page budget, visual system, image direction, production mechanics, and how any installed template should be used. It never chooses or installs a template. Inspect only project-local template spec/prototypes, present one editable application plan, and keep exporter reuse/adherence internal. First author exactly three complete, project-fit solution directions from the confirmed contract and source; only then project each direction into mode, visual style, color, type, icons, and generated-image rendering for lower-level adjustment. Every direction projects a project-specific `custom` mode, `custom` visual style, and `custom` generated-image rendering; the fixed catalogs remain conservative lower-level single-select alternatives. All three must be viable and distinguishable as whole solutions, but do not force safe / shifted / bold archetypes, different catalog bases, or artificial extremes. After all three bundles are complete, compare them against the confirmed contract and source, choose the strongest overall fit, and write its actual zero-based index as `design_directions.selected` (`0`, `1`, or `2`); array order never determines preference. Every direction carries a complete generated-image rendering candidate even when AI imagery is not recommended; `recommend.image_usage` independently decides whether AI is proposed. Generated images inherit deck colors—there is no second image palette. Proactive defaults are speaker notes `true`, custom animations `false`, and narration audio `false`; a prior explicit user instruction overrides the matching recommendation, and effective narration audio requires effective speaker notes. Author each stage once; same-stage edits update only visible browser state through documented deterministic dependencies, without another AI/backend recommendation. Launch/derive/wait mechanics live in [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; item specs keep `a``h`.
@@ -251,9 +251,27 @@ pinned, and changing canvas does not secretly rescale it.
| Footnote / page number | 0.50.65× |
Scan §IX before locking. Declare every recurring role, including `lead`, `footnote`, and chart annotations when used; a lead is always at least body size. Give each role one deck-wide anchor and snap derived anchors to clean even px (for body 24, a sound set is title 42, subtitle 32, lead 30, annotation 18, footnote 16). Executor may vary one occurrence within that role's anchor ±2px while preserving hierarchy and readability. A short non-structural Hero/Display size planned for at most two occurrences may remain undeclared; the third planned occurrence makes it recurring and requires an explicit named slot. Structural text never uses this sparse exception.
#### Formula Planning Trigger
Formula policy and formula-asset planning are conditional. [`strategist-image.md`](./strategist-image.md) is already loaded for Stage-2 rendering candidates; if the source contains formula-worthy expressions, or the user explicitly requests formula handling, apply its §3 before confirming the production policy or writing formula rows. Otherwise omit formula planning from the core path.
#### Mathematical Content Planning
Preserve every source-backed equation and its mathematical meaning. In each
applicable §IX page block, record the exact expression under `Mathematical
content` as a LaTeX body without `$...$`, `$$...$$`, `\(...\)`, or `\[...\]`
source delimiters. This field may cover any mathematics that needs exact
preservation; do not classify it as inline or structural or choose its
implementation. Never invent an equation for decoration or create a formula
policy, manifest, PNG, §VIII row, or `spec_lock.md images` entry. Executor owns
the text-versus-native-formula decision and its authoring; if the supported
LaTeX subset cannot preserve the planned content, return here for a
content-level correction.
#### Hyperlink Content Planning
Preserve every explicit or source-backed link intent. In the applicable §IX
page block, record the linked text/object and its exact absolute URI or final
1-based same-deck slide target. Never guess an external destination, select the
inline/whole-object carrier, or create a link manifest or lock entry. Executor
owns SVG authoring under [`native-hyperlinks.md`](./native-hyperlinks.md).
### h. Image Source Recommendation
@@ -274,9 +292,9 @@ Formula policy and formula-asset planning are conditional. [`strategist-image.md
**Always-on decision module; conditional resource extension**:
1. Before authoring Stage-2 directions, read [`strategist-image.md`](./strategist-image.md) plus only [`image-renderings/_index.md`](./image-renderings/_index.md). After the three whole-direction intents exist and their rendering reference ids are frozen, read only those exact sibling files once and author one complete custom rendering inside each direction before deciding whether `recommend.image_usage` includes AI.
2. Independently derive `recommend.image_usage` from source needs. Confirmed non-`none` sources activate the module's resource-planning sections and the image layout references; formulas activate the formula sections even when usage is `none`. Confirmed `none` without formulas writes no image rows, but does not erase the three recommendation-only rendering candidates.
2. Independently derive `recommend.image_usage` from source needs. Confirmed non-`none` sources activate the module's resource-planning sections and the image layout references. Confirmed `none` writes no image rows, but does not erase the three recommendation-only rendering candidates.
The module owns formula policy, AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent.
The module owns AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent.
### Presentation Capability & Visualization Recall (Non-blocking — Strategist recommends, no user confirmation needed)
@@ -484,7 +502,7 @@ final Stage 2 `false`, explicit objects-off, or explicit all-motion-off; only th
includes transitions.
1. With Generate Step 4's retained complete final-confirmation state, read `${SKILL_DIR}/templates/design_spec_reference.md`.
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I, including one effective outcome plus provenance for Speaker Notes, Custom Animations, and Narration Audio. Resolve them from latest explicit user instruction → matching final Stage 2 proactive value → workflow default `enabled` / `disabled` / `disabled`; Narration Audio enabled requires Speaker Notes enabled without rewriting the raw proactive evidence, and a dependency-driven notes outcome records that provenance. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, complete preferred wording, applicable capability recommendations, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and semantic content are authoritative; non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/visualization patterns remain References unless promoted.
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I, including one effective outcome plus provenance for Speaker Notes, Custom Animations, and Narration Audio. Resolve them from latest explicit user instruction → matching final Stage 2 proactive value → workflow default `enabled` / `disabled` / `disabled`; Narration Audio enabled requires Speaker Notes enabled without rewriting the raw proactive evidence, and a dependency-driven notes outcome records that provenance. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, complete preferred wording, exact mathematical content when applicable, capability recommendations, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and semantic content are authoritative; non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/visualization patterns remain References unless promoted.
3. Compare `design_spec.md` against the final confirmation field by field. Repair every omission or deviation before entering an enabled refine-spec review or authoring `spec_lock.md`.
4. If enabled, run [`refine-spec`](../workflows/stages/refine-spec.md) after Gate 1; edit only that Design Spec and create no lock before explicit approval.
5. Read `${SKILL_DIR}/templates/spec_lock_reference.md`; create the lock once or resynchronize stale derived state from the approved Design Spec and context. Retain identity/refinements and stable roles/routing; omit unnamed page-local values, do not reopen evidence, and make no new recommendation.
@@ -500,7 +518,7 @@ includes transitions.
| Icons | §VI uses the confirmed library or confirmed no-icon/custom path |
| Confirmed image-source set, `image_notes`, and AI strategy | §VIII uses only permitted sources and includes every explicitly required source, asset, or page role; a permitted but unused source needs no row |
| Natural-language template application | §I records it and the relevant layout/prototype choices realize it without silently dropping a requested use or exclusion |
| Formula policy, AI-image acquisition path, generation mode, refine-spec toggle | §I records them as production mechanics; their owning Generate stage consumes the Design Spec, and formula policy also shapes §VIII when formula-worthy content exists |
| AI-image acquisition path, generation mode, refine-spec toggle | §I records them as production mechanics; their owning Generate stage consumes the Design Spec |
| Proactive speaker notes, custom animations, and narration audio | §I records the three resolved effective outcomes with provenance, while §X records enabled note requirements or `Generation: disabled`; they remain outside `spec_lock.md`. §IX Motion suggestions remain optional advice regardless of the animation outcome |
| Explicit final/literal narration script | §IX segments the argument by semantic scene and gives each segment a supporting visible state; §X records the source plus verbatim policy, and Generate freezes the actual segments in `notes/total.md` after Gate 2 |
@@ -16,13 +16,12 @@ and filter/clip contracts.
| Mode | Resource authority and preparation timing |
|---|---|
| Default Generate | `design_spec.md §VIII` plus its lock projection; when user-provided images are selected, run `analyze_images.py` after Strategist confirmation and complete the list before Executor |
| Quick Generate | Current main agent's active-context resource decisions; materialize explicit user paths first, resolve unspecified acquisition decisions automatically, and finish user/ai/web/slice/formula preparation before SVG authoring without confirmation or a persisted roster |
| Quick Generate | Current main agent's active-context resource decisions; materialize explicit user paths first, resolve unspecified acquisition decisions automatically, and finish user/ai/web/slice preparation before SVG authoring without confirmation or a persisted roster |
```markdown
| Filename | Dimensions | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference |
|----------|------------|---------|------|----------------|-------------|-------------|--------|-----------|
| team.jpg | 800x600 | Team photo | Photography | `#P1-02 image left, copy right` | adaptive | web | Pending | Diverse engineering team in modern office |
| formula_001.png | 736x168 | Page 3 block equation | Latex Formula | formula | no-crop | formula | Rendered | `E = mc^2` |
```
### Image Status Enum
@@ -33,8 +32,7 @@ and filter/clip contracts.
| **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content |
| **Generated** | AI/slice output exists | Reference from `../images/`; manifest records govern attribution. An `Illustration Sheet` stays in §VIII only as an unplaced slice source |
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the attribution contract) |
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use a legal anchor with `meet` for the complete placement (centered default: `xMidYMid meet`) and do not crop |
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Existing`, `Generated`, `Sourced`, or `Rendered` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. |
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Existing`, `Generated`, or `Sourced` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. |
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
@@ -48,7 +46,6 @@ and filter/clip contracts.
- Quick Generate → current main agent resolves the required resource in active context; explicit user paths/URLs/choices win, unspecified choices use automatic resolution, no interaction or persisted roster
2. Prepare project-local resources before SVG authoring:
- user → materialize the explicit source under project/images/ → Existing
- formula → write formula_manifest.json and run latex_render.py → Rendered
- Pending prepared derivative → follow [`image-base.md`](./image-base.md) §3 before ordinary `Acquire Via` dispatch
- Pending / Failed + ai → Image_Generator runs image_gen.py → Generated
- Pending / Failed + web → Image_Searcher runs image_search.py → Sourced
@@ -58,7 +55,6 @@ and filter/clip contracts.
├── Sourced + license_tier=no-attribution → <image href=...> only
├── Sourced + license_tier=attribution-required → <image href=...> + small <text> credit element on the slide
├── Sourced + license_tier=manual → <image href=...> only (user-supplied --from-url; rights/credit are user responsibility)
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
└── Placeholder / Needs-Manual → Dashed border + description text until a supplied file is validated and status is reconciled
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
5. Export:
@@ -57,7 +57,7 @@ python3 scripts/update_repo.py
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Animation resources | `sound_sync.py` | [sound catalog](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Spec maintenance | `update_spec.py`, `visualization_recall.py`; legacy `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/visualization-recall.md](./docs/visualization-recall.md) |
| Image tools | `image_gen.py`, `image_treat.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Image tools | `image_gen.py`, `image_treat.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Maintenance smokes | Inline temporary-project commands | [advanced image and motion](./docs/advanced-image-motion-smoke.md); [mask and gradient](./docs/mask-gradient-smoke.md); [multilingual text](./docs/multilingual-text-smoke.md) |
| Repo maintenance | `update_repo.py` | README install/update section |
| Troubleshooting | validation, preview, export, dependency issues | [docs/troubleshooting.md](./docs/troubleshooting.md) |
@@ -303,13 +303,16 @@ literal field fallback because they are shared by multiple slides.
Image generation:
```bash
python3 scripts/latex_render.py <project_path>
python3 scripts/latex_render.py <project_path> --providers codecogs,quicklatex,mathpad,wikimedia
python3 scripts/image_gen.py "A modern futuristic workspace"
python3 scripts/image_gen.py --list-backends
python3 scripts/analyze_images.py <project_path>/images
```
Generated-deck formulas do not use an image command. Author a native formula
marker in the page SVG; `svg_to_pptx.py` compiles its LaTeX metadata to editable
PowerPoint OMML. The retained `latex_render.py` utility is standalone legacy
rasterization only and is not connected to either Generate profile.
Repository update:
```bash
@@ -1286,13 +1286,12 @@ def _stage2_production_recommendations_error(
recommend = recommendations.get('recommend')
if not isinstance(recommend, dict):
recommend = {}
for field in ('formula_policy', 'generation_mode'):
value = recommend.get(field)
if not isinstance(value, str) or not value.strip():
return (
'Stage 2 recommendations must include non-empty '
f'recommend.{field}'
)
generation_mode = recommend.get('generation_mode')
if not isinstance(generation_mode, str) or not generation_mode.strip():
return (
'Stage 2 recommendations must include non-empty '
'recommend.generation_mode'
)
refine_spec = recommendations.get('refine_spec')
if (
not isinstance(refine_spec, dict)
@@ -1311,10 +1310,9 @@ def _stage2_production_recommendations_error(
def _stage2_production_result_error(result: dict) -> Optional[str]:
"""Require every user-confirmed production control in the final payload."""
for field in ('formula_policy', 'generation_mode'):
value = result.get(field)
if not isinstance(value, str) or not value.strip():
return f'final Stage 2 payload must include non-empty {field}'
generation_mode = result.get('generation_mode')
if not isinstance(generation_mode, str) or not generation_mode.strip():
return 'final Stage 2 payload must include non-empty generation_mode'
if not isinstance(result.get('refine_spec'), bool):
return 'final Stage 2 payload must include refine_spec as a boolean'
if _uses_ai_images(result):
@@ -2765,6 +2763,10 @@ def create_app(
result_file,
carry_previous=rec_stage_number > 1,
)
# Formula realization is Executor-owned. Accept the retired field from
# older recommendations/clients, but never persist it in a new receipt.
result.pop('formula_policy', None)
locked_values.pop('formula_policy', None)
if rec_stage_number == 1 or not template_required:
result.pop('template_application', None)
locked_values.pop('template_application', None)
@@ -104,7 +104,6 @@
placeholder_audience: "Who is this deck for?",
placeholder_pages: "e.g. 12-15",
hex_override: "Custom HEX override:",
formula_policy: "Formula rendering policy",
image_ai_path: "AI image source",
image_strategy: "Generated image style",
image_strategy_empty: "No preset style references are available. You can still use a custom style.",
@@ -287,7 +286,6 @@
placeholder_audience: "この資料は誰に向けたもの?",
placeholder_pages: "例:12-15",
hex_override: "カスタムHEXで上書き:",
formula_policy: "数式レンダリング方針",
image_ai_path: "AI画像の生成元",
image_strategy: "生成画像のスタイル",
image_strategy_empty: "プリセットのスタイル見本を利用できません。カスタムスタイルは引き続き使用できます。",
@@ -470,7 +468,6 @@
placeholder_audience: "这份演示文稿面向谁?",
placeholder_pages: "如:12-15",
hex_override: "自定义色值覆盖:",
formula_policy: "公式渲染策略",
image_ai_path: "生成配图来源",
image_strategy: "生成图风格",
image_strategy_empty: "当前没有可用的预设风格参考,仍可使用自定义风格。",
@@ -653,7 +650,6 @@
placeholder_audience: "這份簡報面向誰?",
placeholder_pages: "如:12-15",
hex_override: "自訂色值覆蓋:",
formula_policy: "公式渲染策略",
image_ai_path: "生成配圖來源",
image_strategy: "生成圖風格",
image_strategy_empty: "目前沒有可用的預設風格參考,仍可使用自訂風格。",
@@ -1350,7 +1346,6 @@
if (field === "icons") return REC.icons && REC.icons.value;
if (field === "image_usage") return REC.images && REC.images.value;
if (field === "image_ai_path") return REC.image_ai_path || (REC.images && REC.images.ai_path);
if (field === "formula_policy") return REC.typography && REC.typography.formula_policy && REC.typography.formula_policy.value;
if (field === "generation_mode") return REC.generation_mode && REC.generation_mode.value;
return REC[field] && REC[field].value;
}
@@ -3344,13 +3339,6 @@
}
}
function renderFormulaPolicy(host) {
var sec = section("F", "formula_policy");
enumField(sec, CAT.formula_policy, recOrFirst("formula_policy", CAT.formula_policy),
function () { return STATE.formula_policy; }, function (v) { STATE.formula_policy = v; });
host.appendChild(sec);
}
// Combined color + typography + icon preview — not a separate confirmation, just a
// live "overall impression" of the style choices made above. Kept
// deliberately abstract (a style chip, not a slide layout); page layout
@@ -3922,7 +3910,6 @@
host.appendChild(styleGroup);
renderImageDirection(host);
renderImageProduction(host);
renderFormulaPolicy(host);
renderProactiveExecution(host);
renderMode(host);
renderRefine(host);
@@ -4079,7 +4066,6 @@
}
function initProductionState() {
STATE.formula_policy = pick("formula_policy", CAT.formula_policy);
STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path);
STATE.proactive_speaker_notes = booleanRecommendation("proactive_speaker_notes", true);
STATE.proactive_custom_animations = booleanRecommendation("proactive_custom_animations", false);
@@ -1483,44 +1483,6 @@
"desc_ja": "images/image_prompts.jsonにプロンプトを書き出し、ユーザーが外部で生成した画像をproject/images/に配置する方式。"
}
],
"formula_policy": [
{
"id": "mixed",
"label": "mixed",
"label_zh": "混合",
"label_zh_tw": "混合",
"label_en": "mixed",
"label_ja": "混合",
"desc_zh": "复杂公式渲染为图片;简单行内公式保持为可编辑文本或字符。",
"desc_zh_tw": "複雜公式渲染為圖片;簡單行內公式保持為可編輯文字或字元。",
"desc_en": "Render complex formula-worthy expressions to PNG; keep simple inline math as editable text / Unicode.",
"desc_ja": "複雑な数式はPNG画像として描画し、簡単な行内数式は編集可能なテキスト/Unicode文字のまま残す。"
},
{
"id": "render-all",
"label": "render-all",
"label_zh": "全部渲染",
"label_zh_tw": "全部渲染",
"label_en": "render-all",
"label_ja": "すべて画像化",
"desc_zh": "所有需要按公式处理的表达式都渲染为图片。",
"desc_zh_tw": "所有需要按公式處理的運算式都渲染為圖片。",
"desc_en": "Render every formula-worthy expression to PNG.",
"desc_ja": "数式として扱うべき表現はすべてPNG画像として描画する。"
},
{
"id": "text-only",
"label": "text-only",
"label_zh": "仅文本",
"label_zh_tw": "僅文字",
"label_en": "text-only",
"label_ja": "テキストのみ",
"desc_zh": "不渲染公式;表达式保持为可编辑文本或字符。",
"desc_zh_tw": "不渲染公式;運算式保持為可編輯文字或字元。",
"desc_en": "Do not render formulas; keep expressions as editable text / Unicode.",
"desc_ja": "数式は画像化せず、編集可能なテキスト/Unicode文字のまま保持する。"
}
],
"generation_mode": [
{
"id": "continuous",
@@ -310,7 +310,7 @@ template-selection receipt.
- **Enumerable + custom** — canvas / icons retain blank manual inputs. Mode and visual style first show three project-specific `custom` values projected from the complete directions, then the full fixed base catalog as conservative single-select alternatives. Selecting a projected card expands its behavior editor in place; edits change only the current value, while the adjusted active whole-direction card exposes an explicit restore action for the authored text.
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for fixed `visual_style` catalog choices, and renders real sample SVGs from `templates/icons` for `icons`. Project-specific `custom` direction cards show their authored summary instead of requesting a nonexistent preset asset. These thumbnails and summaries make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from recommendation files, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to recommendation stage files or `result.json`, and they do not replace the later Step 6 live preview.
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. A confirmed non-`none` set is the allowed acquisition-source boundary, not a requirement to use every selected source; only explicit `image_notes` wording can require a source, asset, or page role. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option.
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option.
- **Proactive execution booleans** — Final Stage 2 carries top-level `proactive_speaker_notes`, `proactive_custom_animations`, and `proactive_narration_audio` values. Defaults are `true`, `false`, and `false`, respectively. They control what the Agent does proactively only when the user has not explicitly instructed otherwise; the latest explicit user instruction always wins. These three values are raw confirmation evidence: the UI and server neither couple nor rewrite them, and every boolean combination is valid. When narration audio is enabled, Strategist later resolves the effective Speaker Notes outcome to enabled and records `Narration Audio dependency` as its Design Spec provenance. Disabling proactive custom animation does not suppress the Strategist's advisory motion recommendations.
- **Open prose**`audience`, `communication_intent`, `audience_outcome`, `core_message`, `delivery_context`, `artifact_afterlife`, `content_divergence`, and `page_count`. `communication_intent` may carry several purposes plus priority / sequence; common paths appear only as help text. `delivery_context` states one primary presenter-led / reader-led / hybrid / recorded-self-running context plus optional secondary use; a hybrid recommendation names which context leads. `content_divergence` is the source-treatment axis. `page_count` may be a range here; Strategist resolves the exact §IX roster, leaving Executor no pagination latitude.
- **Coordinated generative directions**`design_directions` carries exactly three complete candidates authored top-down from the project contract. Each has a unique stable id and bundles `custom` mode, `custom` visual style, color, typography, icon id, and `custom` generated-image rendering regardless of recommended image source. Its localized note is a compact, user-facing style summary. It may reuse localized display labels from `catalogs.visual_styles` when they describe the result concisely, but those labels are optional vocabulary rather than a selection constraint or required mapping. Otherwise it uses concise natural language and never forces the nearest label. The summary stays within one or two short sentences and does not expose catalog ids or reference mechanics. All three must be viable as whole solutions; they do not need different catalog bases or forced safe / shifted / bold archetypes. After completing all three bundles, Strategist compares them against the confirmed contract and source, then writes the strongest overall fit's zero-based index to `selected`; array position does not determine preference. That bundle becomes the initial default and applies its three custom projections coherently. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle.
@@ -325,7 +325,7 @@ Direction-local custom projections apply to mode, visual style, and generated-im
## Catalogs — `static/catalogs.json` (the finite option universe)
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `continuous`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
## Round-trip data contract
@@ -363,7 +363,7 @@ run remains inactive. An existing `result.json` outside the current `stage1` /
| Recommendation file | Declared stage | Page renders | Button | On submit |
|---|---|---|---|---|
| `recommendations.stage1.json` + `template_options.json` | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs/completes the handoff |
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, formula policy, proactive notes/custom-animation/narration-audio toggles, generation mode, and Design Spec review toggle | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, proactive notes/custom-animation/narration-audio toggles, generation mode, and Design Spec review toggle | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
In the UI branch, the AI authors Stage 1 without reading template candidates,
then launches the combined page. In chat/delegated confirmation it authors the
@@ -436,11 +436,14 @@ The common paths — inform / explain / persuade / decide / align / teach / repo
After Stage 1 is confirmed, create `recommendations.stage2.json` with the complete solution; leave Stage 1 unchanged (the server folds confirmed communication fields back in when serving the page):
**Stage-2 production contract**: the server rejects the recommendation file
unless `recommend.formula_policy`, `recommend.generation_mode`, and boolean
`refine_spec.value` are present; `recommend.image_ai_path` is additionally
required when `image_usage` includes `ai`. Final submission must retain the
corresponding direct values (`formula_policy`, `generation_mode`, boolean
`refine_spec`, and conditional `image_ai_path`) or confirmation is rejected.
unless `recommend.generation_mode` and boolean `refine_spec.value` are present;
`recommend.image_ai_path` is additionally required when `image_usage` includes
`ai`. Final submission must retain the corresponding direct values
(`generation_mode`, boolean `refine_spec`, and conditional `image_ai_path`) or
confirmation is rejected. Formula realization is Executor-owned and is not a
Stage-2 choice. Legacy recommendation/result objects may contain
`formula_policy`; the server tolerates the extra field but does not render or
persist it in a new receipt.
```json
{
@@ -453,7 +456,6 @@ corresponding direct values (`formula_policy`, `generation_mode`, boolean
"image_strategy": "custom",
"image_usage": ["ai", "provided"],
"image_ai_path": "auto",
"formula_policy": "mixed",
"generation_mode": "continuous"
},
"page_count": { "value": "12-15" },
@@ -554,7 +556,6 @@ Template-mode-only Stage-2 fragment:
"icons": "tabler-outline",
"typography": { "name": "...", "heading": { "primary": "...", "english": "...", "css": "..." }, "body": { "primary": "...", "english": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
"delivery_purpose": "balanced",
"formula_policy": "mixed",
"image_usage": ["ai", "provided"],
"image_notes": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。",
"image_ai_path": "auto",
@@ -200,6 +200,7 @@ Behavior:
- preserves every readable chart dimension or series and emits `[Chart data warning: <reason>]` for missing caches/count mismatches; `[Chart data unavailable: <reason>]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table
- transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning
- exports embedded pictures to a sibling `_files/` directory
- preserves supported run, table-cell, picture, and text-shape links as Markdown links, including `#slide-N` jumps
- appends speaker notes when present
- writes `<input>.conversion_profile.json` after successful conversion
@@ -258,6 +259,12 @@ relationship preference for asset identity; its existing missing-media gate
remains strict rather than silently treating the raster preview as the
template's canonical asset.
Supported `a:hlinkClick` on shape/picture `p:cNvPr` and text `a:rPr` becomes
the shared SVG `<a href>` form for absolute external URIs and final-roster
`#slide-N` jumps. A source shape that also has linked inner runs uses the
importer-only `data-pptx-shape-hyperlink` transport to avoid nested SVG anchors.
Unsupported click actions produce a diagnostic; strict import stops.
### Import compatibility and recovery boundary
Import is tolerant by default because the source deck is user-owned or comes
@@ -5,43 +5,25 @@
> inputs while delivery writes self-contained SVG previews and native PPTX
> media.
Image tools cover formula rendering, prompt-based AI generation, web image search, image inspection, and Gemini watermark removal.
Image tools cover prompt-based AI generation, web image search, image inspection,
and Gemini watermark removal. Native formula authoring belongs to the SVG
pipeline, not the image pipeline.
## `latex_render.py`
## Legacy standalone `latex_render.py`
Manifest-driven LaTeX formula renderer. Default Generate has Strategist write
`images/formula_manifest.json` after Typography confirmation; Quick Generate
has the current agent write the same resource manifest without confirmation.
This script renders only those declared formulas to transparent PNGs and writes
dimensions back into the manifest.
This retained standalone utility renders a user-authored
`images/formula_manifest.json` to PNG. Neither Default nor Quick Generate calls
it, and new projects do not create formula manifests or formula images. The
supported generated-deck path authors a native formula marker in SVG and lets
`svg_to_pptx.py` compile its LaTeX payload to editable PowerPoint OMML.
```bash
python3 scripts/latex_render.py <project_path>
python3 scripts/latex_render.py <project_path> --dry-run
python3 scripts/latex_render.py <project_path> --providers codecogs,quicklatex,mathpad,wikimedia
```
Manifest shape:
```json
{
"providers": ["codecogs", "quicklatex", "mathpad", "wikimedia"],
"items": [
{
"id": "formula_001",
"latex": "E = mc^2",
"display": "block",
"color": "#1D1D1F",
"background": "#FFFFFF",
"transparent": true,
"dpi": 300,
"filename": "formula_001.png"
}
]
}
```
Output files land directly under `project/images/`. Formula filenames should use a shared `formula_` prefix, e.g. `formula_001.png`. The default provider chain is `codecogs,quicklatex,mathpad,wikimedia`; each provider is tried automatically until one succeeds, and the winning provider is recorded back into the manifest. `--providers` or manifest-level `providers` may override the order, but all four are available as no-key fallbacks. Formula PNGs are transparent by default. `background` is the temporary render matte and local background-removal reference; set `transparent: false` only when an opaque final formula asset is intentional. The script does not scan `spec_lock.md` or source documents for `$...$`; formula selection belongs to the active resource owner.
Use it only for an explicitly requested external raster workflow. It is not a
compatibility fallback for Keynote, WPS, LibreOffice, or another client.
## `image_gen.py`
@@ -524,6 +524,21 @@ It aggregates:
Convert project SVGs into PPTX.
Native formulas use the two markers owned by
[`native-formula.md`](../../references/native-formula.md). A standalone block
stores delimiter-free LaTeX in the JSON metadata of
`<g data-pptx-replace-with="formula">` and exports `m:oMathPara`. A leaf
`<tspan data-pptx-inline-formula="...">preview</tspan>` inside ordinary text
exports `m:oMath` in the same DrawingML paragraph as its surrounding runs; it
inherits computed size and visible solid fill, then uses the project text
language and Cambria Math.
Matrices, multiline derivations, and other high-structure expressions remain
blocks. Formula replacement is always active, independent of
`--native-charts-and-tables`: export replaces only the registered SVG preview
and writes editable PowerPoint 2010+ Office Math. It emits no formula PNG, media
relationship, or compatibility fallback, and makes no rendering/editability
promise for Keynote, WPS, LibreOffice, or another non-PowerPoint client.
```bash
python3 scripts/svg_to_pptx.py <project_path>
# Explicit compact image export:
@@ -563,8 +578,8 @@ explicit current structured contract remains blocking.
Explicit direct generation may use the
[`quick-generate`](../../workflows/profiles/quick-generate.md) profile after the
current agent has converted/read sources, researched identified factual gaps,
and prepared the required images, icons, formulas, and resource manifests as
needed. That profile skips Strategist, Confirm UI, `design_spec.md`, and
prepared the required images, icons, and resource manifests as needed, and
retained any source LaTeX for direct native-marker authoring. That profile skips Strategist, Confirm UI, `design_spec.md`, and
`spec_lock.md`; it does not skip the resources required by the authored pages.
After the complete SVG roster exists, run its lockless final checker, then
export:
@@ -584,8 +599,8 @@ custom object animation, and narration start off in Quick and may be enabled
when needed. The exporter refuses a missing, blocking, non-final, or stale
Quick final report before PPTX creation. Default-path output retains the normal
postflight report and `backup/` snapshot; explicit `-o` retains the ordinary
no-backup behavior. Existing source, analysis, image/icon/formula, and
resource-manifest artifacts remain untouched.
no-backup behavior. Existing source, analysis, image/icon, and resource-manifest
artifacts remain untouched; formula source stays inside its authored SVG marker.
For generated-project narration, follow the
[`generate-audio`](../../workflows/stages/generate-audio.md) stage. It owns voice
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
PPT Master - Hyperlink Contract
Parse and validate the shared SVG hyperlink surface used by quality checks,
SVG-to-PPTX export, and PPTX-to-SVG round-trip conversion.
Usage:
from hyperlink_contract import parse_hyperlink_target
Examples:
parse_hyperlink_target("https://example.com")
parse_hyperlink_target("#slide-3", slide_count=8)
Dependencies:
None (standard library only).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from xml.etree import ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
HYPERLINK_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
"hyperlink"
)
SLIDE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
)
SLIDE_JUMP_ACTION = "ppaction://hlinksldjump"
SHAPE_HYPERLINK_ATTR = "data-pptx-shape-hyperlink"
_SLIDE_TARGET_RE = re.compile(r"#slide-([1-9][0-9]*)")
_URI_SCHEME_RE = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*):")
_FORBIDDEN_EXTERNAL_SCHEMES = frozenset({
"data",
"file",
"javascript",
"vbscript",
})
_NON_OUTPUT_ANCESTORS = frozenset({
"defs",
"desc",
"metadata",
"style",
"symbol",
"title",
})
_INLINE_CONTAINER_TAGS = frozenset({"text", "tspan"})
_INLINE_CONTENT_TAGS = frozenset({"tspan"})
_UNSUPPORTED_LINK_BEHAVIOR_ATTRIBUTES = frozenset({
"download",
"hreflang",
"ping",
"referrerpolicy",
"rel",
"target",
"type",
})
class HyperlinkContractError(ValueError):
"""Raised when one hyperlink cannot be represented faithfully in PPTX."""
@dataclass(frozen=True)
class HyperlinkTarget:
"""One normalized hyperlink destination."""
raw: str
kind: str
slide_number: int | None = None
def _local_name(elem: ET.Element) -> str:
if not isinstance(elem.tag, str):
return str(elem.tag)
return elem.tag.rsplit("}", 1)[-1]
def parse_hyperlink_target(
raw: str,
*,
slide_count: int | None = None,
) -> HyperlinkTarget:
"""Parse one canonical SVG hyperlink target.
Same-deck jumps use ``#slide-N``. External destinations must be absolute
URIs with an explicit scheme. Local files, script/data URLs, relative
paths, arbitrary SVG fragments, whitespace, and control characters fail
closed instead of being silently dropped during export.
"""
if not isinstance(raw, str):
raise HyperlinkContractError("hyperlink target must be a string")
if not raw:
raise HyperlinkContractError("hyperlink target must not be empty")
if raw != raw.strip():
raise HyperlinkContractError(
"hyperlink target must not contain leading or trailing whitespace"
)
if any(ord(char) < 0x21 or ord(char) == 0x7F for char in raw):
raise HyperlinkContractError(
"hyperlink target must not contain whitespace or control characters; "
"percent-encode URI spaces"
)
if "\\" in raw:
raise HyperlinkContractError(
"hyperlink target must use URI syntax, not a filesystem path"
)
slide_match = _SLIDE_TARGET_RE.fullmatch(raw)
if slide_match is not None:
slide_number = int(slide_match.group(1))
if slide_count is not None and slide_number > slide_count:
raise HyperlinkContractError(
f"slide jump {raw!r} exceeds the {slide_count}-slide deck"
)
return HyperlinkTarget(
raw=f"#slide-{slide_number}",
kind="slide",
slide_number=slide_number,
)
if raw.startswith("#"):
raise HyperlinkContractError(
"same-deck hyperlinks must use the exact #slide-N form"
)
scheme_match = _URI_SCHEME_RE.match(raw)
if scheme_match is None:
raise HyperlinkContractError(
"external hyperlinks must be absolute URIs with an explicit scheme"
)
scheme = scheme_match.group(1).lower()
if scheme in _FORBIDDEN_EXTERNAL_SCHEMES:
raise HyperlinkContractError(
f"external hyperlink scheme {scheme!r} is not allowed"
)
return HyperlinkTarget(raw=raw, kind="external")
def svg_hyperlink_href(elem: ET.Element) -> str:
"""Return the sole href value from one SVG ``<a>`` element."""
if _local_name(elem) != "a" or elem.tag != f"{{{SVG_NS}}}a":
raise HyperlinkContractError("hyperlink carrier must be an SVG <a>")
href = elem.get("href")
xlink_href = elem.get(f"{{{XLINK_NS}}}href")
if href is not None and xlink_href is not None:
raise HyperlinkContractError(
"SVG <a> must not declare both href and xlink:href"
)
value = href if href is not None else xlink_href
if value is None:
raise HyperlinkContractError("SVG <a> must declare href")
return value
def project_hyperlink_errors(
root: ET.Element,
*,
slide_count: int | None = None,
) -> list[str]:
"""Return fail-closed diagnostics for every SVG hyperlink carrier."""
errors: list[str] = []
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
anchors = [
elem for elem in root.iter()
if _local_name(elem) == "a"
]
for index, anchor in enumerate(anchors, 1):
label = anchor.get("id") or f"anchor {index}"
if anchor.tag != f"{{{SVG_NS}}}a":
errors.append(f"{label}: hyperlink carrier must be an SVG <a>")
continue
try:
href = svg_hyperlink_href(anchor)
parse_hyperlink_target(href, slide_count=slide_count)
except HyperlinkContractError as exc:
errors.append(f"{label}: {exc}")
unsupported_attrs = sorted(
name
for name in anchor.attrib
if name.rsplit("}", 1)[-1].lower()
in _UNSUPPORTED_LINK_BEHAVIOR_ATTRIBUTES
)
if unsupported_attrs:
errors.append(
f"{label}: unsupported link behavior attribute(s): "
+ ", ".join(unsupported_attrs)
)
ancestors: list[ET.Element] = []
current = parent_by_id.get(id(anchor))
while current is not None:
ancestors.append(current)
current = parent_by_id.get(id(current))
ancestor_tags = [_local_name(elem) for elem in ancestors]
if "a" in ancestor_tags:
errors.append(f"{label}: nested SVG <a> elements are not supported")
hidden_ancestor = next(
(tag for tag in ancestor_tags if tag in _NON_OUTPUT_ANCESTORS),
None,
)
if hidden_ancestor is not None:
errors.append(
f"{label}: hyperlink cannot appear inside non-output "
f"<{hidden_ancestor}> content"
)
replacement_ancestor = next(
(
elem
for elem in ancestors
if elem.get("data-pptx-replace-with") is not None
or elem.get("data-pptx-part") == "geometry-detail"
),
None,
)
if replacement_ancestor is not None:
errors.append(
f"{label}: hyperlink cannot appear inside content replaced "
"or skipped during native export"
)
inline = any(tag in _INLINE_CONTAINER_TAGS for tag in ancestor_tags)
children = [
child for child in list(anchor)
if _local_name(child) not in _NON_OUTPUT_ANCESTORS
]
if inline:
if any(_local_name(child) not in _INLINE_CONTENT_TAGS for child in children):
errors.append(
f"{label}: inline hyperlink content may contain only <tspan>"
)
positioned = [
elem
for elem in anchor.iter()
if any(elem.get(name) is not None for name in ("x", "y", "dx", "dy"))
]
if positioned:
errors.append(
f"{label}: inline hyperlink cannot own x/y/dx/dy; "
"put line positioning on an enclosing <tspan>"
)
direct_parent = parent_by_id.get(id(anchor))
if (
direct_parent is not None
and _local_name(direct_parent) == "text"
and (
direct_parent.get("data-paragraph-line-height") is not None
or any(
_local_name(sibling) == "tspan"
and any(
sibling.get(name) is not None
for name in ("x", "y", "dx", "dy")
)
for sibling in list(direct_parent)
)
)
):
errors.append(
f"{label}: multi-line hyperlinks must be nested inside "
"the owning line <tspan>"
)
visible_text = "".join(anchor.itertext())
if not visible_text.strip():
errors.append(f"{label}: inline hyperlink must contain visible text")
else:
if anchor.text and anchor.text.strip():
errors.append(
f"{label}: shape hyperlink cannot contain direct text; "
"wrap text in <text>"
)
visible_children = [
child for child in children
if child.get("data-pptx-part") != "geometry-detail"
]
if not visible_children:
errors.append(
f"{label}: shape hyperlink must wrap at least one visual element"
)
if any(_local_name(child) == "tspan" for child in children):
errors.append(
f"{label}: shape hyperlink cannot contain a bare <tspan>; "
"wrap inline runs in <text>"
)
for index, carrier in enumerate(
(
elem
for elem in root.iter()
if elem.get(SHAPE_HYPERLINK_ATTR) is not None
),
1,
):
label = carrier.get("id") or f"shape hyperlink transport {index}"
raw_target = carrier.get(SHAPE_HYPERLINK_ATTR) or ""
try:
parse_hyperlink_target(raw_target, slide_count=slide_count)
except HyperlinkContractError as exc:
errors.append(f"{label}: {exc}")
if _local_name(carrier) != "g":
errors.append(
f"{label}: {SHAPE_HYPERLINK_ATTR} is allowed only on <g>"
)
ancestors: list[ET.Element] = []
current = parent_by_id.get(id(carrier))
while current is not None:
ancestors.append(current)
current = parent_by_id.get(id(current))
if any(_local_name(elem) == "a" for elem in ancestors):
errors.append(
f"{label}: {SHAPE_HYPERLINK_ATTR} cannot be nested in <a>"
)
inline_anchors = [
elem
for elem in carrier.iter(f"{{{SVG_NS}}}a")
if any(
_local_name(ancestor) in _INLINE_CONTAINER_TAGS
for ancestor in _ancestors_of(elem, parent_by_id)
)
]
if not inline_anchors:
errors.append(
f"{label}: {SHAPE_HYPERLINK_ATTR} is reserved for PPTX "
"round-trip groups that also contain inline hyperlinks"
)
return errors
def _ancestors_of(
elem: ET.Element,
parent_by_id: dict[int, ET.Element],
) -> list[ET.Element]:
"""Return ancestors from nearest parent to the SVG root."""
ancestors: list[ET.Element] = []
current = parent_by_id.get(id(elem))
while current is not None:
ancestors.append(current)
current = parent_by_id.get(id(current))
return ancestors
def trigger_shape_hyperlink_errors(
root: ET.Element,
trigger_group_ids: set[str] | frozenset[str],
) -> list[str]:
"""Reject navigation links on click-trigger animation groups."""
if not trigger_group_ids:
return []
parent_by_id = {
id(child): parent
for parent in root.iter()
for child in list(parent)
}
errors: list[str] = []
for elem in root.iter():
group_id = elem.get("id")
if _local_name(elem) != "g" or group_id not in trigger_group_ids:
continue
has_link = elem.get(SHAPE_HYPERLINK_ATTR) is not None or any(
_local_name(descendant) == "a"
or descendant.get(SHAPE_HYPERLINK_ATTR) is not None
for descendant in elem.iter()
)
if not has_link:
has_link = any(
_local_name(ancestor) == "a"
or ancestor.get(SHAPE_HYPERLINK_ATTR) is not None
for ancestor in _ancestors_of(elem, parent_by_id)
)
if has_link:
errors.append(
f"animation trigger group {group_id!r} cannot also carry a "
"hyperlink; use an ordinary animation or a separate trigger"
)
return errors
__all__ = [
"HYPERLINK_REL_TYPE",
"HyperlinkContractError",
"HyperlinkTarget",
"SHAPE_HYPERLINK_ATTR",
"SLIDE_JUMP_ACTION",
"SLIDE_REL_TYPE",
"parse_hyperlink_target",
"project_hyperlink_errors",
"svg_hyperlink_href",
"trigger_shape_hyperlink_errors",
]
@@ -9,12 +9,27 @@ from pathlib import Path
from urllib.parse import urlsplit
from xml.etree import ElementTree as ET
from hyperlink_contract import (
HYPERLINK_REL_TYPE,
SLIDE_JUMP_ACTION,
SLIDE_REL_TYPE,
)
PACKAGE_REL_NS = (
"http://schemas.openxmlformats.org/package/2006/relationships"
)
_RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships"
_RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship"
_DRAWINGML_NS = (
"http://schemas.openxmlformats.org/drawingml/2006/main"
)
_PRESENTATIONML_NS = (
"http://schemas.openxmlformats.org/presentationml/2006/main"
)
_OFFICE_REL_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
_OPC_UNRESERVED = frozenset(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
@@ -127,6 +142,126 @@ def resolve_internal_opc_target(
return canonical_opc_part_path(resolved)
def _relationships_path_for_part(part_path: Path) -> Path:
return part_path.parent / "_rels" / f"{part_path.name}.rels"
def _relationship_attrs_by_id(rels_path: Path) -> dict[str, dict[str, str]]:
if not rels_path.is_file():
return {}
root = ET.parse(rels_path).getroot()
return {
elem.attrib["Id"]: dict(elem.attrib)
for elem in root.findall(_RELATIONSHIP_TAG)
if elem.attrib.get("Id")
}
def _presentation_slide_roster(extract_dir: Path) -> set[str]:
"""Return canonical slide parts reachable from ``p:sldIdLst``."""
presentation = extract_dir / "ppt" / "presentation.xml"
presentation_rels = _relationships_path_for_part(presentation)
if not presentation.is_file() or not presentation_rels.is_file():
return set()
rels = _relationship_attrs_by_id(presentation_rels)
root = ET.parse(presentation).getroot()
roster: set[str] = set()
rels_rel = presentation_rels.relative_to(extract_dir).as_posix()
for slide_id in root.findall(
f"{{{_PRESENTATIONML_NS}}}sldIdLst/"
f"{{{_PRESENTATIONML_NS}}}sldId"
):
relationship_id = slide_id.attrib.get(f"{{{_OFFICE_REL_NS}}}id")
relationship = rels.get(relationship_id or "")
if relationship is None or relationship.get("Type") != SLIDE_REL_TYPE:
continue
target = relationship.get("Target", "")
resolved = resolve_internal_opc_target(rels_rel, target)
if resolved is not None:
roster.add(resolved)
return roster
def verify_hyperlink_relationships(extract_dir: Path) -> list[str]:
"""Return hyperlink/action mismatches and slide jumps outside the roster."""
roster = _presentation_slide_roster(extract_dir)
problems: list[str] = []
source_patterns = (
"ppt/slides/slide*.xml",
"ppt/slideLayouts/slideLayout*.xml",
"ppt/slideMasters/slideMaster*.xml",
)
for pattern in source_patterns:
for part_path in sorted(extract_dir.glob(pattern)):
rels_path = _relationships_path_for_part(part_path)
rels = _relationship_attrs_by_id(rels_path)
part_rel = part_path.relative_to(extract_dir).as_posix()
rels_rel = rels_path.relative_to(extract_dir).as_posix()
try:
root = ET.parse(part_path).getroot()
except ET.ParseError:
continue
for link in root.iter(f"{{{_DRAWINGML_NS}}}hlinkClick"):
action = (link.attrib.get("action") or "").strip()
if action == "ppaction://media":
continue
relationship_id = (
link.attrib.get(f"{{{_OFFICE_REL_NS}}}id") or ""
).strip()
if not relationship_id:
if action == SLIDE_JUMP_ACTION:
problems.append(
f"{part_rel} -> <slide jump without relationship id>"
)
continue
relationship = rels.get(relationship_id)
if relationship is None:
problems.append(
f"{part_rel} -> <missing hyperlink relationship "
f"{relationship_id!r}>"
)
continue
rel_type = relationship.get("Type", "")
target_mode = relationship.get("TargetMode", "")
target = relationship.get("Target", "")
if rel_type == HYPERLINK_REL_TYPE:
if target_mode.lower() != "external":
problems.append(
f"{part_rel} -> <hyperlink relationship "
f"{relationship_id!r} is not External>"
)
if action == SLIDE_JUMP_ACTION:
problems.append(
f"{part_rel} -> <slide jump {relationship_id!r} "
"uses an external hyperlink relationship>"
)
continue
if rel_type == SLIDE_REL_TYPE:
if target_mode:
problems.append(
f"{part_rel} -> <slide relationship "
f"{relationship_id!r} cannot be External>"
)
if action != SLIDE_JUMP_ACTION:
problems.append(
f"{part_rel} -> <slide relationship "
f"{relationship_id!r} lacks hlinksldjump action>"
)
resolved = resolve_internal_opc_target(rels_rel, target)
if resolved is not None and resolved not in roster:
problems.append(
f"{part_rel} -> <slide jump {relationship_id!r} "
f"targets non-roster part {resolved}>"
)
continue
if action == SLIDE_JUMP_ACTION:
problems.append(
f"{part_rel} -> <slide jump {relationship_id!r} uses "
f"relationship type {rel_type!r}>"
)
return problems
def verify_internal_relationships(extract_dir: Path) -> list[str]:
"""Return invalid or dangling internal relationships in an OPC package."""
package_parts: set[str] = set()
@@ -207,4 +342,5 @@ def verify_internal_relationships(extract_dir: Path) -> list[str]:
)
elif resolved not in package_parts:
problems.append(f"{rels_rel} -> {resolved}")
problems.extend(verify_hyperlink_relationships(extract_dir))
return problems
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
PPT Master - PPTX Hyperlink Resolver
Resolve native DrawingML click actions into the canonical SVG ``href`` form
used by the authoring and round-trip pipelines.
Usage:
from .hyperlinks import resolve_click_hyperlink
Examples:
result = resolve_click_hyperlink(rels, "rId3", "", slide_index_by_part=roster)
Dependencies:
PPT Master hyperlink contract.
"""
from __future__ import annotations
from dataclasses import dataclass
from hyperlink_contract import (
HYPERLINK_REL_TYPE,
HyperlinkContractError,
SLIDE_JUMP_ACTION,
SLIDE_REL_TYPE,
parse_hyperlink_target,
)
@dataclass(frozen=True)
class HyperlinkResolution:
"""Resolved SVG target or one actionable source-package diagnostic."""
href: str | None = None
error: str | None = None
def resolve_click_hyperlink(
relationships: dict[str, dict[str, str]],
relationship_id: str,
action: str,
*,
slide_index_by_part: dict[str, int],
) -> HyperlinkResolution:
"""Resolve a click hyperlink relationship from one OOXML source part."""
if action == "ppaction://media":
return HyperlinkResolution()
if not relationship_id:
return HyperlinkResolution(error="click hyperlink has no relationship id")
relationship = relationships.get(relationship_id)
if relationship is None:
return HyperlinkResolution(
error=f"click hyperlink relationship {relationship_id!r} is missing"
)
rel_type = relationship.get("type", "")
target = relationship.get("target", "")
external = relationship.get("external") == "1"
if action == SLIDE_JUMP_ACTION:
if external or rel_type != SLIDE_REL_TYPE:
return HyperlinkResolution(
error="hlinksldjump does not reference an internal slide"
)
slide_index = slide_index_by_part.get(target)
if slide_index is None:
return HyperlinkResolution(
error=f"slide jump target {target!r} is not in the presentation roster"
)
return HyperlinkResolution(href=f"#slide-{slide_index}")
if action.startswith("ppaction://") and action not in {
"ppaction://hlinkurl",
}:
return HyperlinkResolution(
error=f"unsupported PowerPoint click action {action!r}"
)
if not external or rel_type != HYPERLINK_REL_TYPE:
return HyperlinkResolution(
error="click hyperlink does not reference an external hyperlink"
)
try:
parsed = parse_hyperlink_target(target)
except HyperlinkContractError as exc:
return HyperlinkResolution(error=str(exc))
if parsed.kind != "external":
return HyperlinkResolution(
error="external hyperlink relationship resolved to a slide target"
)
return HyperlinkResolution(href=parsed.raw)
__all__ = ["HyperlinkResolution", "resolve_click_hyperlink"]
@@ -424,6 +424,11 @@ class OoxmlPackage:
return self._slides[index - 1]
return None
@property
def slide_index_by_part(self) -> dict[str, int]:
"""Return final presentation-order indices keyed by slide part path."""
return {slide.part.path: slide.index for slide in self._slides}
def iter_all_masters(self) -> Iterator[PartRef]:
"""Yield every slideMaster declared in presentation.xml, regardless of
whether any slide currently uses it.
@@ -71,6 +71,8 @@ class ShapeNode:
name: str = ""
spid: str = ""
hidden: bool = False
hyperlink_rid: str = ""
hyperlink_action: str = ""
placeholder: PlaceholderInfo | None = None
inherited_lst_styles: tuple[ET.Element, ...] = ()
inherited_body_properties: tuple[ET.Element, ...] = ()
@@ -85,7 +87,10 @@ class ShapeNode:
# Walker
# ---------------------------------------------------------------------------
def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, PlaceholderInfo | None]:
def _read_nv_sp_pr(
parent: ET.Element,
nv_tag: str,
) -> tuple[str, str, bool, PlaceholderInfo | None, str, str]:
"""Extract name/id/hidden/placeholder from an nvXXXPr container.
nv_tag is one of nvSpPr / nvPicPr / nvCxnSpPr / nvGrpSpPr / nvGraphicFramePr.
@@ -95,8 +100,10 @@ def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, Pla
spid = ""
hidden = False
ph: PlaceholderInfo | None = None
hyperlink_rid = ""
hyperlink_action = ""
if container is None:
return name, spid, hidden, ph
return name, spid, hidden, ph, hyperlink_rid, hyperlink_action
cnv = container.find("p:cNvPr", NS)
if cnv is not None:
@@ -104,6 +111,10 @@ def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, Pla
spid = cnv.attrib.get("id", "")
if ooxml_bool(cnv.attrib.get("hidden")):
hidden = True
hyperlink = cnv.find("a:hlinkClick", NS)
if hyperlink is not None:
hyperlink_rid = hyperlink.attrib.get(f"{{{NS['r']}}}id", "")
hyperlink_action = hyperlink.attrib.get("action", "")
nv_pr = container.find("p:nvPr", NS)
if nv_pr is not None:
@@ -116,7 +127,7 @@ def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, Pla
orient=ph_elem.attrib.get("orient"),
)
return name, spid, hidden, ph
return name, spid, hidden, ph, hyperlink_rid, hyperlink_action
def _resolve_xfrm(shape: ET.Element, kind: str) -> ET.Element | None:
@@ -248,7 +259,14 @@ def _walk_container(
continue
kind, nv_tag = kind_info
name, spid, hidden, ph = _read_nv_sp_pr(child, nv_tag)
(
name,
spid,
hidden,
ph,
hyperlink_rid,
hyperlink_action,
) = _read_nv_sp_pr(child, nv_tag)
xfrm = parse_xfrm(_resolve_xfrm(child, kind))
effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0
@@ -288,6 +306,8 @@ def _walk_container(
node = ShapeNode(
kind=kind, xml=child, xfrm=xfrm,
name=name, spid=spid, hidden=hidden, placeholder=ph,
hyperlink_rid=hyperlink_rid,
hyperlink_action=hyperlink_action,
inherited_lst_styles=inherited_lst_styles,
inherited_body_properties=inherited_body_properties,
effective_rotation=effective_rotation,
@@ -42,6 +42,7 @@ from pptx_effects import (
txbody_has_run_effects,
unsupported_effect_metadata,
)
from hyperlink_contract import SHAPE_HYPERLINK_ATTR
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload
@@ -57,6 +58,7 @@ from .import_diagnostics import (
ImportDiagnostic,
append_diagnostic,
)
from .hyperlinks import resolve_click_hyperlink
from .ln_to_svg import StrokeResult, resolve_stroke
from .ooxml_loader import (
OoxmlPackage,
@@ -169,6 +171,30 @@ def _diagnose_picture_result(
)
def _resolve_svg_hyperlink(
ctx: AssemblyContext,
relationship_id: str,
action: str,
) -> str | None:
"""Resolve one source-part click link or record its explicit loss."""
resolution = resolve_click_hyperlink(
ctx.slide_part.rels,
relationship_id,
action,
slide_index_by_part=ctx.pkg.slide_index_by_part,
)
if resolution.error is None:
return resolution.href
if ctx.strict:
raise ValueError(resolution.error)
ctx.diagnose(
"hyperlink-omitted",
resolution.error,
"retain the object and omit only its unsupported click link",
)
return None
# ---------------------------------------------------------------------------
# Public entry
# ---------------------------------------------------------------------------
@@ -532,6 +558,11 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
fallback_lst_styles=node.inherited_lst_styles,
id_prefix=f"{ctx.group_id_prefix}txt",
id_seq=ctx.grad_seq,
hyperlink_resolver=lambda rid, action: _resolve_svg_hyperlink(
ctx,
rid,
action,
),
)
else:
text_result = convert_txbody(
@@ -543,6 +574,11 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
fallback_lst_styles=node.inherited_lst_styles,
id_prefix=f"{ctx.group_id_prefix}txt",
id_seq=ctx.grad_seq,
hyperlink_resolver=lambda rid, action: _resolve_svg_hyperlink(
ctx,
rid,
action,
),
) if tx_body is not None else TextResult()
except ValueError as exc:
if ctx.strict:
@@ -1222,6 +1258,11 @@ def _render_graphic_table(
id_prefix=f"tbl{ctx.shape_seq[0]}",
grad_seq=ctx.grad_seq,
marker_seq=ctx.marker_seq,
hyperlink_resolver=lambda rid, action: _resolve_svg_hyperlink(
ctx,
rid,
action,
),
)
if result.defs:
ctx.defs.extend(result.defs)
@@ -1573,7 +1614,21 @@ def _wrap_shape_group(
)
if transform:
attrs.append(f'transform="{transform}"')
return f"<g {' '.join(attrs)}>\n{inner}\n</g>"
group_xml = f"<g {' '.join(attrs)}>\n{inner}\n</g>"
if node.hyperlink_rid or node.hyperlink_action:
href = _resolve_svg_hyperlink(
ctx,
node.hyperlink_rid,
node.hyperlink_action,
)
if href is not None and '<a href=' in inner:
attrs.append(
f'{SHAPE_HYPERLINK_ATTR}="{_xml_escape(href)}"'
)
return f"<g {' '.join(attrs)}>\n{inner}\n</g>"
if href is not None:
return f'<a href="{_xml_escape(href)}">{group_xml}</a>'
return group_xml
def _attrs_to_xml(attrs: dict[str, str]) -> str:
@@ -56,6 +56,7 @@ from .emu_units import (
from .fill_to_svg import FillResult, resolve_fill
from .ln_to_svg import resolve_stroke
from .txbody_to_svg import _resolve_theme_typeface, convert_txbody
from .txbody_to_svg import HyperlinkResolver
BUILTIN_MEDIUM_STYLE_2_ACCENT_1 = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
@@ -279,6 +280,7 @@ def convert_tbl(
id_prefix: str = "tbl",
grad_seq: list[int] | None = None,
marker_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> TableResult:
"""Render an <a:tbl> at the given absolute xfrm into SVG markup."""
grad_seq = grad_seq if grad_seq is not None else [0]
@@ -414,6 +416,7 @@ def convert_tbl(
slide_number=slide_number,
id_prefix=f"{id_prefix}txt",
id_seq=grad_seq,
hyperlink_resolver=hyperlink_resolver,
)
defs.extend(text_result.defs)
if text_result.svg:
@@ -1549,6 +1552,7 @@ def _convert_cell_text(
slide_number: int | None,
id_prefix: str,
id_seq: list[int] | None,
hyperlink_resolver: HyperlinkResolver | None,
):
"""Render cell text. PowerPoint's <a:tcPr> can override txBody insets via
its own marL/marR/marT/marB attrs; convert_txbody reads from <a:bodyPr>,
@@ -1602,6 +1606,7 @@ def _convert_cell_text(
slide_number=slide_number,
id_prefix=id_prefix,
id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
except (AttributeError, OverflowError, TypeError, ValueError):
plain_tx_body = _plain_table_text_body(render_tx_body, overrides)
@@ -1611,6 +1616,7 @@ def _convert_cell_text(
slide_number=slide_number,
id_prefix=id_prefix,
id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
finally:
if overrides and body_pr is not None:
@@ -23,6 +23,7 @@ back to paragraph/list defaults, endParaRPr, or spec-default values.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from xml.etree import ElementTree as ET
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
@@ -64,6 +65,10 @@ class TextRun:
strikethrough: bool = False
letter_spacing_px: float = 0.0
is_break: bool = False # marks an a:br within a paragraph
hyperlink_href: str | None = None
HyperlinkResolver = Callable[[str, str], str | None]
@dataclass
@@ -114,6 +119,7 @@ def convert_txbody(
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> TextResult:
"""Convert <p:txBody> under the given shape geometry to SVG <text>(s)."""
if tx_body is None:
@@ -126,6 +132,7 @@ def convert_txbody(
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
if not paragraphs or not _has_visible_text(paragraphs):
return TextResult()
@@ -224,6 +231,7 @@ def convert_vertical_txbody(
fallback_run_props: tuple[ET.Element, ...] = (),
id_prefix: str = "txt",
id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> TextResult:
"""Render East Asian vertical text as upright stacked glyphs.
@@ -241,6 +249,7 @@ def convert_vertical_txbody(
fallback_lst_styles=fallback_lst_styles,
fallback_run_props=fallback_run_props,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
runs = [
run
@@ -280,12 +289,15 @@ def convert_vertical_txbody(
if first_run is None:
first_run = run
first_baseline = baseline_y
spans.append(f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>")
run_span = f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>"
spans.append(_wrap_run_hyperlink(run_span, run))
else:
dy = baseline_y - (previous_baseline or baseline_y)
run_span = f"<tspan{tspan_attrs}>{_xml_escape(char)}</tspan>"
linked_span = _wrap_run_hyperlink(run_span, run)
spans.append(
f'<tspan x="{fmt_num(center_x)}" dy="{fmt_num(dy)}"'
f"{tspan_attrs}>{_xml_escape(char)}</tspan>"
f'<tspan x="{fmt_num(center_x)}" dy="{fmt_num(dy)}">'
f"{linked_span}</tspan>"
)
previous_baseline = baseline_y
cursor_y += advance
@@ -374,6 +386,7 @@ def _parse_paragraphs(
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> list[TextParagraph]:
"""Walk <a:p> children producing TextParagraph objects."""
paragraphs: list[TextParagraph] = []
@@ -393,6 +406,7 @@ def _parse_paragraphs(
default_font_size_px=default_font_size_px,
slide_number=slide_number,
id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
paragraphs.append(para)
@@ -412,6 +426,7 @@ def _parse_paragraph(
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> TextParagraph:
para = TextParagraph()
@@ -452,6 +467,7 @@ def _parse_paragraph(
default_fill=default_fill,
default_font_size_px=default_font_size_px,
id_prefix=id_prefix, id_seq=id_seq,
hyperlink_resolver=hyperlink_resolver,
)
for child in list(p_elem):
@@ -517,6 +533,7 @@ def _build_run(
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
hyperlink_resolver: HyperlinkResolver | None = None,
) -> TextRun:
"""Resolve a single <a:r> run from its rPr and fallback run properties."""
style_chain = (
@@ -605,6 +622,14 @@ def _build_run(
)
font_family = _build_font_stack(latin_face, ea_face, cs_face)
hyperlink_href: str | None = None
if rpr is not None and hyperlink_resolver is not None:
hyperlink = rpr.find("a:hlinkClick", NS)
if hyperlink is not None:
hyperlink_href = hyperlink_resolver(
hyperlink.attrib.get(f"{{{NS['r']}}}id", ""),
hyperlink.attrib.get("action", ""),
)
return TextRun(
text=text,
@@ -618,6 +643,7 @@ def _build_run(
underline=underline,
strikethrough=strikethrough,
letter_spacing_px=letter_spacing_px,
hyperlink_href=hyperlink_href,
)
@@ -1125,6 +1151,7 @@ def _copy_run(run: TextRun, *, text: str) -> TextRun:
underline=run.underline,
strikethrough=run.strikethrough,
letter_spacing_px=run.letter_spacing_px,
hyperlink_href=run.hyperlink_href,
)
@@ -1238,6 +1265,23 @@ def _emit_paragraph(
f'dy="{fmt_num(line_advance)}"></tspan>'
)
continue
line_has_hyperlink = any(run.hyperlink_href for run in line)
if line_has_hyperlink:
run_spans = ''.join(
_wrap_run_hyperlink(
f'<tspan{_run_tspan_attrs(run)}>'
f'{_xml_escape(run.text)}</tspan>',
run,
)
for run in line
)
position_attrs = (
f' x="{fmt_num(anchor_x)}" dy="{fmt_num(line_advance)}"'
if line_advance is not None
else ''
)
spans.append(f'<tspan{position_attrs}>{run_spans}</tspan>')
continue
for run_idx, run in enumerate(line):
attrs = _run_tspan_attrs(run)
if run_idx == 0 and line_advance is not None:
@@ -1318,6 +1362,13 @@ def _run_tspan_attrs(run: TextRun) -> str:
return " " + " ".join(parts)
def _wrap_run_hyperlink(markup: str, run: TextRun) -> str:
"""Wrap one visible SVG run in the canonical hyperlink carrier."""
if not run.hyperlink_href:
return markup
return f'<a href="{_xml_escape(run.hyperlink_href)}">{markup}</a>'
def _xml_escape(text: str) -> str:
return (text.replace("&", "&amp;")
.replace("<", "&lt;")
@@ -18,7 +18,7 @@
"file_budgets": {
"AGENTS.md": 2750,
"skills/ppt-master/SKILL.md": 1250,
"skills/ppt-master/references/executor-base.md": 9250,
"skills/ppt-master/references/executor-base.md": 10000,
"skills/ppt-master/references/executor-structured.md": 5500,
"skills/ppt-master/references/executor-chart.md": 3500,
"skills/ppt-master/references/executor-visualization.md": 1250,
@@ -326,15 +326,23 @@
"max_tokens": 110000
},
"route.generate.quick-generate.formula": {
"description": "Quick Generate with manifest-driven formula rendering and formula-image realization.",
"description": "Quick Generate with direct PowerPoint-native formula marker authoring.",
"scope": "cumulative",
"include": [
"route.generate.quick-generate",
"stage.generate.executor.image"
"stage.generate.executor.formula"
],
"files": [
"skills/ppt-master/scripts/docs/image.md"
"files": [],
"max_tokens": 110000
},
"route.generate.quick-generate.hyperlink": {
"description": "Quick Generate with direct PowerPoint-native external and same-deck hyperlink authoring.",
"scope": "cumulative",
"include": [
"route.generate.quick-generate",
"stage.generate.executor.hyperlink"
],
"files": [],
"max_tokens": 110000
},
"route.generate.quick-generate.ai-two-types": {
@@ -386,14 +394,13 @@
"max_tokens": 115000
},
"route.generate.planning-formula": {
"description": "Formula-only planning context, including the shared image/formula layout math and vocabulary.",
"description": "Formula-bearing planning context; Strategist records exact mathematics without loading image-layout or formula-rendering resources.",
"scope": "cumulative",
"include": [
"route.generate.planning",
"stage.generate.strategist.image"
"route.generate.planning"
],
"files": [],
"max_tokens": 115000
"max_tokens": 110000
},
"route.generate.planning-ai": {
"description": "Generate-PPTX planning after AI imagery is confirmed, adding image-layout and resource-planning authorities to the rendering index and already-selected rendering bases.",
@@ -538,6 +545,30 @@
"files": [],
"max_tokens": 165000
},
"route.generate.flat-formula": {
"description": "Default flat Generate-PPTX context with direct PowerPoint-native formula marker authoring and no image branch.",
"scope": "cumulative",
"include": [
"route.generate.planning-formula",
"stage.generate.executor.flat",
"stage.generate.executor.formula",
"stage.generate.executor.notes"
],
"files": [],
"max_tokens": 170000
},
"route.generate.flat-hyperlink": {
"description": "Default flat Generate-PPTX context with PowerPoint-native external and same-deck hyperlink authoring.",
"scope": "cumulative",
"include": [
"route.generate.planning",
"stage.generate.executor.flat",
"stage.generate.executor.hyperlink",
"stage.generate.executor.notes"
],
"files": [],
"max_tokens": 170000
},
"route.generate.flat-ai-two-types": {
"description": "Generate-PPTX context with AI images, the selected preset or exact custom rendering bases, and two local types.",
"scope": "cumulative",
@@ -552,7 +583,7 @@
"max_tokens": 205000
},
"route.generate.flat-in-hand-image": {
"description": "Generate-PPTX context with provided, placeholder, or formula images and no acquisition role.",
"description": "Generate-PPTX context with provided or placeholder images and no acquisition role.",
"scope": "cumulative",
"include": [
"route.generate.planning-image",
@@ -991,7 +1022,7 @@
"max_tokens": 1000
},
"stage.generate.strategist.image": {
"description": "Conditional Strategist image/formula layout math and compact composition vocabulary; the rendering decision core is already in base planning.",
"description": "Conditional Strategist image layout math and compact composition vocabulary; the rendering decision core is already in base planning.",
"scope": "incremental",
"files": [
"skills/ppt-master/references/image-layout-spec.md",
@@ -1015,6 +1046,22 @@
],
"max_tokens": 6750
},
"stage.generate.executor.formula": {
"description": "Conditional direct authoring contract for PowerPoint-native inline and block formulas.",
"scope": "incremental",
"files": [
"skills/ppt-master/references/native-formula.md"
],
"max_tokens": 1250
},
"stage.generate.executor.hyperlink": {
"description": "Conditional direct authoring contract for PowerPoint-native external and same-deck click hyperlinks.",
"scope": "incremental",
"files": [
"skills/ppt-master/references/native-hyperlinks.md"
],
"max_tokens": 1250
},
"stage.generate.executor.image": {
"description": "Conditional image embedding and execution rules with the always-read layout math and compact composition vocabulary.",
"scope": "incremental",
@@ -2021,6 +2068,10 @@
"glob": "skills/ppt-master/scripts/docs/prompt_audit.md",
"reason": "Maintainer-only documentation for this audit tool."
},
{
"glob": "skills/ppt-master/scripts/docs/image.md",
"reason": "Standalone image-tool command reference; generation roles load the owning image contracts directly, and its formula renderer section is legacy-only."
},
{
"glob": "skills/ppt-master/scripts/docs/mask-gradient-smoke.md",
"reason": "Maintainer-only executable smoke; never loaded by generation roles."
@@ -217,8 +217,11 @@ def _encode_md_url(url: str) -> str:
return quote(url, safe="/:?=&%#@!$'*+,;")
def _resolve_internal_jump(run: object, shape: object) -> str | None:
"""Return ``#slide-N`` for a run carrying a slide-internal jump, else None.
def _resolve_internal_jump(
run: object,
shape: object,
) -> tuple[bool, str | None]:
"""Return whether a run is an internal jump and its resolved target.
Reads ``run._r`` (private python-pptx API) because the public
``run.hyperlink.address`` cannot tell an internal jump apart from an
@@ -228,26 +231,26 @@ def _resolve_internal_jump(run: object, shape: object) -> str | None:
try:
rpr = run._r.find(qn("a:rPr"))
if rpr is None:
return None
return False, None
hlink = rpr.find(qn("a:hlinkClick"))
if hlink is None or "hlinksldjump" not in (hlink.get("action", "") or ""):
return None
return False, None
r_id = hlink.get(qn("r:id"), "")
if not r_id:
return None
return True, None
target_slide = shape.part.related_part(r_id).slide
prs = shape.part.slide.part.package.presentation_part.presentation
return f"#slide-{list(prs.slides).index(target_slide) + 1}"
return True, f"#slide-{list(prs.slides).index(target_slide) + 1}"
except (KeyError, ValueError, AttributeError):
print(f"[WARN] ppt_to_md: could not resolve slide jump rId={r_id}", file=sys.stderr)
return None
return True, None
def _run_url(run: object, shape: object) -> str | None:
"""Resolve a run's hyperlink target to a markdown-ready URL, or None."""
if shape is not None:
internal = _resolve_internal_jump(run, shape)
if internal:
is_internal, internal = _resolve_internal_jump(run, shape)
if is_internal:
return internal
try:
addr = run.hyperlink.address
@@ -258,7 +261,12 @@ def _run_url(run: object, shape: object) -> str | None:
return None
def _paragraph_to_markdown(paragraph: object, shape: object) -> str:
def _paragraph_to_markdown(
paragraph: object,
shape: object,
*,
use_shape_click_action: bool = True,
) -> str:
"""Render one paragraph, merging consecutive runs that share a URL.
Run text is concatenated verbatim including the spaces between runs and
@@ -298,28 +306,40 @@ def _paragraph_to_markdown(paragraph: object, shape: object) -> str:
text = normalize_text("".join(parts))
# Shape-level click_action only matters when no run carried its own link.
if not has_run_hyperlink and shape is not None:
if (
use_shape_click_action
and not has_run_hyperlink
and shape is not None
):
text = _apply_shape_click_action(text, shape)
return text
def _apply_shape_click_action(text: str, shape: object) -> str:
"""Wrap paragraph text in a link from the shape's click_action, if any."""
target = _shape_click_target(shape)
if target is None:
return text
return f"[{_escape_md_link_text(text)}]({target})"
def _shape_click_target(shape: object) -> str | None:
"""Return one Markdown-ready whole-shape click target, if supported."""
try:
action = shape.click_action
if action.action == PP_ACTION.HYPERLINK:
url = action.hyperlink.address or ""
if _is_supported_url(url):
return f"[{_escape_md_link_text(text)}]({_encode_md_url(url)})"
return _encode_md_url(url)
elif action.action == PP_ACTION.NAMED_SLIDE:
target = action.target_slide
if target is not None:
prs = shape.part.slide.part.package.presentation_part.presentation
idx = list(prs.slides).index(target) + 1
return f"[{_escape_md_link_text(text)}](#slide-{idx})"
return f"#slide-{idx}"
except (AttributeError, ValueError):
print("[WARN] ppt_to_md: could not process shape click_action", file=sys.stderr)
return text
return None
def _paragraph_has_hyperlink(paragraph: object) -> bool:
@@ -339,7 +359,12 @@ def _paragraph_has_hyperlink(paragraph: object) -> bool:
return False
def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
def text_frame_to_markdown(
text_frame: object,
shape: object = None,
*,
use_shape_click_action: bool = True,
) -> str:
"""Convert a PowerPoint text frame into Markdown, preserving hyperlinks.
Run-level external URLs and slide-internal jumps are emitted as
@@ -362,7 +387,11 @@ def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
paragraphs = []
for paragraph in visible_paragraphs:
text = _escape_readback_control_lines(
_paragraph_to_markdown(paragraph, shape)
_paragraph_to_markdown(
paragraph,
shape,
use_shape_click_action=use_shape_click_action,
)
)
if not text:
continue
@@ -377,11 +406,20 @@ def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
return "\n\n".join(paragraphs)
def table_to_markdown(table: object) -> str:
def table_to_markdown(table: object, shape: object = None) -> str:
"""Convert a PowerPoint table to a Markdown table."""
rows = []
for row in table.rows:
cells = [escape_table_cell(cell.text) for cell in row.cells]
cells = [
escape_table_cell(
text_frame_to_markdown(
cell.text_frame,
shape,
use_shape_click_action=False,
)
)
for cell in row.cells
]
rows.append(cells)
if not rows:
@@ -1098,7 +1136,7 @@ def convert_presentation_to_markdown(
shape = item.shape
if getattr(shape, "has_table", False):
table_md = table_to_markdown(shape.table)
table_md = table_to_markdown(shape.table, shape)
if table_md:
blocks.append(table_md)
continue
@@ -1136,10 +1174,14 @@ def convert_presentation_to_markdown(
image_count = next_image_index
image_manifest.append(saved_picture.manifest_entry)
asset_dir_used = True
blocks.append(
image_markdown = (
f"![Slide {slide_index} Image {image_ref_count}]"
f"({asset_dir.name}/{saved_picture.filename})"
)
image_link = _shape_click_target(shape)
if image_link is not None:
image_markdown = f"[{image_markdown}]({image_link})"
blocks.append(image_markdown)
if is_picture_shape:
continue
@@ -189,6 +189,7 @@ PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break"
# Marks an authored visual line boundary that remains a hard DrawingML break
# in the default single-frame preserve mode.
PARAGRAPH_LINE_BREAK_ATTR = "data-paragraph-line-break"
INLINE_FORMULA_ATTR = "data-pptx-inline-formula"
# Tolerance for detecting "base line-height" vs "paragraph gap": dy values
# within ±DY_TOLERANCE_PX of each other are considered the same line-height.
@@ -676,8 +677,42 @@ def flatten_text_with_tspans(
def _has_tspan_children(elem: ET.Element) -> bool:
"""Return True if elem contains any nested <tspan> children (inline runs)."""
return any(c.tag == f"{{{SVG_NS}}}tspan" for c in list(elem))
"""Return True when one inline subtree has nested runs or hyperlinks."""
return any(
c.tag in {
f"{{{SVG_NS}}}a",
f"{{{SVG_NS}}}tspan",
}
for c in list(elem)
)
def _copy_inline_element(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
"""Deep-copy one supported inline ``tspan`` or hyperlink subtree."""
local = src.tag.rsplit("}", 1)[-1]
if local not in {"a", "tspan"}:
raise ValueError(f"Unsupported inline text child <{local}>")
new = ET.Element(f"{{{SVG_NS}}}{local}")
consumed_dx = (
local == "tspan"
and strip_line_attrs
and _positional_tspan_attribute(src) is not None
)
for k, v in src.attrib.items():
if strip_line_attrs and k in ("x", "y", "dy"):
continue
if k == "dx" and consumed_dx:
continue
new.set(k, v)
new.text = src.text
for child in list(src):
if child.tag in {
f"{{{SVG_NS}}}a",
f"{{{SVG_NS}}}tspan",
}:
new.append(_copy_inline_element(child, strip_line_attrs=False))
new.tail = src.tail
return new
def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
@@ -689,23 +724,7 @@ def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
dx on later inline runs.
Nested tspans are copied recursively without stripping (they are already inline-only).
"""
new = ET.Element(f"{{{SVG_NS}}}tspan")
consumed_dx = (
strip_line_attrs
and _positional_tspan_attribute(src) is not None
)
for k, v in src.attrib.items():
if strip_line_attrs and k in ("x", "y", "dy"):
continue
if k == "dx" and consumed_dx:
continue
new.set(k, v)
new.text = src.text
for child in list(src):
if child.tag == f"{{{SVG_NS}}}tspan":
new.append(_copy_inline_tspan(child, strip_line_attrs=False))
new.tail = src.tail
return new
return _copy_inline_element(src, strip_line_attrs)
def _create_text_element_from_line(
@@ -741,6 +760,7 @@ def _create_text_element_from_line(
and len(tspans) == 1
and not _has_tspan_children(tspans[0])
and not tspans[0].tail
and tspans[0].get(INLINE_FORMULA_ATTR) is None
):
tspan = tspans[0]
content = collect_text_content(tspan)
@@ -119,6 +119,15 @@ except ImportError:
_unsafe_exported_font_faces = None
_validate_dml_shape_matrix = None
try:
from hyperlink_contract import (
SHAPE_HYPERLINK_ATTR as _SHAPE_HYPERLINK_ATTR,
project_hyperlink_errors as _project_hyperlink_errors,
)
except ImportError:
_SHAPE_HYPERLINK_ATTR = 'data-pptx-shape-hyperlink'
_project_hyperlink_errors = None
try:
from svg_to_pptx.drawingml.converter import (
SvgNativeConversionError as _SvgNativeConversionError,
@@ -209,13 +218,17 @@ except ImportError:
try:
from svg_to_pptx.native_objects import (
INLINE_FORMULA_ATTR as _INLINE_FORMULA_ATTR,
native_fallback_kind as _native_fallback_kind,
inline_formula_marker_errors as _inline_formula_marker_errors,
native_marker_legacy_warnings as _native_marker_legacy_warnings,
native_replacement_kind as _native_replacement_kind,
native_replacement_status as _native_replacement_status,
)
except ImportError:
_INLINE_FORMULA_ATTR = 'data-pptx-inline-formula'
_native_fallback_kind = None
_inline_formula_marker_errors = None
_native_marker_legacy_warnings = None
_native_replacement_kind = None
_native_replacement_status = None
@@ -1054,6 +1067,7 @@ class SVGQualityChecker:
self._communication_trace_issues: List[Tuple[str, str]] = []
self._pptx_structure_issues: List[Tuple[str, str]] = []
self._has_incomplete_page_roster = False
self._active_slide_count: int | None = None
self._prototype_by_output: Dict[Path, Path] = {}
self._active_prototype_path: Path | None = None
self._active_template_reuse_scope: str | None = None
@@ -1219,6 +1233,9 @@ class SVGQualityChecker:
# 5. Check text wrapping methods
self._check_text_elements(content, root, result)
# 5b. Validate native hyperlink targets and carrier structure.
self._check_hyperlinks(root, result)
# 6. Check image references (file existence and resolution)
self._check_image_references(root, svg_path, result)
@@ -1238,7 +1255,7 @@ class SVGQualityChecker:
# 8b. Check <pattern> elements declare a PPTX preset.
self._check_pattern_fills(root, result)
# 8c. Check opt-in native table/chart markers before export.
# 8c. Check explicit native replacement markers before export.
self._check_native_object_markers(root, result)
# 8d. Validate explicit master/layout/placeholder metadata.
@@ -1494,6 +1511,32 @@ class SVGQualityChecker:
self._check_unmergeable_leading_text(root, result)
self._check_nested_positional_tspans(root, result)
def _check_hyperlinks(self, root: ET.Element, result: Dict) -> None:
"""Validate the standard SVG anchor surface shared with export."""
anchors = [
elem for elem in root.iter()
if _local_name(elem) == 'a'
]
transports = [
elem for elem in root.iter()
if elem.get(_SHAPE_HYPERLINK_ATTR) is not None
]
if not anchors and not transports:
return
result['info']['hyperlinks'] = len(anchors) + len(transports)
if _project_hyperlink_errors is None:
result['errors'].append(
'Unable to import hyperlink validator; cannot verify SVG links'
)
return
result['errors'].extend(
f'Invalid SVG hyperlink: {error}'
for error in _project_hyperlink_errors(
root,
slide_count=self._active_slide_count,
)
)
def _check_nested_positional_tspans(
self,
root: ET.Element,
@@ -3726,7 +3769,20 @@ class SVGQualityChecker:
)
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
"""Validate opt-in native table/chart markers before PPTX export."""
"""Validate explicit native replacement markers before PPTX export."""
inline_formula_markers = [
elem for elem in root.iter()
if elem.get(_INLINE_FORMULA_ATTR) is not None
]
if inline_formula_markers and _inline_formula_marker_errors is None:
result['errors'].append(
"Unable to import inline-formula validator; cannot verify "
f"{_INLINE_FORMULA_ATTR} markers"
)
elif _inline_formula_marker_errors is not None:
for error in _inline_formula_marker_errors(root):
result['errors'].append(f"Invalid inline formula marker: {error}")
invalid_status_elements: set[ET.Element] = set()
for elem in root.iter():
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
@@ -5029,6 +5085,8 @@ class SVGQualityChecker:
self.issue_types['Input issues'] += 1
return []
self._active_slide_count = len(svg_files)
self._configure_prototype_context(dir_path, svg_files)
if not self.template_mode:
self._prepare_undeclared_size_occurrences(svg_files)
@@ -387,7 +387,7 @@ def check_reference_spelling(root: ET.Element, result: Dict) -> None:
labels = []
xlink_href = f'{{{XLINK_NS}}}href'
for elem in root.iter():
if _local_name(elem).lower() not in {'image', 'use'}:
if _local_name(elem).lower() not in {'a', 'image', 'use'}:
continue
if elem.get(xlink_href) is not None:
labels.append(_element_label(elem))
@@ -10,6 +10,8 @@ from pathlib import Path, PureWindowsPath
from typing import Any
from xml.etree import ElementTree as ET
from hyperlink_contract import SHAPE_HYPERLINK_ATTR
from pptx_animations import (
ANIMATIONS,
ANIMATION_AFTER_EFFECTS,
@@ -77,6 +79,7 @@ class GroupTarget:
order: int
chrome: bool = False
structurally_static: bool = False
has_hyperlink: bool = False
@dataclass(frozen=True)
@@ -156,6 +159,11 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
order=visual_index,
chrome=chrome,
structurally_static=structurally_static,
has_hyperlink=any(
_tag_name(descendant) == 'a'
or descendant.get(SHAPE_HYPERLINK_ATTR) is not None
for descendant in child.iter()
),
)
)
@@ -1492,6 +1500,12 @@ def validate_animation_config(
f'references non-triggerable structural group '
f'{trigger_shape!r}'
)
elif trigger_target.has_hyperlink:
warnings.append(
f'animations.json {effect_path}.trigger_shape '
f'references hyperlink-bearing group {trigger_shape!r}; '
'use an ordinary animation or a separate trigger'
)
morph_pairs, morph_errors = _resolve_morph_pairs(
list(targets_by_slide),
@@ -69,6 +69,8 @@ class ConvertContext:
claimed_shape_ids: set[int] = field(default_factory=set)
referenced_shape_ids: set[int] = field(default_factory=set)
slide_num: int = 1
# Public presentation roster size, used to fail closed on #slide-N links.
slide_count: int | None = None
translate_x: float = 0.0
translate_y: float = 0.0
scale_x: float = 1.0
@@ -245,6 +247,7 @@ class ConvertContext:
claimed_shape_ids=self.claimed_shape_ids,
referenced_shape_ids=self.referenced_shape_ids,
slide_num=self.slide_num,
slide_count=self.slide_count,
translate_x=self.translate_x + dx,
translate_y=self.translate_y + dy,
scale_x=self.scale_x * sx,
@@ -12,6 +12,11 @@ from typing import Any
from xml.etree import ElementTree as ET
from native_payloads import NativePayloadError, hydrate_native_payload_refs
from hyperlink_contract import (
SHAPE_HYPERLINK_ATTR,
project_hyperlink_errors,
svg_hyperlink_href,
)
from pptx_shapes import (
has_relationship_attributes,
resolve_preset_preview_hash,
@@ -33,6 +38,7 @@ from .context import (
ShapeResult,
resolve_text_flow,
)
from .hyperlinks import apply_shape_hyperlink
from .paths import (
project_freeform_geometry_errors,
project_gradient_geometry_errors,
@@ -92,8 +98,10 @@ from ..canvas_contract import (
parse_project_viewbox,
)
from ..native_objects import (
INLINE_FORMULA_ATTR,
NativeMarkerAttributeError,
convert_native_object,
inline_formula_marker_errors,
native_metadata_payload_matches,
native_replacement_kind,
native_marker_transform,
@@ -117,11 +125,11 @@ def _hydrate_native_payloads(root: ET.Element, svg_path: Path) -> int:
) from exc
def _require_chart_table_marker_attributes(
def _require_native_marker_attributes(
root: ET.Element,
svg_path: Path | str,
) -> None:
"""Reject contradictory chart/table marker aliases before either route."""
"""Reject contradictory native marker aliases before conversion."""
errors: list[str] = []
for elem in root.iter():
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
@@ -147,11 +155,64 @@ def _require_chart_table_marker_attributes(
preview = '; '.join(errors[:8])
suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
raise SvgNativeConversionError(
f'{Path(svg_path).name}: invalid chart/table replacement metadata: '
f'{Path(svg_path).name}: invalid native replacement metadata: '
f'{preview}{suffix}'
)
def _require_inline_formula_markers(
root: ET.Element,
svg_path: Path | str,
) -> None:
"""Reject malformed inline formula runs before text lowering."""
errors = inline_formula_marker_errors(root)
if not errors:
return
preview = '; '.join(errors[:8])
suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
raise SvgNativeConversionError(
f'{Path(svg_path).name}: invalid {INLINE_FORMULA_ATTR} marker(s): '
f'{preview}{suffix}'
)
def _require_project_hyperlinks(
root: ET.Element,
svg_path: Path | str,
*,
slide_count: int | None,
) -> None:
"""Reject hyperlinks that cannot be represented faithfully in PPTX."""
errors = project_hyperlink_errors(root, slide_count=slide_count)
if not errors:
return
preview = '; '.join(errors[:8])
suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
raise SvgNativeConversionError(
f'{Path(svg_path).name}: invalid SVG hyperlink(s): {preview}{suffix}'
)
def _native_replacement_enabled(elem: ET.Element, ctx: ConvertContext) -> bool:
"""Return whether this marker is active under the current export policy."""
kind = native_replacement_kind(elem)
if kind == 'formula':
return True
return ctx.native_objects_enabled and kind in {'chart', 'table'}
def _contains_enabled_native_replacement(
elem: ET.Element,
ctx: ConvertContext,
) -> bool:
"""Return whether one subtree contains an active native replacement."""
return any(
descendant.tag.replace(f'{{{SVG_NS}}}', '') != 'metadata'
and _native_replacement_enabled(descendant, ctx)
for descendant in elem.iter()
)
def _require_project_freeform_geometry(
root: ET.Element,
svg_path: Path | str,
@@ -693,11 +754,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
keep their absolute slide coordinates unchanged.
"""
transform = elem.get('transform', '')
native_subtree_active = ctx.native_objects_enabled and any(
native_replacement_kind(descendant)
and descendant.tag.replace(f'{{{SVG_NS}}}', '') != 'metadata'
for descendant in elem.iter()
)
native_subtree_active = _contains_enabled_native_replacement(elem, ctx)
if native_subtree_active:
dx, dy, sx, sy = native_marker_transform(transform)
angle_deg = 0.0
@@ -789,12 +846,12 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
if native_subtree_active and child_ctx.opacity_multiplier < 1.0:
raise SvgNativeConversionError(
"Group opacity cannot be applied to data-pptx-replace-with chart/table "
"objects; export without --native-charts-and-tables to use the "
"shape-based SVG fallback"
"Group opacity cannot be applied to an active native replacement; "
"remove the group opacity or remove data-pptx-replace-with to keep "
"the subtree as ordinary SVG"
)
if child_ctx.native_objects_enabled:
if _native_replacement_enabled(elem, child_ctx):
native_result = convert_native_object(elem, child_ctx)
if native_result:
ctx.sync_from_child(child_ctx)
@@ -977,6 +1034,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
</p:grpSp>''', bounds_emu=(group_x, group_y, group_x + group_w, group_y + group_h))
def convert_a(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert one standard SVG anchor into a clickable DrawingML object."""
result = convert_g(elem, ctx)
if result is None:
return None
return apply_shape_hyperlink(result, ctx, svg_hyperlink_href(elem))
# ---------------------------------------------------------------------------
# Defs collection & element dispatch
# ---------------------------------------------------------------------------
@@ -994,6 +1059,7 @@ _CONVERTERS = {
'text': convert_text,
'image': convert_image,
'g': convert_g,
'a': convert_a,
'svg': convert_nested_svg,
}
@@ -1188,6 +1254,18 @@ def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str,
return {'output_geometry': 'picture', 'fidelity': 'native-normalized'}
if xml.startswith('<p:graphicFrame>'):
return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'}
if xml.startswith('<mc:AlternateContent'):
if elem.tag.replace(f'{{{SVG_NS}}}', '') == 'text':
inline_formula_count = sum(
1 for child in elem.iter()
if child.get(INLINE_FORMULA_ATTR) is not None
)
return {
'output_geometry': 'text',
'inline_formula_count': inline_formula_count,
'fidelity': 'native-normalized',
}
return {'output_geometry': 'native-formula', 'fidelity': 'exact'}
preset_match = re.search(r'<a:prstGeom prst="([^"]+)"', xml)
if preset_match is not None:
@@ -1291,6 +1369,9 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
if converter:
try:
result = converter(elem, ctx)
shape_hyperlink = elem.get(SHAPE_HYPERLINK_ATTR)
if result is not None and shape_hyperlink is not None:
result = apply_shape_hyperlink(result, ctx, shape_hyperlink)
except Exception as e:
trace('error', error=str(e))
raise SvgNativeConversionError(f'Failed to convert <{tag}>: {e}') from e
@@ -1344,7 +1425,7 @@ def collect_unsupported_visuals(
return
is_supported_visual_child = (
tag in _SUPPORTED_VISUAL_CHILD_TAGS
and parent_tag in {'text', 'tspan'}
and parent_tag in {'text', 'tspan', 'a'}
)
is_data_icon_placeholder = (
allow_data_icon_use
@@ -1372,6 +1453,7 @@ def collect_unsupported_visuals(
def convert_svg_to_slide_shapes(
svg_path: str | Path,
slide_num: int = 1,
slide_count: int | None = None,
verbose: bool = False,
merge_paragraphs: bool | None = None,
image_optimize: bool = True,
@@ -1400,6 +1482,7 @@ def convert_svg_to_slide_shapes(
Args:
svg_path: Path to the SVG file.
slide_num: Slide number (for naming).
slide_count: Public deck size used to validate ``#slide-N`` targets.
verbose: Print progress info.
merge_paragraphs: Legacy compatibility option. True selects reflow;
False selects split. Do not combine with ``text_flow``.
@@ -1413,7 +1496,8 @@ def convert_svg_to_slide_shapes(
image_scale: Target image pixels per SVG display pixel.
image_quality: JPEG quality used for opaque optimized rasters.
native_objects: Convert explicit ``data-pptx-replace-with`` chart/table
markers to native PowerPoint Chart/Table objects. Default off.
markers to native PowerPoint Chart/Table objects. Formula markers
are intrinsically native and do not use this opt-in. 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.
@@ -1455,7 +1539,13 @@ def convert_svg_to_slide_shapes(
)
except CanvasContractError as exc:
raise SvgNativeConversionError(str(exc)) from exc
_require_chart_table_marker_attributes(root, svg_path)
_require_native_marker_attributes(root, svg_path)
_require_inline_formula_markers(root, svg_path)
_require_project_hyperlinks(
root,
svg_path,
slide_count=slide_count,
)
_require_project_nested_svg_crops(root, svg_path)
_require_project_clip_paths(root, svg_path)
authored_errors = validate_authored_preset_tree(root)
@@ -1597,6 +1687,13 @@ def convert_svg_to_slide_shapes(
if verbose:
print(f' Expanded {expanded_local} local <use href="#..."/> instance(s)')
_require_inline_formula_markers(root, svg_path)
_require_project_hyperlinks(
root,
svg_path,
slide_count=slide_count,
)
# Recheck compiler-injected icon/use wrappers and cloned definition trees.
_require_project_nested_svg_crops(root, svg_path)
_require_project_images(root, svg_path)
@@ -1621,6 +1718,18 @@ def convert_svg_to_slide_shapes(
f'{svg_path.name}: text-metric materialization failed: {exc}'
) from exc
inline_formula_count_before_text_lowering = sum(
1 for elem in root.iter()
if elem.get(INLINE_FORMULA_ATTR) is not None
)
hyperlink_count_before_text_lowering = sum(
1 for elem in root.iter()
if (
elem.tag == f'{{{SVG_NS}}}a'
or elem.get(SHAPE_HYPERLINK_ATTR) is not None
)
)
# 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,
@@ -1646,6 +1755,39 @@ def convert_svg_to_slide_shapes(
if verbose:
print(f' Lowered positional <tspan> using {text_flow} text flow')
inline_formula_count_after_text_lowering = sum(
1 for elem in root.iter()
if elem.get(INLINE_FORMULA_ATTR) is not None
)
hyperlink_count_after_text_lowering = sum(
1 for elem in root.iter()
if (
elem.tag == f'{{{SVG_NS}}}a'
or elem.get(SHAPE_HYPERLINK_ATTR) is not None
)
)
if (
inline_formula_count_after_text_lowering
!= inline_formula_count_before_text_lowering
):
raise SvgNativeConversionError(
f'{svg_path.name}: positional text lowering changed inline formula '
f'marker count from {inline_formula_count_before_text_lowering} '
f'to {inline_formula_count_after_text_lowering}'
)
if hyperlink_count_after_text_lowering != hyperlink_count_before_text_lowering:
raise SvgNativeConversionError(
f'{svg_path.name}: positional text lowering changed hyperlink '
f'count from {hyperlink_count_before_text_lowering} '
f'to {hyperlink_count_after_text_lowering}'
)
_require_inline_formula_markers(root, svg_path)
_require_project_hyperlinks(
root,
svg_path,
slide_count=slide_count,
)
_require_project_text_properties(root, svg_path)
try:
text_font_sizes = resolve_project_font_sizes(root)
@@ -1673,6 +1815,7 @@ def convert_svg_to_slide_shapes(
reserved_shape_ids=frozenset(source_shape_id_map.values()),
source_shape_id_map=source_shape_id_map,
slide_num=slide_num,
slide_count=slide_count,
viewport_width=viewport_width,
viewport_height=viewport_height,
svg_dir=Path(svg_path).parent,
@@ -24,6 +24,7 @@ from pptx_shapes import (
validate_ooxml_xfrm,
)
from pptx_effects import EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR
from hyperlink_contract import svg_hyperlink_href
from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE
from resource_paths import (
resolve_external_image_reference,
@@ -36,6 +37,12 @@ from .context import (
ConvertContext,
ShapeResult,
)
from .hyperlinks import (
HYPERLINK_ACTION_KEY,
HYPERLINK_RID_KEY,
hyperlink_click_xml,
hyperlink_run_metadata,
)
from .theme_colors import color_node_xml
from .theme_fonts import theme_font_tokens
from .text_properties import (
@@ -1957,6 +1964,8 @@ _TEXT_BULLET_MARKERS = {
_TEXT_BULLET_RE = re.compile(
r'^(?P<prefix>\s*)(?P<marker>[·•●▪■◆◇◦‣])(?P<space>\s*)'
)
_INLINE_FORMULA_ATTR = 'data-pptx-inline-formula'
_INLINE_FORMULA_KEY = '_inline_formula_latex'
def _normalize_text_run_whitespace(
@@ -2172,6 +2181,12 @@ def _extract_text_bullet(
runs: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""Convert a leading text bullet marker into paragraph metadata."""
first_nonspace = _first_nonspace_run(runs)
if first_nonspace and (
first_nonspace.get(_INLINE_FORMULA_KEY) is not None
or first_nonspace.get(HYPERLINK_RID_KEY) is not None
):
return runs, None
full_text = ''.join(str(run.get('text', '')) for run in runs)
match = _TEXT_BULLET_RE.match(full_text)
if not match:
@@ -2385,33 +2400,68 @@ def _collect_tspan_runs(
ctx: ConvertContext,
inherited_xml_space: str = 'default',
) -> list[dict[str, Any]]:
"""Recursively turn a tspan subtree into runs, propagating styling through nested tspans.
"""Recursively turn one inline SVG subtree into DrawingML text runs."""
return _collect_inline_runs(
tspan,
inherited_attrs,
ctx,
inherited_xml_space,
)
Order: tspan.text (each nested child tspan's runs → that child's tail under THIS tspan's attrs).
"""
def _collect_inline_runs(
container: ET.Element,
inherited_attrs: dict[str, Any],
ctx: ConvertContext,
inherited_xml_space: str = 'default',
inherited_hyperlink: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""Collect nested ``tspan``/``a`` content with style and link inheritance."""
runs: list[dict[str, Any]] = []
own_attrs = _override_run_attrs(inherited_attrs, tspan, ctx)
own_xml_space = resolve_project_xml_space(tspan, inherited_xml_space)
own_attrs = _override_run_attrs(inherited_attrs, container, ctx)
own_xml_space = resolve_project_xml_space(container, inherited_xml_space)
container_tag = container.tag.replace(f'{{{SVG_NS}}}', '')
own_hyperlink = inherited_hyperlink
if container_tag == 'a':
own_hyperlink = hyperlink_run_metadata(
ctx,
svg_hyperlink_href(container),
)
if tspan.text:
runs.append({
if container.text:
run = {
**own_attrs,
'text': tspan.text,
'text': container.text,
'_xml_space': own_xml_space,
})
}
if own_hyperlink is not None:
run.update(own_hyperlink)
inline_formula = container.get(_INLINE_FORMULA_ATTR)
if inline_formula is not None:
run[_INLINE_FORMULA_KEY] = inline_formula
runs.append(run)
for child in tspan:
for child in container:
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if child_tag == 'tspan':
if child_tag in {'tspan', 'a'}:
runs.extend(
_collect_tspan_runs(child, own_attrs, ctx, own_xml_space)
_collect_inline_runs(
child,
own_attrs,
ctx,
own_xml_space,
own_hyperlink,
)
)
if child.tail:
runs.append({
tail_run = {
**own_attrs,
'text': child.tail,
'_xml_space': own_xml_space,
})
}
if own_hyperlink is not None:
tail_run.update(own_hyperlink)
runs.append(tail_run)
return runs
@@ -2439,10 +2489,13 @@ def _build_text_runs(
for child in elem:
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if child_tag == 'tspan':
runs.extend(
_collect_tspan_runs(child, parent_attrs, ctx, xml_space)
)
if child_tag in {'tspan', 'a'}:
runs.extend(_collect_inline_runs(
child,
parent_attrs,
ctx,
xml_space,
))
if child.tail:
runs.append({
**parent_attrs,
@@ -2540,6 +2593,7 @@ def _build_run_properties_xml(
default_fonts: dict[str, str],
ctx: ConvertContext | None = None,
effect_xml: str = '',
fixed_font_family: str | None = None,
) -> str:
"""Build the final ``a:rPr`` used to compare and emit one text run."""
text = str(run['text'])
@@ -2569,14 +2623,22 @@ def _build_run_properties_xml(
spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px)
fonts = parse_font_family(ff) if ff else default_fonts
run_fonts = theme_font_tokens(
fonts,
ctx.theme_font_spec if ctx is not None else None,
) or resolve_text_run_fonts(text, fonts)
lang = detect_text_lang(
run_fonts = (
{
'latin': fixed_font_family,
'ea': fixed_font_family,
'cs': fixed_font_family,
}
if fixed_font_family is not None
else theme_font_tokens(
fonts,
ctx.theme_font_spec if ctx is not None else None,
) or resolve_text_run_fonts(text, fonts)
)
lang = str(run.get('_language_override') or detect_text_lang(
text,
ctx.primary_language if ctx is not None else None,
)
))
rtl_xml = (
'\n<a:rtl val="1"/>'
if text_has_rtl_characters(text)
@@ -2585,6 +2647,17 @@ def _build_run_properties_xml(
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
outline_xml = _build_text_outline_xml(run, ctx)
relationship_id = run.get(HYPERLINK_RID_KEY)
hyperlink_xml = (
hyperlink_click_xml(
str(relationship_id),
str(run.get(HYPERLINK_ACTION_KEY))
if run.get(HYPERLINK_ACTION_KEY) is not None
else None,
)
if relationship_id is not None
else ''
)
return f'''<a:rPr lang="{lang}" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr}{spc_attr} dirty="0">
{outline_xml}
@@ -2592,7 +2665,8 @@ def _build_run_properties_xml(
{effect_xml}
<a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
<a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>{rtl_xml}
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
{hyperlink_xml}{rtl_xml}
</a:rPr>'''
@@ -2612,8 +2686,18 @@ def _coalesce_text_runs(
text = str(run.get('text', ''))
if not text:
continue
if run.get(_INLINE_FORMULA_KEY) is not None:
merged.append({**run, 'text': text})
previous_properties = None
continue
properties = _build_run_properties_xml(run, default_fonts, ctx)
if merged and properties == previous_properties:
if (
merged
and merged[-1].get(_INLINE_FORMULA_KEY) is None
and merged[-1].get(HYPERLINK_RID_KEY) == run.get(HYPERLINK_RID_KEY)
and merged[-1].get(HYPERLINK_ACTION_KEY) == run.get(HYPERLINK_ACTION_KEY)
and properties == previous_properties
):
candidate = {
**merged[-1],
'text': str(merged[-1].get('text', '')) + text,
@@ -2652,6 +2736,37 @@ def _build_run_xml(
if run.get('_line_break'):
return '<a:br/>'
text = str(run['text'])
inline_formula = run.get(_INLINE_FORMULA_KEY)
if inline_formula is not None:
fill_raw = str(run.get('fill_raw') or f"#{run.get('fill', '000000')}")
fill_color, fill_alpha = parse_svg_color(fill_raw)
if fill_color is None or fill_alpha <= 0:
raise ValueError(
'inline formula text requires one visible solid fill color'
)
math_run = {
**run,
'font_family': 'Cambria Math',
'font_weight': '400',
'font_style': 'normal',
'text_decoration': 'none',
'letter_spacing': 0.0,
'stroke_raw': '',
'stroke_opacity': None,
'_language_override': (
ctx.primary_language
if ctx is not None and ctx.primary_language is not None
else 'en-US'
),
}
properties_xml = _build_run_properties_xml(
math_run,
default_fonts,
ctx,
fixed_font_family='Cambria Math',
)
from ..native_objects.inline_formula import build_inline_formula_xml
return build_inline_formula_xml(str(inline_formula), properties_xml)
properties_xml = _build_run_properties_xml(
run,
default_fonts,
@@ -2802,11 +2917,15 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
if prev and not prev_text.endswith(' ') \
and not next_text.startswith(' ') \
and not boundary_is_cjk:
prev.append({
joining_space = {
**prev[-1],
'text': ' ',
'letter_spacing': 0.0,
})
}
joining_space.pop(_INLINE_FORMULA_KEY, None)
joining_space.pop(HYPERLINK_RID_KEY, None)
joining_space.pop(HYPERLINK_ACTION_KEY, None)
prev.append(joining_space)
prev.extend(line_runs)
else:
paragraph_runs.append(line_runs)
@@ -3036,6 +3155,11 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
(run for run in line if not run.get('_line_break')),
None,
)
effective_line_spacing = (
''
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
else ln_spc_xml
)
p_pr_xml = _paragraph_pr_xml(
algn=algn,
font_size=(
@@ -3043,7 +3167,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
if first_text_run is not None
else font_size
),
body_xml=f'{ln_spc_xml}{spc_bef_xml}',
body_xml=f'{effective_line_spacing}{spc_bef_xml}',
bullet=bullet,
ctx=ctx,
rtl=text_uses_rtl(
@@ -3128,7 +3252,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
'anchor="t" anchorCtr="0">\n<a:spAutoFit/>\n</a:bodyPr>'
)
return ShapeResult(xml=f'''<p:sp>
shape_xml = f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
<p:cNvSpPr txBox="1"/><p:nvPr/>
@@ -3146,7 +3270,14 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
<a:lstStyle/>
{paragraphs_xml}
</p:txBody>
</p:sp>''', bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
</p:sp>'''
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs):
from ..native_objects.inline_formula import wrap_inline_formula_shape
shape_xml = wrap_inline_formula_shape(shape_xml)
return ShapeResult(
xml=shape_xml,
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
)
# ---------------------------------------------------------------------------
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
PPT Master - DrawingML Hyperlink Lowering
Register native PowerPoint hyperlink relationships and attach click actions to
text runs or clickable leaf shapes produced by the SVG converter.
Usage:
from .hyperlinks import apply_shape_hyperlink, hyperlink_run_metadata
Examples:
metadata = hyperlink_run_metadata(ctx, "https://example.com")
linked = apply_shape_hyperlink(result, ctx, "#slide-2")
Dependencies:
PPT Master hyperlink contract and DrawingML conversion context.
"""
from __future__ import annotations
import re
from hyperlink_contract import (
HYPERLINK_REL_TYPE,
SLIDE_JUMP_ACTION,
SLIDE_REL_TYPE,
parse_hyperlink_target,
)
from .context import ConvertContext, ShapeResult
HYPERLINK_RID_KEY = "_hyperlink_rid"
HYPERLINK_ACTION_KEY = "_hyperlink_action"
_CNVPR_RE = re.compile(
r"<p:cNvPr\b[^<>]*(?:/>|>.*?</p:cNvPr>)",
re.DOTALL,
)
_GROUP_NONVISUAL_RE = re.compile(
r"<p:nvGrpSpPr>.*?</p:nvGrpSpPr>",
re.DOTALL,
)
def register_hyperlink(
ctx: ConvertContext,
raw_target: str,
) -> tuple[str, str | None]:
"""Register one slide relationship and return ``(rId, action)``."""
target = parse_hyperlink_target(
raw_target,
slide_count=ctx.slide_count,
)
if target.kind == "slide":
relationship_type = SLIDE_REL_TYPE
relationship_target = f"slide{target.slide_number}.xml"
target_mode = None
action = SLIDE_JUMP_ACTION
else:
relationship_type = HYPERLINK_REL_TYPE
relationship_target = target.raw
target_mode = "External"
action = None
for relationship in ctx.rel_entries:
if (
relationship.get("type") == relationship_type
and relationship.get("target") == relationship_target
and relationship.get("target_mode") == target_mode
):
return relationship["id"], action
relationship_id = ctx.next_rel_id()
relationship = {
"id": relationship_id,
"type": relationship_type,
"target": relationship_target,
}
if target_mode is not None:
relationship["target_mode"] = target_mode
ctx.rel_entries.append(relationship)
return relationship_id, action
def hyperlink_click_xml(
relationship_id: str,
action: str | None = None,
) -> str:
"""Build one DrawingML click hyperlink child."""
action_attr = f' action="{action}"' if action else ""
return f'<a:hlinkClick r:id="{relationship_id}"{action_attr}/>'
def hyperlink_run_metadata(
ctx: ConvertContext,
raw_target: str,
) -> dict[str, str]:
"""Return the internal run fields for one SVG inline anchor."""
relationship_id, action = register_hyperlink(ctx, raw_target)
metadata = {HYPERLINK_RID_KEY: relationship_id}
if action is not None:
metadata[HYPERLINK_ACTION_KEY] = action
return metadata
def apply_shape_hyperlink(
result: ShapeResult,
ctx: ConvertContext,
raw_target: str,
) -> ShapeResult:
"""Attach one click hyperlink to every clickable leaf object.
DrawingML group containers are selection/animation structures, not a
reliable click-action carrier across PowerPoint APIs. A multi-object SVG
anchor therefore shares one relationship across each leaf ``p:cNvPr``.
Authors use an explicit background shape when the whole rectangular button
area, including gaps between descendants, must be clickable.
"""
relationship_id, action = register_hyperlink(ctx, raw_target)
hlink_xml = hyperlink_click_xml(relationship_id, action)
group_ranges = [
(match.start(), match.end())
for match in _GROUP_NONVISUAL_RE.finditer(result.xml)
]
replacements: list[tuple[int, int, str]] = []
for match in _CNVPR_RE.finditer(result.xml):
if any(start <= match.start() < end for start, end in group_ranges):
continue
original = match.group(0)
if "<a:hlinkClick" in original:
raise ValueError(
"hyperlink carrier already contains a click hyperlink"
)
if original.endswith("/>"):
replacement = f"{original[:-2]}>{hlink_xml}</p:cNvPr>"
else:
replacement = original.replace(
"</p:cNvPr>",
f"{hlink_xml}</p:cNvPr>",
1,
)
replacements.append((match.start(), match.end(), replacement))
if not replacements:
raise ValueError(
"hyperlink carrier did not produce a clickable DrawingML leaf"
)
linked_xml = result.xml
for start, end, replacement in reversed(replacements):
linked_xml = linked_xml[:start] + replacement + linked_xml[end:]
return ShapeResult(xml=linked_xml, bounds_emu=result.bounds_emu)
__all__ = [
"HYPERLINK_ACTION_KEY",
"HYPERLINK_RID_KEY",
"apply_shape_hyperlink",
"hyperlink_click_xml",
"hyperlink_run_metadata",
"register_hyperlink",
]
@@ -1026,7 +1026,7 @@ def _contains_native_marker(elem: ET.Element) -> bool:
from ..native_objects.marker_attributes import native_replacement_kind
return any(
native_replacement_kind(descendant) in {'table', 'chart'}
native_replacement_kind(descendant) in {'table', 'chart', 'formula'}
for descendant in _iter_visual_transform_tree(elem)
)
@@ -1177,7 +1177,7 @@ def _transform_semantic_error(
if all(name in {'translate', 'scale'} for name in names):
return None
return (
f'{label} native table/chart marker transforms support only '
f'{label} native replacement marker transforms support only '
'translate and scale'
)
if _contains_thick_circle(elem, thick_circle_ids):
@@ -1,4 +1,4 @@
"""Native PowerPoint table/chart converters for explicit SVG metadata markers."""
"""Native PowerPoint object converters for explicit SVG metadata markers."""
from __future__ import annotations
@@ -8,12 +8,13 @@ from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import _xml_escape
from .chart_data import _chart_data
from .chart_data import _chart_data, _chart_plot_area_layout
from .chart_style import (
_axis_titles,
_chart_companion_entries,
_chart_companion_text_xml,
_chart_text_sizes,
_chart_title_is_bounded,
_classic_chart_style,
_native_chart_chrome_errors,
_native_chart_chrome_warnings,
@@ -33,6 +34,11 @@ from .fallback_hash import (
snapshot_native_fallback_freshness,
stamp_native_fallback_baseline,
)
from .formula import FormulaSpec, build_native_formula, validate_formula_payload
from .inline_formula import (
INLINE_FORMULA_ATTR,
inline_formula_marker_errors,
)
from .marker_common import (
CHART_CONTENT_TYPE,
CHARTEX_CONTENT_TYPE,
@@ -73,6 +79,7 @@ from .workbook import (
__all__ = [
"convert_native_object",
"INLINE_FORMULA_ATTR",
"NativeMarkerAttributeError",
"native_fallback_kind",
"native_import_source",
@@ -82,6 +89,7 @@ __all__ = [
"native_replacement_kind",
"native_replacement_status",
"native_marker_transform",
"inline_formula_marker_errors",
"snapshot_native_fallback_freshness",
"stamp_native_fallback_baseline",
"validate_native_object_marker",
@@ -152,6 +160,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
chart_data=chart_data,
inherited_styles=ctx.inherited_styles,
primary_language=ctx.primary_language,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
)
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
if chart_data["kind"] == "xy":
@@ -183,7 +192,10 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
chart_style=chart_style,
note_font_size=text_sizes["note"],
title_font_size=text_sizes["title"],
include_title=chart_data["kind"] == "chartex",
include_title=(
chart_data["kind"] == "chartex"
or _chart_title_is_bounded(payload)
),
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
xml = chart_frame_xml + companion_xml
@@ -197,7 +209,7 @@ def _validate_native_object_marker_payload(
ctx: ConvertContext | None = None,
ancestors: tuple[ET.Element, ...] = (),
require_fresh_fallback: bool = False,
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
) -> tuple[str, dict[str, Any], list[list[Any]] | FormulaSpec | None]:
try:
kind = native_replacement_kind(elem)
except NativeMarkerAttributeError as exc:
@@ -210,9 +222,9 @@ def _validate_native_object_marker_payload(
if kind not in _NATIVE_KINDS:
raise RuntimeError(f"Unsupported data-pptx-replace-with value: {kind}")
if _local_tag(elem) != "g":
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
raise RuntimeError("Native PPTX replacement markers must be <g> elements")
native_marker_transform(elem.get("transform"))
if require_fresh_fallback:
if require_fresh_fallback and kind in {"chart", "table"}:
require_fresh_native_fallback(elem, use_runtime_snapshot=True)
try:
@@ -221,26 +233,36 @@ def _validate_native_object_marker_payload(
raise RuntimeError(str(exc)) from exc
bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors)
off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx)
table_rows = None
validated_data: list[list[Any]] | FormulaSpec | None = None
if kind == "table":
table_rows, col_count, _merge_layout = _validate_table_payload(payload)
validated_data = table_rows
if ext_cx < col_count or ext_cy < len(table_rows):
raise RuntimeError(
"Native PPTX table bounds must provide at least one EMU per row and column"
)
else:
elif kind == "chart":
chart_data = _chart_data(payload)
_chart_plot_area_layout(
chart_data,
(off_x, off_y, ext_cx, ext_cy),
)
_validate_chart_companion_boxes(
payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
include_title=chart_data["kind"] == "chartex",
include_title=(
chart_data["kind"] == "chartex"
or _chart_title_is_bounded(payload)
),
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
if validate_chrome and native_import_source(elem) != "pptx":
chrome_errors = _native_chart_chrome_errors(elem, payload)
if chrome_errors:
raise RuntimeError("; ".join(chrome_errors))
return kind, payload, table_rows
else:
validated_data = validate_formula_payload(payload, ctx=ctx)
return kind, payload, validated_data
def validate_native_object_marker(
@@ -248,7 +270,7 @@ def validate_native_object_marker(
*,
ancestors: tuple[ET.Element, ...] = (),
) -> None:
"""Validate a chart/table replacement marker without mutating the package."""
"""Validate a native replacement marker without mutating the package."""
_validate_native_object_marker_payload(elem, ancestors=ancestors)
@@ -258,8 +280,8 @@ def validate_native_object_marker_with_warnings(
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Validate a chart/table replacement marker and return non-fatal warnings."""
kind, payload, table_rows = _validate_native_object_marker_payload(
"""Validate a native replacement marker and return non-fatal warnings."""
kind, payload, validated_data = _validate_native_object_marker_payload(
elem,
ancestors=ancestors,
)
@@ -268,10 +290,10 @@ def validate_native_object_marker_with_warnings(
elem,
document_root=document_root,
)
if kind else []
if kind in {"chart", "table"} else []
)
if kind == "table" and table_rows is not None:
warnings.extend(_native_table_warnings(elem, table_rows))
if kind == "table" and isinstance(validated_data, list):
warnings.extend(_native_table_warnings(elem, validated_data))
elif kind == "chart":
warnings.extend(_native_chart_chrome_warnings(elem, payload))
return warnings
@@ -283,7 +305,7 @@ def native_object_marker_warnings(
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Return non-fatal warnings for a chart/table replacement marker."""
"""Return non-fatal warnings for a native replacement marker."""
return validate_native_object_marker_with_warnings(
elem,
ancestors=ancestors,
@@ -292,7 +314,7 @@ def native_object_marker_warnings(
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert a marked SVG group to a native PowerPoint table or chart."""
"""Convert a marked SVG group to its native PowerPoint object."""
try:
kind = native_replacement_kind(elem)
except NativeMarkerAttributeError as exc:
@@ -300,12 +322,18 @@ def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult
if not kind:
return None
kind, payload, _ = _validate_native_object_marker_payload(
kind, payload, validated_data = _validate_native_object_marker_payload(
elem,
validate_chrome=False,
ctx=ctx,
require_fresh_fallback=True,
)
if kind == "formula":
formula_spec = (
validated_data if isinstance(validated_data, FormulaSpec) else None
)
return build_native_formula(elem, ctx, payload, formula_spec)
marker_id = elem.get("id") or "<unnamed>"
for warning in native_fallback_contract_warnings(
elem,
@@ -12,6 +12,7 @@ from .marker_common import (
_first_present,
_hex_or_none,
_number,
_powerpoint_emu,
_powerpoint_line_width_emu,
)
@@ -235,6 +236,8 @@ _AXIS_ROLE_DEFAULTS = {
def _chart_axes(
payload: dict[str, Any],
allowed_roles: set[str],
*,
bar_orientation: bool = False,
) -> dict[str, dict[str, Any]]:
"""Normalize the narrow classic-chart axis contract."""
raw_axes = payload.get("axes")
@@ -264,6 +267,10 @@ def _chart_axes(
f"Native PPTX chart axes.{role} contains unsupported field(s): {fields}"
)
default_kind, default_position = _AXIS_ROLE_DEFAULTS[role]
if bar_orientation and role == "category":
default_position = "left"
elif bar_orientation and role == "value":
default_position = "bottom"
kind = _compact_key(raw_config.get("kind") or default_kind)
if kind not in {"date", "text", "value"}:
raise RuntimeError(
@@ -291,11 +298,16 @@ def _chart_axes(
raise RuntimeError(
f"Native PPTX chart axes.{role}.position must be bottom, left, right, or top"
)
allowed_positions = (
{"bottom", "top"}
if role in {"category", "secondary_category", "x"}
else {"left", "right"}
)
if bar_orientation and role == "category":
allowed_positions = {"left", "right"}
elif bar_orientation and role == "value":
allowed_positions = {"bottom", "top"}
else:
allowed_positions = (
{"bottom", "top"}
if role in {"category", "secondary_category", "x"}
else {"left", "right"}
)
if position not in allowed_positions:
choices = ", ".join(sorted(allowed_positions))
raise RuntimeError(
@@ -372,6 +384,101 @@ def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool:
return axes.get("category", {}).get("kind") == "date"
def _chart_plot_area(payload: dict[str, Any]) -> dict[str, float] | None:
"""Normalize an optional absolute slide-local plot-area box."""
raw = payload.get("plot_area")
if raw is None:
return None
if not isinstance(raw, dict):
raise RuntimeError("Native PPTX chart plot_area must be an object")
box_keys = {"x", "y", "width", "height"}
unknown_keys = set(raw) - box_keys
if unknown_keys:
fields = ", ".join(sorted(unknown_keys))
raise RuntimeError(
f"Native PPTX chart plot_area contains unsupported field(s): {fields}"
)
missing_keys = box_keys - set(raw)
if missing_keys:
fields = ", ".join(sorted(missing_keys))
raise RuntimeError(
"Native PPTX chart plot_area requires x/y/width/height together; "
f"missing: {fields}"
)
plot_area = {
key: _number(raw[key], f"chart plot_area.{key}")
for key in ("x", "y", "width", "height")
}
if plot_area["width"] <= 0 or plot_area["height"] <= 0:
raise RuntimeError("Native PPTX chart plot_area width/height must be positive")
return plot_area
def _chart_plot_area_layout(
chart_data: dict[str, Any],
chart_bounds: tuple[int, int, int, int],
) -> tuple[float, float, float, float] | None:
"""Resolve an absolute plot-area box to chart-relative manual-layout factors."""
plot_area = chart_data.get("plot_area")
if plot_area is None:
return None
chart_x, chart_y, chart_width, chart_height = chart_bounds
plot_x = _powerpoint_emu(plot_area["x"], "chart plot_area.x")
plot_y = _powerpoint_emu(plot_area["y"], "chart plot_area.y")
plot_width = _powerpoint_emu(
plot_area["width"],
"chart plot_area.width",
positive=True,
)
plot_height = _powerpoint_emu(
plot_area["height"],
"chart plot_area.height",
positive=True,
)
if (
plot_x < chart_x
or plot_y < chart_y
or plot_x + plot_width > chart_x + chart_width
or plot_y + plot_height > chart_y + chart_height
):
raise RuntimeError(
"Native PPTX chart plot_area must be fully contained within the chart frame"
)
return (
(plot_x - chart_x) / chart_width,
(plot_y - chart_y) / chart_height,
plot_width / chart_width,
plot_height / chart_height,
)
def _doughnut_hole_size(payload: dict[str, Any], chart_type: str) -> int | None:
"""Normalize the closed doughnut-hole percentage contract."""
raw = payload.get("hole_size")
if chart_type != "doughnut":
if raw is not None:
raise RuntimeError(
"Native PPTX chart hole_size is supported for doughnut charts only"
)
return None
if raw is None:
return 75
if not isinstance(raw, (int, float)) or isinstance(raw, bool):
raise RuntimeError("Native PPTX doughnut hole_size must be a numeric integer")
value = _chart_number(raw)
if not float(value).is_integer():
raise RuntimeError("Native PPTX doughnut hole_size must be an integer")
hole_size = int(value)
if not 10 <= hole_size <= 90:
raise RuntimeError(
"Native PPTX doughnut hole_size must be between 10 and 90"
)
return hole_size
def _chart_kind(payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
raw_type = payload.get("type") or payload.get("chart_type") or "column"
key = _compact_key(raw_type)
@@ -655,8 +762,12 @@ def _category_chart_data(
alias_grouping: str | None,
alias_style: str | None,
) -> dict[str, Any]:
axes = _chart_axes(payload, {"category", "value"})
if axes and chart_type in {"bar", "doughnut", "of_pie", "pie"}:
axes = _chart_axes(
payload,
{"category", "value"},
bar_orientation=chart_type == "bar",
)
if axes and chart_type in {"doughnut", "of_pie", "pie"}:
raise RuntimeError(
f"Native PPTX {chart_type} chart axes are outside current support"
)
@@ -716,6 +827,7 @@ def _category_chart_data(
"categories": categories,
"grouping": grouping,
"of_pie_type": of_pie_type,
"hole_size": _doughnut_hole_size(payload, chart_type),
"line_style": line_style,
"radar_marker_style": radar_marker_style,
"radar_style": radar_style,
@@ -1275,6 +1387,15 @@ def _xy_chart_data(
def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _chart_kind(payload)
if payload.get("hole_size") is not None and chart_type != "doughnut":
raise RuntimeError(
"Native PPTX chart hole_size is supported for doughnut charts only"
)
plot_area = _chart_plot_area(payload)
if plot_area is not None and chart_type in _CHARTEX_CHART_TYPES:
raise RuntimeError(
"Native PPTX chart plot_area is supported for classic charts only"
)
if (
chart_type not in _CATEGORY_CHART_TYPES | {"combo", "stock"} | _XY_CHART_TYPES
and _data_labels_config(payload) is not None
@@ -1283,14 +1404,27 @@ def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
f"Native PPTX {chart_type} chart data labels are outside current support"
)
if chart_type == "combo":
return _combo_chart_data(payload)
chart_data = _combo_chart_data(payload)
chart_data["plot_area"] = plot_area
return chart_data
if chart_type in _CHARTEX_CHART_TYPES:
return _chartex_chart_data(payload, chart_type)
if chart_type == "stock":
return _stock_chart_data(payload)
chart_data = _stock_chart_data(payload)
chart_data["plot_area"] = plot_area
return chart_data
if chart_type in _XY_CHART_TYPES:
return _xy_chart_data(payload, chart_type, alias_style)
return _category_chart_data(payload, chart_type, alias_grouping, alias_style)
chart_data = _xy_chart_data(payload, chart_type, alias_style)
chart_data["plot_area"] = plot_area
return chart_data
chart_data = _category_chart_data(
payload,
chart_type,
alias_grouping,
alias_style,
)
chart_data["plot_area"] = plot_area
return chart_data
def validate_chart_payload(payload: dict[str, Any]) -> None:
@@ -340,6 +340,12 @@ def _chart_text_entry(value: Any) -> tuple[str, dict[str, Any]] | None:
return str(value).strip(), {}
def _chart_title_is_bounded(payload: dict[str, Any]) -> bool:
"""Return whether the classic title requests an explicit companion box."""
title = payload.get("title")
return isinstance(title, dict) and _chart_companion_box(title) is not None
def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
raw = _first_present(item.get("font_size"), item.get("fontSize"))
if raw is None:
@@ -777,6 +783,16 @@ def _validate_chart_companion_boxes(
include_subtitle_as_caption: bool,
) -> None:
"""Validate companion boxes without allocating shapes or relationships."""
title_bounded = _chart_title_is_bounded(payload)
if (
title_bounded
and not include_subtitle_as_caption
and _chart_text_entry(payload.get("subtitle")) is not None
):
raise RuntimeError(
"Native PPTX classic chart bounded title does not support subtitle; "
"use a separately bounded caption"
)
_, chart_off_y, _, chart_ext_cy = chart_bounds
below_index = 0
for item in _chart_companion_entries(
@@ -808,6 +824,8 @@ def _chart_companion_text_xml(
include_title: bool,
include_subtitle_as_caption: bool,
) -> str:
if _chart_title_is_bounded(payload):
include_title = True
entries = _chart_companion_entries(
payload,
include_title=include_title,
@@ -15,6 +15,7 @@ from .chart_data import (
_DEFAULT_CHART_COLORS,
_category_axis_is_date,
_chart_list,
_chart_plot_area_layout,
_data_label_position,
_data_label_point_items,
_data_labels_config,
@@ -30,6 +31,7 @@ from .chart_style import (
_chart_text_entry_font_size,
_chart_text_entry,
_chart_text_sizes,
_chart_title_is_bounded,
_chart_tx_pr_xml,
_classic_chart_style,
_font_face_xml,
@@ -495,11 +497,30 @@ def _chart_title_xml(
return (
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
f"{''.join(paragraphs)}"
"</c:rich></c:tx><c:layout/></c:title>"
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
'<c:autoTitleDeleted val="0"/>'
)
def _plot_area_layout_xml(
chart_data: dict[str, Any],
chart_bounds: tuple[int, int, int, int],
) -> str:
layout = _chart_plot_area_layout(chart_data, chart_bounds)
if layout is None:
return "<c:layout/>"
x, y, width, height = layout
return (
"<c:layout><c:manualLayout>"
'<c:layoutTarget val="inner"/>'
'<c:xMode val="edge"/><c:yMode val="edge"/>'
'<c:wMode val="factor"/><c:hMode val="factor"/>'
f'<c:x val="{x:.12g}"/><c:y val="{y:.12g}"/>'
f'<c:w val="{width:.12g}"/><c:h val="{height:.12g}"/>'
"</c:manualLayout></c:layout>"
)
def _chart_legend_xml(
payload: dict[str, Any],
*,
@@ -1181,7 +1202,8 @@ def _chart_plot_xml(
return (
'<c:doughnutChart><c:varyColors val="1"/>'
f"{ser_xml}"
'<c:firstSliceAng val="0"/><c:holeSize val="75"/>'
'<c:firstSliceAng val="0"/>'
f'<c:holeSize val="{chart_data["hole_size"]}"/>'
"</c:doughnutChart>"
)
if chart_type == "of_pie":
@@ -1399,6 +1421,7 @@ def _chart_xml(
*,
chart_rels_id: str,
chart_data: dict[str, Any],
chart_bounds: tuple[int, int, int, int],
inherited_styles: dict[str, str] | None = None,
primary_language: str | None = None,
) -> bytes:
@@ -1420,11 +1443,12 @@ def _chart_xml(
axis_titles=axis_titles,
chart_style=chart_style,
)
bounded_title = _chart_title_is_bounded(payload)
title_xml = _chart_title_xml(
payload.get("title"),
None if bounded_title else payload.get("title"),
font_size=text_sizes["title"],
color=chart_style.get("text_color"),
subtitle=payload.get("subtitle"),
subtitle=None if bounded_title else payload.get("subtitle"),
subtitle_font_size=text_sizes["subtitle"],
font_face=chart_style.get("font_face"),
primary_language=primary_language,
@@ -1451,7 +1475,7 @@ def _chart_xml(
<c:lang val="{_xml_escape(chart_language)}"/>
<c:chart>
{title_xml}
<c:plotArea><c:layout/>{plot_xml}{_chart_area_sp_pr_xml(chart_style.get("plot_fill"))}</c:plotArea>
<c:plotArea>{_plot_area_layout_xml(chart_data, chart_bounds)}{plot_xml}{_chart_area_sp_pr_xml(chart_style.get("plot_fill"))}</c:plotArea>
{legend_xml}
<c:plotVisOnly val="1"/>
<c:dispBlanksAs val="gap"/>
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
PPT Master - Native Formula Shape Builder
Build editable PowerPoint formula shapes from explicit LaTeX markers.
Usage:
Imported by the SVG-to-PPTX native-object converter.
Examples:
from svg_to_pptx.native_objects.formula import build_native_formula
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import _xml_escape, font_px_to_hpt
from .formula_compiler import FormulaCompileError, compile_latex_to_omml
from .marker_common import _bounds, _hex_or_none
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
_MATH_RUN = f"{{{MATH_NS}}}r"
_MATH_RUN_PROPERTIES = f"{{{MATH_NS}}}rPr"
_DML_RUN_PROPERTIES = f"{{{DML_NS}}}rPr"
_LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
_ALIGNMENTS = {
"center": ("ctr", "center"),
"left": ("l", "left"),
"right": ("r", "right"),
}
for _prefix, _uri in (("a", DML_NS), ("m", MATH_NS)):
try:
ET.register_namespace(_prefix, _uri)
except (ValueError, AttributeError):
pass
@dataclass(frozen=True)
class FormulaSpec:
"""Validated formula source plus the PowerPoint text style to apply."""
omml: str
font_size_hpt: int
color: str
paragraph_alignment: str
math_alignment: str
language: str
def _positive_number(value: Any, field_name: str) -> float:
if isinstance(value, bool):
raise RuntimeError(f"Native PPTX formula {field_name} must be numeric")
try:
result = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise RuntimeError(
f"Native PPTX formula {field_name} must be numeric"
) from exc
if not math.isfinite(result) or result <= 0:
raise RuntimeError(
f"Native PPTX formula {field_name} must be a positive finite number"
)
return result
def _formula_language(payload: dict[str, Any], ctx: ConvertContext | None) -> str:
raw = payload.get("language")
if raw is None and ctx is not None:
raw = ctx.primary_language
language = str(raw or "en-US").strip()
if len(language) > 35 or _LANGUAGE_RE.fullmatch(language) is None:
raise RuntimeError(
"Native PPTX formula language must be a compact BCP-47 tag"
)
return language
def validate_formula_payload(
payload: dict[str, Any],
*,
ctx: ConvertContext | None = None,
) -> FormulaSpec:
"""Validate and compile one block-formula payload."""
latex = payload.get("latex")
if not isinstance(latex, str) or not latex.strip():
raise RuntimeError("Native PPTX formula metadata requires non-empty `latex`")
display = str(payload.get("display") or "block").strip().lower()
if display != "block":
raise RuntimeError(
"Native PPTX formula currently supports independent block formulas only"
)
raw_font_size = payload.get("font_size", 28)
font_size_px = _positive_number(raw_font_size, "font_size")
if font_size_px > 400:
raise RuntimeError("Native PPTX formula font_size must not exceed 400 px")
try:
font_size_hpt = font_px_to_hpt(font_size_px)
except ValueError as exc:
raise RuntimeError(
"Native PPTX formula font_size is outside the PowerPoint range"
) from exc
raw_color = payload.get("color", "#000000")
color = _hex_or_none(raw_color)
if color is None:
raise RuntimeError(
"Native PPTX formula color must be a visible CSS color or HEX value"
)
alignment = str(payload.get("align") or "center").strip().lower()
if alignment not in _ALIGNMENTS:
choices = ", ".join(sorted(_ALIGNMENTS))
raise RuntimeError(
f"Native PPTX formula align must be one of: {choices}"
)
paragraph_alignment, math_alignment = _ALIGNMENTS[alignment]
try:
omml = compile_latex_to_omml(latex)
except FormulaCompileError as exc:
raise RuntimeError(f"Native PPTX formula LaTeX is unsupported: {exc}") from exc
return FormulaSpec(
omml=omml,
font_size_hpt=font_size_hpt,
color=color,
paragraph_alignment=paragraph_alignment,
math_alignment=math_alignment,
language=_formula_language(payload, ctx),
)
def _styled_omml(spec: FormulaSpec) -> str:
"""Apply DrawingML run styling and alignment to validated OMML."""
root = ET.fromstring(spec.omml)
if root.tag == f"{{{MATH_NS}}}oMathPara":
para_properties = root.find(f"{{{MATH_NS}}}oMathParaPr")
if para_properties is None:
para_properties = ET.Element(f"{{{MATH_NS}}}oMathParaPr")
root.insert(0, para_properties)
justification = para_properties.find(f"{{{MATH_NS}}}jc")
if justification is None:
justification = ET.SubElement(
para_properties,
f"{{{MATH_NS}}}jc",
)
justification.set(f"{{{MATH_NS}}}val", spec.math_alignment)
for run in root.iter(_MATH_RUN):
for existing in list(run):
if existing.tag == _DML_RUN_PROPERTIES:
run.remove(existing)
run_properties = ET.Element(
_DML_RUN_PROPERTIES,
{
"lang": spec.language,
"sz": str(spec.font_size_hpt),
"dirty": "0",
},
)
solid_fill = ET.SubElement(run_properties, f"{{{DML_NS}}}solidFill")
ET.SubElement(
solid_fill,
f"{{{DML_NS}}}srgbClr",
{"val": spec.color},
)
for tag in ("latin", "ea", "cs"):
ET.SubElement(
run_properties,
f"{{{DML_NS}}}{tag}",
{"typeface": "Cambria Math"},
)
insert_at = 0
if len(run) and run[0].tag == _MATH_RUN_PROPERTIES:
insert_at = 1
run.insert(insert_at, run_properties)
return ET.tostring(root, encoding="unicode", short_empty_elements=True)
def build_native_formula(
elem: ET.Element,
ctx: ConvertContext,
payload: dict[str, Any],
spec: FormulaSpec | None = None,
) -> ShapeResult:
"""Build one Choice-only editable formula with no image fallback."""
formula_spec = spec or validate_formula_payload(payload, ctx=ctx)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
shape_id = ctx.next_id()
name = _xml_escape(
str(payload.get("name") or elem.get("id") or f"Formula {shape_id}")
)
omml = _styled_omml(formula_spec)
xml = f'''<mc:AlternateContent xmlns:mc="{MC_NS}">
<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">
<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvSpPr txBox="1"><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:noFill/>
<a:ln><a:noFill/></a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr lIns="0" tIns="0" rIns="0" bIns="0" wrap="none" anchor="ctr">
<a:normAutofit/>
</a:bodyPr>
<a:lstStyle/>
<a:p><a:pPr algn="{formula_spec.paragraph_alignment}"/>
<a14:m>{omml}</a14:m>
</a:p>
</p:txBody>
</p:sp>
</mc:Choice>
</mc:AlternateContent>'''
return ShapeResult(
xml=xml,
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
)
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
PPT Master - Native Inline Formula Contract
Validate SVG inline-formula markers and build editable PowerPoint math runs.
Usage:
Imported by the SVG quality checker and SVG-to-PPTX text converter.
Examples:
<tspan data-pptx-inline-formula="x_i^2">xᵢ²</tspan>
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
from copy import deepcopy
from xml.etree import ElementTree as ET
from ..drawingml.utils import parse_inline_style, parse_svg_color
from .formula_compiler import (
FormulaCompileError,
compile_latex_to_inline_omml,
)
SVG_NS = "http://www.w3.org/2000/svg"
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
OFFICE_REL_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
INLINE_FORMULA_ATTR = "data-pptx-inline-formula"
_SVG_TEXT = f"{{{SVG_NS}}}text"
_SVG_TSPAN = f"{{{SVG_NS}}}tspan"
_SVG_A = f"{{{SVG_NS}}}a"
_MATH_RUN = f"{{{MATH_NS}}}r"
_MATH_RUN_PROPERTIES = f"{{{MATH_NS}}}rPr"
_DML_RUN_PROPERTIES = f"{{{DML_NS}}}rPr"
_POSITION_ATTRIBUTES = ("x", "y", "dx", "dy")
_PARAGRAPH_ATTRIBUTES = (
"data-paragraph-line-height",
"data-paragraph-line-break",
"data-paragraph-soft-break",
"data-paragraph-space-before",
)
_NON_OUTPUT_ANCESTORS = frozenset({
"clipPath",
"desc",
"defs",
"filter",
"linearGradient",
"marker",
"mask",
"metadata",
"pattern",
"radialGradient",
"style",
"symbol",
"title",
})
for _prefix, _uri in (("a", DML_NS), ("m", MATH_NS)):
try:
ET.register_namespace(_prefix, _uri)
except (ValueError, AttributeError):
pass
def _marker_label(elem: ET.Element) -> str:
elem_id = (elem.get("id") or "").strip()
return f"<tspan id={elem_id!r}>" if elem_id else "<tspan>"
def inline_formula_marker_errors(root: ET.Element) -> list[str]:
"""Return strict authoring errors for every inline-formula marker."""
errors: list[str] = []
parent_map = {child: parent for parent in root.iter() for child in parent}
markers = [
elem for elem in root.iter()
if elem.get(INLINE_FORMULA_ATTR) is not None
]
for marker in markers:
label = _marker_label(marker)
if marker.tag != _SVG_TSPAN:
tag = marker.tag.rsplit("}", 1)[-1]
errors.append(
f"{INLINE_FORMULA_ATTR} is only valid on <tspan>, found <{tag}>"
)
continue
if marker.get("data-pptx-replace-with") is not None:
errors.append(
f"{label} cannot also declare data-pptx-replace-with"
)
source = marker.get(INLINE_FORMULA_ATTR) or ""
if not source.strip():
errors.append(f"{label} requires non-empty {INLINE_FORMULA_ATTR}")
else:
try:
compile_latex_to_inline_omml(source)
except FormulaCompileError as exc:
errors.append(f"{label} has unsupported inline LaTeX: {exc}")
if list(marker):
errors.append(
f"{label} must contain preview text directly and cannot nest elements"
)
if not (marker.text or "").strip():
errors.append(f"{label} requires non-empty visible SVG preview text")
elif marker.text != marker.text.strip():
errors.append(
f"{label} preview cannot contain leading or trailing whitespace; "
"place spacing in the surrounding text"
)
positioned = [name for name in _POSITION_ATTRIBUTES if marker.get(name) is not None]
if positioned:
errors.append(
f"{label} is an inline run and cannot set " + ", ".join(positioned)
)
paragraph_attrs = [
name for name in _PARAGRAPH_ATTRIBUTES
if marker.get(name) is not None
]
if paragraph_attrs:
errors.append(
f"{label} cannot own paragraph layout metadata: "
+ ", ".join(paragraph_attrs)
)
effective_fill: str | None = None
style_owner: ET.Element | None = marker
while style_owner is not None:
inline_style = parse_inline_style(style_owner.get("style"))
effective_fill = inline_style.get("fill")
if effective_fill is None:
effective_fill = style_owner.get("fill")
if effective_fill is not None:
break
style_owner = parent_map.get(style_owner)
color, alpha = parse_svg_color(effective_fill or "#000000")
if color is None or alpha <= 0:
errors.append(
f"{label} requires one visible solid fill color inherited "
"from itself or its text ancestors"
)
parent = parent_map.get(marker)
text_owner: ET.Element | None = None
nested_marker = False
inside_block_formula = False
inside_native_replacement = False
inside_preserved_text = False
inside_placeholder = False
inside_skipped_transport = False
invalid_inline_container: str | None = None
non_output_ancestor: str | None = None
fixed_structure_layer: str | None = None
while parent is not None:
parent_tag = parent.tag.rsplit("}", 1)[-1]
if text_owner is None and parent.tag == _SVG_TEXT:
text_owner = parent
elif (
text_owner is None
and parent.tag not in {_SVG_A, _SVG_TSPAN}
and invalid_inline_container is None
):
invalid_inline_container = parent_tag
if parent_tag in _NON_OUTPUT_ANCESTORS:
non_output_ancestor = parent_tag
if parent.get(INLINE_FORMULA_ATTR) is not None:
nested_marker = True
replacement = (
parent.get("data-pptx-replace-with") or ""
).strip().lower()
if replacement:
inside_native_replacement = True
inside_block_formula = (
inside_block_formula or replacement == "formula"
)
if (parent.get("data-pptx-part") or "").strip() in {
"geometry-detail",
"geometry-preview",
}:
inside_skipped_transport = True
if parent.get("data-pptx-placeholder") is not None:
inside_placeholder = True
layer = (parent.get("data-pptx-layer") or "").strip().lower()
if layer in {"master", "layout"}:
fixed_structure_layer = layer
if any(
child.tag.rsplit("}", 1)[-1] == "metadata"
and child.get("data-pptx-part") == "txbody"
for child in parent
):
inside_preserved_text = True
parent = parent_map.get(parent)
if text_owner is None:
errors.append(f"{label} must be inside an SVG <text> element")
elif invalid_inline_container is not None:
errors.append(
f"{label} can only be nested through <tspan>/<a> elements before "
f"its owning <text>, found <{invalid_inline_container}>"
)
if non_output_ancestor is not None:
errors.append(
f"{label} cannot be placed inside non-output "
f"<{non_output_ancestor}> content"
)
if nested_marker:
errors.append(f"{label} cannot be nested inside another inline formula marker")
if inside_block_formula:
errors.append(
f"{label} cannot be placed inside a block formula preview"
)
elif inside_native_replacement:
errors.append(
f"{label} cannot be placed inside a native replacement subtree"
)
if inside_skipped_transport:
errors.append(
f"{label} cannot be placed inside non-output geometry transport"
)
if inside_preserved_text:
errors.append(
f"{label} cannot be placed inside an imported preserved txBody group"
)
if inside_placeholder:
errors.append(
f"{label} cannot be used inside a structured Layout placeholder"
)
if fixed_structure_layer is not None:
errors.append(
f"{label} cannot be used on the {fixed_structure_layer} layer; "
"inline formulas are slide-local text"
)
return errors
def _apply_run_properties(omml: str, run_properties_xml: str) -> str:
"""Replace DrawingML properties on every Office Math leaf run."""
try:
root = ET.fromstring(omml)
wrapper = ET.fromstring(
f'<root xmlns:a="{DML_NS}" xmlns:r="{OFFICE_REL_NS}">'
f"{run_properties_xml}</root>"
)
except (ET.ParseError, RecursionError) as exc:
raise RuntimeError(f"Invalid inline formula XML: {exc}") from exc
if len(wrapper) != 1:
raise RuntimeError("Inline formula styling must contain one a:rPr root")
run_properties = wrapper[0]
if root.tag != f"{{{MATH_NS}}}oMath":
raise RuntimeError("Inline formula compiler must return one m:oMath root")
if run_properties.tag != _DML_RUN_PROPERTIES:
raise RuntimeError("Inline formula styling must use one a:rPr root")
for run in root.iter(_MATH_RUN):
for existing in list(run):
if existing.tag == _DML_RUN_PROPERTIES:
run.remove(existing)
insert_at = 1 if len(run) and run[0].tag == _MATH_RUN_PROPERTIES else 0
run.insert(insert_at, deepcopy(run_properties))
return ET.tostring(root, encoding="unicode", short_empty_elements=True)
def build_inline_formula_xml(latex: str, run_properties_xml: str) -> str:
"""Build one ``a14:m`` inline math zone for insertion inside ``a:p``."""
try:
omml = compile_latex_to_inline_omml(latex)
except FormulaCompileError as exc:
raise RuntimeError(f"Unsupported inline formula LaTeX: {exc}") from exc
styled = _apply_run_properties(omml, run_properties_xml)
return f"<a14:m>{styled}</a14:m>"
def wrap_inline_formula_shape(shape_xml: str) -> str:
"""Wrap a text shape containing ``a14:m`` in its required MCE Choice."""
return (
f'<mc:AlternateContent xmlns:mc="{MC_NS}">'
f'<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">'
f"{shape_xml}"
"</mc:Choice>"
"</mc:AlternateContent>"
)
__all__ = [
"INLINE_FORMULA_ATTR",
"build_inline_formula_xml",
"inline_formula_marker_errors",
"wrap_inline_formula_shape",
]
@@ -1,4 +1,4 @@
"""Canonical and compatible attribute access for chart/table replacements."""
"""Canonical and compatible attribute access for native replacements."""
from __future__ import annotations
@@ -49,7 +49,7 @@ def _resolved_alias(
def native_replacement_kind(elem: ET.Element) -> str:
"""Return the requested chart/table replacement kind, or an empty string."""
"""Return the requested native replacement kind, or an empty string."""
return _resolved_alias(elem, REPLACE_WITH_ATTR, LEGACY_REPLACE_WITH_ATTR) or ""
@@ -46,7 +46,7 @@ CHARTEX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml"
CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
_NATIVE_KINDS = {"table", "chart"}
_NATIVE_KINDS = {"table", "chart", "formula"}
_POWERPOINT_COORD_MIN = -(2**31)
_POWERPOINT_COORD_MAX = 2**31 - 1
_POWERPOINT_LINE_WIDTH_MAX = 20116800
@@ -268,11 +268,11 @@ def native_marker_transform(transform: str | None) -> tuple[float, float, float,
operations = parse_transform_operations(raw)
except ValueError as exc:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
"Native PPTX replacement markers support translate/scale transforms only"
) from exc
if any(name not in {"translate", "scale"} for name, _args in operations):
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
"Native PPTX replacement markers support translate/scale transforms only"
)
a, b, c, d, e, f = parse_transform_matrix(raw)
@@ -281,7 +281,7 @@ def native_marker_transform(transform: str | None) -> tuple[float, float, float,
raise RuntimeError("Native PPTX marker transform exceeds finite coordinates")
if b != 0.0 or c != 0.0:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
"Native PPTX replacement markers support translate/scale transforms only"
)
return e, f, a, d
@@ -631,7 +631,7 @@ def _resolved_bounds(
) -> tuple[float, float, float, float, bool]:
"""Resolve object bounds in SVG px plus whether all bounds were explicit."""
if ctx.use_transform_matrix:
raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
raise RuntimeError("Native PPTX replacement markers support translate/scale only")
raw_x = payload.get("x", elem.get("data-pptx-x"))
raw_y = payload.get("y", elem.get("data-pptx-y"))
@@ -1,4 +1,4 @@
"""Validate chart/table replacement fallback and release-route attributes."""
"""Validate native replacement fallback and release-route attributes."""
from __future__ import annotations
@@ -24,7 +24,7 @@ from .marker_attributes import (
VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"})
ROUTE_STATUSES = frozenset({"reconstruction-only"})
REPLACEMENT_KINDS = frozenset({"chart", "table"})
REPLACEMENT_KINDS = frozenset({"chart", "formula", "table"})
# Closed importer outputs from chart_to_svg, chartex_to_svg, and tbl_to_svg.
# This includes codes forwarded through their dynamic ``status`` parameters.
REPLACEMENT_STATUS_CODES = frozenset({
@@ -126,7 +126,9 @@ def native_marker_status_errors(elem: ET.Element) -> list[str]:
and canonical_kind_raw == canonical_kind_raw.strip()
and canonical_kind_raw != canonical_kind_raw.lower()
):
errors.append(f"{REPLACE_WITH_ATTR} must use lowercase chart or table")
errors.append(
f"{REPLACE_WITH_ATTR} must use lowercase chart, formula, or table"
)
if visual_raw is not None and visual_raw != visual_raw.strip():
errors.append(f"{FALLBACK_KIND_ATTR} must not contain surrounding whitespace")
if legacy_visual_raw is not None and legacy_visual_raw != legacy_visual_raw.strip():
@@ -19,10 +19,10 @@ import zipfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path, PureWindowsPath
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape
from xml.sax.saxutils import escape, quoteattr
from pptx import Presentation
from pptx.util import Emu
@@ -55,6 +55,10 @@ from pptx_opc_validation import (
verify_internal_relationships,
)
from language_tags import normalize_language_tag
from hyperlink_contract import (
HYPERLINK_REL_TYPE,
trigger_shape_hyperlink_errors,
)
from ..animation_config import (
MorphPair,
@@ -144,8 +148,19 @@ 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"
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
for _prefix, _uri in (("p", PML_NS), ("a", DML_NS), ("r", REL_NS), ("p14", P14_NS)):
for _prefix, _uri in (
("p", PML_NS),
("a", DML_NS),
("r", REL_NS),
("p14", P14_NS),
("mc", MC_NS),
("a14", A14_NS),
("m", MATH_NS),
):
try:
ET.register_namespace(_prefix, _uri)
except (ValueError, AttributeError):
@@ -240,10 +255,15 @@ def _find_relationship_id(
rels_path: Path,
rel_type: str,
target: str,
target_mode: str | None = None,
) -> str | None:
"""Find an existing relationship by type and target."""
for rel_id, attrs in _read_relationships(rels_path).items():
if attrs.get("Type") == rel_type and attrs.get("Target") == target:
if (
attrs.get("Type") == rel_type
and attrs.get("Target") == target
and attrs.get("TargetMode") == target_mode
):
return rel_id
return None
@@ -423,6 +443,7 @@ _TOP_LEVEL_SHAPE_TAGS = {
f"{{{PML_NS}}}pic",
f"{{{PML_NS}}}cxnSp",
f"{{{PML_NS}}}graphicFrame",
f"{{{MC_NS}}}AlternateContent",
}
_FLAT_SYSTEM_PLACEHOLDER_TYPES = frozenset({"dt", "ftr", "sldNum"})
_REL_ATTRS = {
@@ -641,15 +662,20 @@ def _shape_relationships_supported(
elem: ET.Element,
rels: dict[str, dict[str, str]],
) -> bool:
"""Only image relationships are safe to copy into a slide master here."""
"""Return whether every shape relation can move to Master/Layout parts."""
for rel_id in _relationship_ids_in_shape(elem):
attrs = rels.get(rel_id)
if not attrs:
return False
if attrs.get("TargetMode"):
return False
if attrs.get("Type") != IMAGE_REL_TYPE:
return False
rel_type = attrs.get("Type")
target_mode = attrs.get("TargetMode")
if rel_type == IMAGE_REL_TYPE and not target_mode:
continue
if rel_type == HYPERLINK_REL_TYPE and target_mode == "External":
continue
if rel_type == SLIDE_REL_TYPE and not target_mode:
continue
return False
return True
@@ -679,7 +705,8 @@ def _canonical_shape_xml(
attrs = rels.get(value, {})
node.set(
attr_name,
f"{attrs.get('Type', '')}|{attrs.get('Target', '')}",
f"{attrs.get('Type', '')}|{attrs.get('Target', '')}|"
f"{attrs.get('TargetMode', '')}",
)
return ET.tostring(clone, encoding="utf-8")
@@ -688,11 +715,37 @@ def _ensure_relationship(
rels_path: Path,
rel_type: str,
target: str,
target_mode: str | None = None,
) -> str:
existing = _find_relationship_id(rels_path, rel_type, target)
existing = _find_relationship_id(
rels_path,
rel_type,
target,
target_mode,
)
if existing:
return existing
return _append_relationship(rels_path, rel_type, target)
return _append_relationship(
rels_path,
rel_type,
target,
target_mode=target_mode,
)
def _part_name_for_relationships_path(rels_path: Path) -> str:
"""Recover one ``ppt/...`` package part from its relationship sidecar."""
if rels_path.parent.name != "_rels" or not rels_path.name.endswith(".rels"):
raise RuntimeError(f"Invalid PPTX relationship path: {rels_path}")
part_path = rels_path.parent.parent / rels_path.name.removesuffix(".rels")
parts = part_path.parts
try:
ppt_index = len(parts) - 1 - tuple(reversed(parts)).index("ppt")
except ValueError as exc:
raise RuntimeError(
f"Relationship path is not under a ppt package: {rels_path}"
) from exc
return PurePosixPath(*parts[ppt_index:]).as_posix()
def _copy_shape_relationships_to_part(
@@ -702,6 +755,7 @@ def _copy_shape_relationships_to_part(
) -> ET.Element:
"""Clone a shape and retarget supported relationship ids to another part."""
clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
target_part = _part_name_for_relationships_path(target_rels_path)
for node in clone.iter():
for attr_name, value in list(node.attrib.items()):
if attr_name not in _REL_ATTRS:
@@ -709,10 +763,22 @@ def _copy_shape_relationships_to_part(
rel = slide_rels.get(value)
if not rel:
raise RuntimeError(f"Missing slide relationship for {value}")
target_mode = rel.get("TargetMode")
relationship_target = rel["Target"]
if target_mode != "External":
resolved_target = _resolve_package_target(
"ppt/slides/source.xml",
relationship_target,
)
relationship_target = posixpath.relpath(
resolved_target,
posixpath.dirname(target_part),
)
new_rid = _ensure_relationship(
target_rels_path,
rel["Type"],
rel["Target"],
relationship_target,
target_mode,
)
node.set(attr_name, new_rid)
return clone
@@ -2132,7 +2198,7 @@ def _move_template_static_shape(
if not _shape_relationships_supported(shape, state.rels):
raise TemplateStructureError(
f"{state.spec.svg_path.name}: structure element {item.element_id!r} "
"uses a non-image or external relationship"
"uses a relationship that cannot move to a template part"
)
prototype_state = states[0]
@@ -3764,6 +3830,8 @@ def _append_relationship(
rels_path: Path,
rel_type: str,
target: str,
*,
target_mode: str | None = None,
) -> str:
"""Append a relationship entry with the next available rId."""
with open(rels_path, 'r', encoding='utf-8') as f:
@@ -3771,9 +3839,14 @@ def _append_relationship(
rid_numbers = [int(match) for match in re.findall(r'Id="rId(\d+)"', rels_content)]
next_rid = f'rId{max(rid_numbers, default=0) + 1}'
mode_attr = (
f" TargetMode={quoteattr(target_mode)}"
if target_mode is not None
else ""
)
rel_xml = (
f' <Relationship Id="{next_rid}" '
f'Type="{rel_type}" Target="{target}"/>'
f" <Relationship Id={quoteattr(next_rid)} "
f"Type={quoteattr(rel_type)} Target={quoteattr(target)}{mode_attr}/>"
)
rels_content = rels_content.replace(
'</Relationships>', rel_xml + '\n</Relationships>',
@@ -5329,6 +5402,13 @@ def create_pptx_with_native_svg(
explicit_animation_groups = frozenset(
explicit_group_ids | trigger_group_ids
)
if trigger_group_ids:
hyperlink_trigger_errors = trigger_shape_hyperlink_errors(
ET.parse(svg_path).getroot(),
trigger_group_ids,
)
if hyperlink_trigger_errors:
raise ValueError('; '.join(hyperlink_trigger_errors))
converter_group_overrides = (
explicit_animation_groups
| frozenset(
@@ -5347,7 +5427,9 @@ def create_pptx_with_native_svg(
content_type_overrides,
) = (
convert_svg_to_slide_shapes(
svg_path, slide_num=slide_num, verbose=verbose,
svg_path, slide_num=slide_num,
slide_count=public_slide_count,
verbose=verbose,
text_flow=text_flow,
image_optimize=image_optimize,
image_max_dimension=image_max_dimension,
@@ -5532,9 +5614,16 @@ def create_pptx_with_native_svg(
extra_rels = ''
for rel in rel_entries:
target_mode = rel.get('target_mode')
mode_attr = (
f" TargetMode={quoteattr(target_mode)}"
if target_mode is not None
else ''
)
extra_rels += (
f'\n <Relationship Id="{rel["id"]}" '
f'Type="{rel["type"]}" Target="{rel["target"]}"/>'
f"\n <Relationship Id={quoteattr(rel['id'])} "
f"Type={quoteattr(rel['type'])} "
f"Target={quoteattr(rel['target'])}{mode_attr}/>"
)
rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -50,6 +50,7 @@ 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"
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
)
@@ -79,6 +80,7 @@ _TOP_LEVEL_VISIBLE_TAGS = frozenset({
f"{{{PML_NS}}}graphicFrame",
f"{{{PML_NS}}}grpSp",
f"{{{PML_NS}}}cxnSp",
f"{{{MC_NS}}}AlternateContent",
})
@@ -8,10 +8,12 @@ from __future__ import annotations
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from hyperlink_contract import SLIDE_JUMP_ACTION
from pptx_animations import (
object_animation_fingerprint,
validate_pptx_animation_package,
@@ -30,7 +32,17 @@ from .chart_fill import (
)
from .clone import _make_part_allocator, deep_clone_slide_private_parts
from .notes import _find_notes_master_target, _slide_rels_with_notes
from .ooxml import NS, REL_NS, SLIDE_REL_TYPE, _parse_slide_refs, _qn, _xml_bytes
from .ooxml import (
NS,
REL_NS,
SLIDE_REL_TYPE,
SlideRef,
_normalize_part,
_parse_slide_refs,
_qn,
_rels_name_for_part,
_xml_bytes,
)
from .package import (
_add_notes_override,
_add_slide_override,
@@ -40,6 +52,7 @@ from .package import (
_max_slide_id,
_max_slide_part_number,
_prune_unreferenced_parts,
_relative_target,
)
from .table_fill import _apply_table_edits_to_slide
from .text_fill import _apply_replacements_to_slide
@@ -51,6 +64,201 @@ from .transitions import (
)
@dataclass(frozen=True)
class _PlannedSlideClone:
"""One output slide resolved before any source relationship is rewritten."""
offset: int
item: dict[str, Any]
source_slide: int
source_ref: SlideRef
slide_number: int
part_name: str
rels_name: str
presentation_rid: str
def _build_clone_roster(
plan_slides: list[Any],
slide_refs: dict[int, SlideRef],
*,
next_slide_number: int,
next_rel_number: int,
) -> list[_PlannedSlideClone]:
"""Resolve every planned output slide before cloning begins."""
roster: list[_PlannedSlideClone] = []
for offset, raw_item in enumerate(plan_slides):
if not isinstance(raw_item, dict):
raise RuntimeError(f"Plan slide {offset + 1} must be an object")
source_slide = int(raw_item.get("source_slide", 0))
if source_slide not in slide_refs:
raise RuntimeError(f"Plan references a missing source slide: {source_slide}")
slide_number = next_slide_number + offset
roster.append(
_PlannedSlideClone(
offset=offset,
item=raw_item,
source_slide=source_slide,
source_ref=slide_refs[source_slide],
slide_number=slide_number,
part_name=f"ppt/slides/slide{slide_number}.xml",
rels_name=f"ppt/slides/_rels/slide{slide_number}.xml.rels",
presentation_rid=f"rId{next_rel_number + offset}",
)
)
return roster
_SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
)
_SLIDE_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
)
def _slide_jump_relationship_ids(part_root: ET.Element) -> set[str]:
"""Return relationship ids used by actual same-deck click actions."""
relationship_attr = _qn(NS["r"], "id")
return {
relationship_id
for link in part_root.iter(_qn(NS["a"], "hlinkClick"))
if (link.attrib.get("action") or "").strip() == SLIDE_JUMP_ACTION
if (relationship_id := (link.attrib.get(relationship_attr) or "").strip())
}
def _remap_slide_jump_relationships(
part_root: ET.Element,
relationships_root: ET.Element,
*,
source_owner_part: str,
output_owner_part: str,
owner_label: str,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
self_source_part: str | None = None,
self_output_part: str | None = None,
) -> bool:
"""Point referenced slide-jump relationships at final output slides.
Unreferenced ``/slide`` relationships are left untouched. A cloned slide's
self-link follows that clone; shared layout/master links require one unique
output for the referenced source slide.
"""
relationship_ids = _slide_jump_relationship_ids(part_root)
relationships = {
rel.attrib.get("Id", ""): rel
for rel in relationships_root.findall(_qn(REL_NS, "Relationship"))
}
changed = False
for relationship_id in sorted(relationship_ids):
rel = relationships.get(relationship_id)
if rel is None:
raise RuntimeError(
f"{owner_label} has a slide jump with missing relationship "
f"{relationship_id!r}"
)
if rel.attrib.get("Type") != SLIDE_REL_TYPE:
raise RuntimeError(
f"{owner_label} slide jump {relationship_id!r} does not use "
"a slide relationship"
)
if rel.attrib.get("TargetMode") == "External":
raise RuntimeError(
f"{owner_label} has an external slide relationship"
)
target = rel.attrib.get("Target")
if not target:
raise RuntimeError(
f"{owner_label} has a slide relationship without a target"
)
source_target_part = _normalize_part(target, source_owner_part)
if (
self_source_part is not None
and self_output_part is not None
and source_target_part == self_source_part
):
output_target_part = self_output_part
else:
output_targets = outputs_by_source_part.get(source_target_part, [])
source_target_ref = source_ref_by_part.get(source_target_part)
target_label = (
f"source slide {source_target_ref.index}"
if source_target_ref is not None
else source_target_part
)
if not output_targets:
raise RuntimeError(
f"{owner_label} links to omitted {target_label}; "
"include the target exactly once or remove the link"
)
if len(output_targets) > 1:
raise RuntimeError(
f"{owner_label} links to repeated {target_label}; "
"the output target is ambiguous"
)
output_target_part = output_targets[0]
output_target = _relative_target(output_owner_part, output_target_part)
if rel.attrib.get("Target") != output_target:
rel.set("Target", output_target)
changed = True
return changed
def _remap_reachable_shared_layer_slide_jumps(
entries: dict[str, bytes],
clone_roster: list[_PlannedSlideClone],
*,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
) -> None:
"""Remap links inherited from layouts and masters used by output slides."""
pending: list[str] = []
for clone in clone_roster:
rels_data = entries.get(clone.rels_name)
if not rels_data:
continue
rels_root = ET.fromstring(rels_data)
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("Type") != _SLIDE_LAYOUT_REL_TYPE:
continue
target = rel.attrib.get("Target")
if target:
pending.append(_normalize_part(target, clone.part_name))
visited: set[str] = set()
while pending:
part_name = pending.pop()
if part_name in visited:
continue
visited.add(part_name)
part_data = entries.get(part_name)
rels_name = _rels_name_for_part(part_name)
rels_data = entries.get(rels_name)
if not part_data or not rels_data:
continue
part_root = ET.fromstring(part_data)
rels_root = ET.fromstring(rels_data)
changed = _remap_slide_jump_relationships(
part_root,
rels_root,
source_owner_part=part_name,
output_owner_part=part_name,
owner_label=part_name,
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
)
if changed:
entries[rels_name] = _xml_bytes(rels_root)
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("Type") != _SLIDE_MASTER_REL_TYPE:
continue
target = rel.attrib.get("Target")
if target:
pending.append(_normalize_part(target, part_name))
def apply_plan(
pptx_path: Path,
plan: dict[str, Any],
@@ -90,15 +298,25 @@ def apply_plan(
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))
if source_slide not in slide_refs:
raise RuntimeError(f"Plan references a missing source slide: {source_slide}")
source_ref = slide_refs[source_slide]
new_slide_number = next_slide_number + offset
new_part = f"ppt/slides/slide{new_slide_number}.xml"
new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels"
new_rid = f"rId{next_rel_number + offset}"
clone_roster = _build_clone_roster(
plan_slides,
slide_refs,
next_slide_number=next_slide_number,
next_rel_number=next_rel_number,
)
source_ref_by_part = {ref.part_name: ref for ref in slide_refs.values()}
outputs_by_source_part: dict[str, list[str]] = {}
for clone in clone_roster:
outputs_by_source_part.setdefault(clone.source_ref.part_name, []).append(clone.part_name)
for clone in clone_roster:
item = clone.item
source_slide = clone.source_slide
source_ref = clone.source_ref
new_slide_number = clone.slide_number
new_part = clone.part_name
new_rels = clone.rels_name
new_rid = clone.presentation_rid
source_slide_xml = entries[source_ref.part_name]
source_animation_fingerprint = object_animation_fingerprint(
@@ -143,6 +361,17 @@ def apply_plan(
source_rels = entries.get(source_ref.rels_name)
slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root()
_remap_slide_jump_relationships(
slide_root,
slide_rels_root,
source_owner_part=source_ref.part_name,
output_owner_part=new_part,
owner_label=f"Source slide {source_slide}",
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
self_source_part=source_ref.part_name,
self_output_part=new_part,
)
deep_clone_slide_private_parts(
slide_rels_root,
new_slide_part=new_part,
@@ -203,9 +432,19 @@ def apply_plan(
ET.SubElement(
sld_id_lst,
_qn(NS["p"], "sldId"),
{"id": str(next_slide_id + offset), _qn(NS["r"], "id"): new_rid},
{
"id": str(next_slide_id + clone.offset),
_qn(NS["r"], "id"): new_rid,
},
)
_remap_reachable_shared_layer_slide_jumps(
entries,
clone_roster,
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
)
entries["ppt/presentation.xml"] = _xml_bytes(pres_root)
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root)
_prune_unreferenced_parts(entries, content_root)
@@ -43,7 +43,6 @@ Start with this exact heading order:
| Reading Mode | <text, balanced, presentation, or the active non-PPT equivalent> |
| Content Strategy | <confirmed material-divergence prose or balanced default> |
| Design Style | <resolved design direction> |
| Formula Policy | <mixed, render-all, or text-only> |
| AI Image Acquisition Path | <confirmed path or not applicable> |
| Generation Mode | <continuous or split> |
| Spec Refinement | <enabled or disabled> |
@@ -179,7 +178,7 @@ in `kebab-case` and add one `Native-ready` map: `<key>=yes|no; ...`. Use `yes`
only when editable native output helps. Qualitative relationships/read order
remain unkeyed prose, as do incidental microvisuals.
In §VIII, author every planned or explicitly required resource from the confirmed source boundary. Write one concise, non-empty `Layout pattern` suggestion in ordinary language; optionally cite hierarchical ids from the layout library when they help recall a technique. Set `Crop Policy` to `adaptive` or `no-crop`; set `Acquire Via` to `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`. Preserve unresolved required assets as `Pending` or `Needs-Manual` instead of dropping or reclassifying them.
In §VIII, author every planned or explicitly required resource from the confirmed source boundary. Write one concise, non-empty `Layout pattern` suggestion in ordinary language; optionally cite hierarchical ids from the layout library when they help recall a technique. Set `Crop Policy` to `adaptive` or `no-crop`; set `Acquire Via` to `ai`, `web`, `user`, `placeholder`, or `slice`. Preserve unresolved required assets as `Pending` or `Needs-Manual` instead of dropping or reclassifying them. Native formulas never enter this table or `spec_lock.md images`.
§VIII `Layout pattern` is a per-resource preference. When a page uses several images, repeats one image in multiple views, or combines an image with native overlays, describe the page-level relationship and participating resources in §IX `Layout` / `Images`; do not duplicate an unchanged resource row merely to encode animation sequencing.
@@ -201,6 +200,7 @@ Write one ordered Slide block per page. Slide count and order must equal §I `Pa
- **Title**: <preferred page title>
- **Core message**: <one governing assertion>
- **Content**: <complete intended on-slide content and hierarchy>
- **Mathematical content**: <exact expression as a delimiter-free LaTeX body; omit when none>
## X. Speaker Notes Requirements
@@ -228,7 +228,7 @@ never write an empty or `none` placeholder:
- **Motion suggestion**: <communication job plus desired page-entry or reveal relationship/order>
```
Add `Visualization` / `Images` when a Slide consumes §VII/§VIII or uses a page-local visual model. Name every value-driven geometry, qualitative relationship, cell grid, and child visual here; only independent Chart/Table entries use object keys. Describe qualitative order, linkage, hierarchy, grouping, contrast, overlap, and reading path freely—not as a model name or grammar enum. §IX may choose a custom Chart/Table fallback. Add `Native shape suggestion` only when a preset, stock Connector, or compound silhouette/cutout/intersection/fragment may help; name the semantic result plus candidate family or Boolean operands, never implementation geometry or keys. Executor chooses the primitive, preset, Boolean construction, or necessary freeform. Add `Motion suggestion` whenever transition/reveal advice strengthens communication, regardless of the Custom Animations outcome; state purpose and semantic order/relationship, not registry keys, options, timing, ids, or coverage. The suggestion never activates animation execution by itself, creates content, or binds implementation. Describe required visible image states in `Layout` / `Images` only for an explicit motion requirement or an enabled Custom Animations outcome. Add keyed `Native-ready` only for independent data charts or pure text-grid tables, `Fact IDs` for sourced claims, and `Data class: scenario` for invented demo values. Except on preservation paths, `Cover impact` carries a binding hook and adaptable composition; apply the same split to `Closing impact` only when the deck genuinely resolves. Roster/order/content stay authoritative. §VIII image layout is non-empty free prose with optional library ids; §VII Chart/Table rows are references. Executor owns geometry, hierarchy, treatment, and sparse local garnish.
Add `Mathematical content` whenever a Slide needs a mathematical expression preserved exactly. Store the expression body as valid LaTeX without `$...$`, `$$...$$`, `\(...\)`, or `\[...\]` source delimiters; the field does not classify inline versus structural use. This is content authority for [`native-formula.md`](../references/native-formula.md), not a formula policy, marker, or implementation request; Executor chooses ordinary text, inline native math, or block native math. Add `Visualization` / `Images` when a Slide consumes §VII/§VIII or uses a page-local visual model. Name every value-driven geometry, qualitative relationship, cell grid, and child visual here; only independent Chart/Table entries use object keys. Describe qualitative order, linkage, hierarchy, grouping, contrast, overlap, and reading path freely—not as a model name or grammar enum. §IX may choose a custom Chart/Table fallback. Add `Native shape suggestion` only when a preset, stock Connector, or compound silhouette/cutout/intersection/fragment may help; name the semantic result plus candidate family or Boolean operands, never implementation geometry or keys. Executor chooses the primitive, preset, Boolean construction, or necessary freeform. Add `Motion suggestion` whenever transition/reveal advice strengthens communication, regardless of the Custom Animations outcome; state purpose and semantic order/relationship, not registry keys, options, timing, ids, or coverage. The suggestion never activates animation execution by itself, creates content, or binds implementation. Describe required visible image states in `Layout` / `Images` only for an explicit motion requirement or an enabled Custom Animations outcome. Add keyed `Native-ready` only for independent data charts or pure text-grid tables, `Fact IDs` for sourced claims, and `Data class: scenario` for invented demo values. Except on preservation paths, `Cover impact` carries a binding hook and adaptable composition; apply the same split to `Closing impact` only when the deck genuinely resolves. Roster/order/content stay authoritative. §VIII image layout is non-empty free prose with optional library ids; §VII Chart/Table rows are references. Executor owns geometry, hierarchy, treatment, and sparse local garnish.
For free-design pages, describe `Layout` through relationships, hierarchy, regions, and column spans; do not prescribe element-level `x`, `y`, `width`, or `height` or duplicate the global geometry in §II/§V. Exact coordinates belong to Executor SVG authoring. Preserve literal geometry only when the user explicitly requires it or a mirror/template preservation contract owns it.
@@ -17,7 +17,6 @@
| Reading Mode | [fill] |
| Content Strategy | [fill] |
| Design Style | [fill] |
| Formula Policy | [fill] |
| AI Image Acquisition Path | [fill or not applicable] |
| Generation Mode | [fill] |
| Spec Refinement | [fill] |
@@ -115,6 +114,7 @@
- **Title**: [fill]
- **Core message**: [fill]
- **Content**: [fill]
- **Mathematical content**: [fill exact delimiter-free LaTeX expression body, or omit]
## X. Speaker Notes Requirements
@@ -25,9 +25,9 @@ request does not explicitly select Quick.
| Scope | Contract |
|---|---|
| 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. |
| 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, block/inline native-formula preview, 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. |
| Semantic SVG markers | Minimal rendering-neutral compiler hints used only after existing Layout/Layer/Placeholder/Native metadata has been considered. Chart/table markers preserve their visible SVG fallback; block and inline formula markers carry exact LaTeX and replace only their registered ordinary SVG preview with editable Office Math during PPTX export. |
| `svg_final/` | Mandatory derived, self-contained SVG visual preview in the default pipeline. 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. Quick-generate skips it. |
| 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 compiles only the selected route's explicit structure contract: `flat` keeps represented content Slide-local, while `structured` may place explicitly scoped content in Master/Layout/Slide parts. It MUST NOT infer structure, upgrade `flat`, or invent new visible page content. |
| Native PPTX routes and presentation-behavior stages | Remain outside SVG page-design closure. `template-fill-pptx`, `native-enhance-pptx`, animations, transitions, speaker notes, narration, and package relationships are not required to round-trip through SVG. |
@@ -255,16 +255,16 @@ Then load only the extra role modules triggered by the current plan:
|---|---|
| Stage 1 is confirmed and its template choice installed a selected Brand/Style/Layout/Deck workspace into this project | `references/strategist-template.md` before Stage 2 |
| The confirmed Stage-1 `delivery_context` identifies recorded/self-running/video delivery, or input is an explicit final/literal narration script | `references/video-design.md` before the three Stage-2 whole solutions and page roster |
| The confirmed Stage 2 `image_usage` contains a source other than `none`, the user supplied an explicit non-`none` image constraint, or formula-worthy content activates formula planning | `references/image-layout-spec.md` + `references/image-layout-patterns.md` before production detail, formula resources, or §VIII |
| The confirmed Stage 2 `image_usage` contains a source other than `none`, or the user supplied an explicit non-`none` image constraint | `references/image-layout-spec.md` + `references/image-layout-patterns.md` before production detail or §VIII |
After Stage 1 and template handoff, load `strategist-image.md` plus only the
three `_index.md` files. Author the three whole solution intents before mapping
any component basis. Freeze every referenced mode/style/rendering id from the
indexes, then read once only the deduplicated union of those exact detail files
and finish the three custom behaviors. A novel custom reads no detail file.
Confirmed non-`none` or formulas load the layout references and continue into
resource planning; confirmed `none` without formulas writes no image rows while
retaining recommendation-only rendering candidates. Only an installed
Confirmed non-`none` loads the layout references and continues into resource
planning; confirmed `none` writes no image rows while retaining
recommendation-only rendering candidates. Only an installed
project-local template state loads the template module, and only after Stage 1
is confirmed; a bare template/style name does not.
@@ -312,7 +312,7 @@ Stage-2 summary without fabricating UI results. Silence confirms nothing.
| `confirm_ui/template_options.json` | Candidate schema/language plus supplied exact roots; library entries remain server-owned index data | Stage-1 submission writes user-owned `template_selection.json` with `phase: template`, `status: confirmed` |
| `confirm_ui/recommendations.stage1.json` | Communication contract, `content_divergence`, and canvas only; no template-derived recommendation | The same submission writes `result.json` with `status: stage1-confirmed` |
| `confirm_ui/template_handoff.json` | Only through `--complete-template-selection`, after the Stage-1 selection and free-design closure or successful installation | `status: ready`, bound to the current selection hash; prerequisite for Stage 2 |
| `confirm_ui/recommendations.stage2.json` | `stage: stage2`; complete deck solution plus conditional AI path, formula policy, generation mode, refine-spec, proactive speaker notes, custom animations, and narration audio | `stage: final`, `status: confirmed` |
| `confirm_ui/recommendations.stage2.json` | `stage: stage2`; complete deck solution plus conditional AI path, generation mode, refine-spec, proactive speaker notes, custom animations, and narration audio | `stage: final`, `status: confirmed` |
If the user rejects the current recommendation before confirming it, regenerate by overwriting that same stage file and have the page refresh; do not create revision-suffixed files. This never authorizes one stage file to carry another stage's payload.
@@ -415,7 +415,23 @@ For the normal/default `continuous` path, print no split-mode reminder and proce
**Mandatory — spec-refinement note** (not another Confirm UI stage): after confirmation details and any split-mode line, append one localized 💡 line offering review of the complete Design Spec before the lock; any part may be revised in chat until explicit approval. Default OFF; only explicit chat opt-in or `refine_spec: true` runs [`refine-spec`](stages/refine-spec.md) after Gate 1. Confirm UI records the toggle; chat fallback prints the same line.
**Formula policy**: Final Stage 2 confirms `mixed`, `render-all`, or `text-only`. The image-decision core is already loaded; when rendering is required, load the conditional image-layout references even if `image_usage` is `none`, then follow [`strategist-image.md`](../references/strategist-image.md)'s formula-resource contract. `text-only` creates no formula image rows.
**Native formula content**: Formula handling is not a confirmation field or an
image-acquisition path. Strategist records exact mathematical content as a
delimiter-free LaTeX expression body in the applicable §IX page block without
classifying its implementation. Executor independently chooses ordinary text,
same-paragraph native inline math, or a standalone native block under
[`native-formula.md`](../references/native-formula.md); matrices, multiline
derivations, and other high-structure expressions remain blocks.
No formula manifest, §VIII resource row, or `spec_lock.md images` entry is
created.
**Native hyperlink content**: Hyperlinks are not a confirmation field or a
resource-acquisition path. Strategist records the linked text/object intent and
exact absolute URI or 1-based same-deck slide target in the applicable §IX page
block. Executor chooses an inline or whole-object carrier and authors the
canonical SVG `<a href>` under
[`native-hyperlinks.md`](../references/native-hyperlinks.md). Unknown targets
return upstream; no hyperlink manifest or `spec_lock.md` entry is created.
**Proactive production decisions**: Final Stage 2 records
`proactive_speaker_notes`, `proactive_custom_animations`, and
@@ -437,7 +453,7 @@ neither row and ask one question: disable audio too, or retain its required
notes. Wait, then update both. Before `generate-audio`, create and split notes
when complete per-slide files are absent.
If the user provided images or formula PNGs were rendered, run analysis **before outputting the design spec**. It writes `analysis/image_analysis.csv` — the authoritative regenerated image-fact view in the `analysis/` folder, which MUST be read before authoring §VIII:
If the user provided images, run analysis **before outputting the design spec**. It writes `analysis/image_analysis.csv` — the authoritative regenerated image-fact view in the `analysis/` folder, which MUST be read before authoring §VIII:
```bash
python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
```
@@ -468,15 +484,15 @@ state and §X records its source/verbatim policy. After Gate 2, before Step 5 or
split handoff, write the exact segments once to `notes/total.md`; split them only
in Step 7.1. This is frozen production input, not a third planning artifact.
**✅ Internal checkpoint — Phase deliverables complete**: facts read; confirmation consumed once; final Stage-2 production fields resolved (formula policy, generation mode, refine-spec, proactive choices, and conditional AI path); Design Spec passed Gate 1; enabled refinement approved; lock derived from it; split handling resolved; communication and every §IX `Audience move` validated. Do not print this checklist; auto-proceed.
**✅ Internal checkpoint — Phase deliverables complete**: facts read; confirmation consumed once; final Stage-2 production fields resolved (generation mode, refine-spec, proactive choices, and conditional AI path); mathematical content recorded where applicable; Design Spec passed Gate 1; enabled refinement approved; lock derived from it; split handling resolved; communication and every §IX `Audience move` validated. Do not print this checklist; auto-proceed.
---
### Step 5: Image Acquisition Phase (Conditional)
🚧 **GATE**: Step 4 complete; `<project_path>/design_spec.md` and `<project_path>/spec_lock.md` both exist. If either required artifact is missing, stop before any acquisition or generation and follow [`failure-recovery.md`](governance/failure-recovery.md) §3. Formula rows already have `Acquire Via: formula` and status `Rendered` or `Needs-Manual`.
🚧 **GATE**: Step 4 complete; `<project_path>/design_spec.md` and `<project_path>/spec_lock.md` both exist. If either required artifact is missing, stop before any acquisition or generation and follow [`failure-recovery.md`](governance/failure-recovery.md) §3.
> **Trigger**: At least one row in the resource list has `Acquire Via: ai`, `web`, and/or `slice`, or any row is a pending prepared derivative declared by `Reference: Derived from <canonical bare filename>; treatment=...`. A prepared-user-only plan skips this step only when it has no derivative to materialize; `formula` and `placeholder` rows alone do not trigger it. A permitted but unused image source creates no row and does not trigger acquisition. If §VIII omits a source, asset, or page role that `image_notes` explicitly requires, the Design Spec is incomplete; return to Step 4 Gate 1, repair it from the retained final state, and re-author the affected lock anchors from context. Do not reopen `result.json` during this check.
> **Trigger**: At least one row in the resource list has `Acquire Via: ai`, `web`, and/or `slice`, or any row is a pending prepared derivative declared by `Reference: Derived from <canonical bare filename>; treatment=...`. A prepared-user-only plan skips this step only when it has no derivative to materialize; `placeholder` rows alone do not trigger it. A permitted but unused image source creates no row and does not trigger acquisition. If §VIII omits a source, asset, or page role that `image_notes` explicitly requires, the Design Spec is incomplete; return to Step 4 Gate 1, repair it from the retained final state, and re-author the affected lock anchors from context. Do not reopen `result.json` during this check.
**Failure recovery**: stop/continue behavior for AI/web/slice/image-readiness failures is defined in [`workflows/governance/failure-recovery.md`](governance/failure-recovery.md). This Step keeps the acquisition procedure.
@@ -494,7 +510,7 @@ Then **lazy-load the path-specific reference** for each row that actually needs
| `ai` | `references/image-generator.md` | write `<project_path>/images/image_prompts.json`, then follow `image-generator.md §7 Path Selection` (`image_gen.py --manifest` is **Path A only**) |
| `web` | `references/image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...` (≥2 web rows → `--batch images/image_queries.json`) |
| `slice` | `references/image-generator.md` §4.3 | derived — **after** the parent `ai` sheet row is `Generated`, run `python3 ${SKILL_DIR}/scripts/slice_images.py <project_path>/images/<sheet>.png --grid RxC --names ... --trim --alpha` (see workflow step 2.5) |
| `user` / `formula` / `placeholder` | (skip) | (skip) |
| `user` / `placeholder` | (skip) | (skip) |
A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `web` rows never loads `image-generator.md`. A mixed deck loads both, processes each row through its own path, and writes both `image_prompts.json` and `image_sources.json`.
@@ -576,7 +592,9 @@ Keep the core's shared visual-quality defaults and `svg-effects.md` §6.1 Visual
| Mandatory per-page Structure decision from §IX is `yes` | `executor-structure.md` before any geometry for the first applicable page |
| Actual row × column fact grid | `executor-table.md` |
| Used preset pattern fill, or independent Chart/Table with §IX `<object-key>=yes` | `native-data-interface.md` before that object |
| `spec_lock.md images` / §VIII has an image/formula row, or the template has bundled images | `executor-image.md` + `image-layout-spec.md` + `image-layout-patterns.md` + `svg-image-embedding.md` |
| §IX or current page content contains mathematical notation that may require native math | `native-formula.md` before choosing ordinary text, inline native math, or block native math |
| §IX or current page content requires an external or same-deck click hyperlink | `native-hyperlinks.md` before authoring its inline or whole-object SVG anchor |
| `spec_lock.md images` / §VIII has an image row, or the template has bundled images | `executor-image.md` + `image-layout-spec.md` + `image-layout-patterns.md` + `svg-image-embedding.md` |
| At least one placed image is `Status: Sourced` or its filename has an `image_sources.json` record | `executor-web-image.md` after the image branch |
| §I records recorded/self-running/video delivery, or §X records a final/literal narration script | `video-design.md` before the first SVG; retain it through notes/motion handling |
| All SVG pages and SVG quality gates are complete, and the effective Speaker Notes outcome in `design_spec.md §I` is enabled | `executor-notes.md` before generating speaker notes |
@@ -24,7 +24,7 @@ Global stop/continue rules for all four top-level routes, plus concrete failure
| Execution exposes a missing Strategist-owned role/plan detail | Yes for the affected page | Repair affected Design Spec/lock fragments under [`executor-base.md`](../../references/executor-base.md) §2.1 | Only if confirmed intent changes | Step 4 Gate 1/2 → Step 6 current page |
| Execution context is fresh, resumed, restarted, compacted/summary-only, external, or unknown | Yes until rebuilt | Read complete Design Spec, then lock, once; reload triggered inputs and latest completed SVG when mid-deck | No | Step 6 current page |
| Step 3 rejects a legacy or incomplete template contract | Yes | Stop template consumption; create a new current workspace through Create Template from the original PPTX/reference, then return with its exact workspace root | Only when required source evidence or template choices are unavailable | Create Template → Generate PPTX Step 3 |
| Formula rendering provider failure | No until the Step 7 readiness gate | Exhaust the provider chain; if unresolved, mark only the affected formula rows `Needs-Manual` and continue | Supply the exact target PNG or change formula policy | Step 4 / Step 7 image readiness gate |
| Native formula marker validation or LaTeX compilation failure | Yes for the affected page | Repair the marker metadata or source LaTeX and rerun the SVG checker; there is no formula-image fallback | Clarify the intended equation only when the source is ambiguous | Active SVG authoring step |
| AI image generation failure | No | `auto`: follow A → B → Offline Manual. Explicit `api` / `host-native`: retry only that path, then mark the row `Needs-Manual` without switching automated providers | Only when missing files are required before export | Step 5 / Step 7 image readiness gate |
| Web image search/download failure | No | Adjust query/source per image-searcher rules, then mark `Needs-Manual` if unresolved | Only if the resource is required and no acceptable substitute exists | Step 5 |
| Slice sheet missing | Yes for derived slice rows | Wait for parent sheet; run `slice_images.py`; rerun image analysis | Yes when sheet was manual/offline | Step 5 slice handling / Step 7 image readiness gate |
@@ -16,6 +16,7 @@ This route treats a `.pptx` as the artifact to preserve. It archives the source
|---|---|
| Source file | If already under `projects/`, move it into the enhancement project; otherwise copy it |
| Visible slides | Do not rewrite existing text, shapes, images, charts, tables, masters, or layouts |
| Existing hyperlinks | Preserve hyperlink XML and relationships unchanged |
| Route | Direct PPTX package patching; no SVG conversion |
| Output | A new `.pptx` under `<project>/exports/` |
| Project kind | `native_pptx_enhancement` |
@@ -53,7 +54,7 @@ source.pptx
| `narration.timings` | Enabled | Set narrated slides to auto-advance from page-start lead-in, audio duration, and page-tail padding |
| `narration.transitions` | Enabled | Add page-level transitions for narrated/selected slides |
| `delivery.check` | Enabled | Read-only package/font/media/hidden-slide/file-size and existing-motion audit |
| `media` | Planned | Background music, video, media compression |
| `delivery.compress` | Planned | Reduce embedded media size for oversized packages; the audit above already reports them |
| `presenter` | Planned | Q&A notes, speaker cues, rehearsal artifacts |
| `animation` | Planned | Explicit object-level animation only |
| `visible-stamp` | Planned | Watermark/footer/logo; requires explicit confirmation |
@@ -360,7 +361,9 @@ Patch scope:
| `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.
**Hard rule**: Do not modify existing slide shapes, text bodies, images, chart
data, master/layout parts, hyperlink XML/relationships, or existing non-target
relationships.
Before publishing the candidate, apply validates transitions, timing/object
animation structure, ZIP integrity, unique parts, internal relationships,
@@ -24,7 +24,7 @@ Page count alone never activates or blocks this profile.
| Execution memory | Keep routine page, visual, and resource decisions only in the current active context; losing that context restarts Quick instead of reconstructing a plan from project files |
| Inputs | Any supported Generate input; convert/import sources and run bounded factual research when the input requires them |
| Templates | Directly validate and install at most one exact workspace root per kind supplied for this run; when none are supplied, use free design without catalog selection or Confirm UI |
| Resources | Prepare every project-local image, icon, formula, and required provenance/manifest artifact before its SVG; sound waits for §4 |
| Resources | Prepare every project-local image, icon, and required provenance/manifest artifact before its SVG; author native formula markers and hyperlink anchors directly in the affected SVG; sound waits for §4 |
| Planning artifacts | Do not author a root project `design_spec.md`, `spec_lock.md`, confirmation payloads, or any substitute planning artifact; installed `templates/design_spec.<kind>.<id>.md` files remain template input |
| Traceability | Operational resource manifests, checker reports, postflight, and bounded Python command/outcome audit entries may remain, but they do not record the AI's design reasoning or form a resumable generation history |
| Delivery | Hand-author the resolved SVG roster, run one lockless final checker, skip `finalize_svg.py`, and export the final native PPTX through `--quick-generate` |
@@ -274,9 +274,11 @@ the roster after the whole-roster check:
communication job, a recognizable invariant, and deliberate variation across
applicable page roles; omit it when restraint serves the deck better;
- the resource decisions needed for immediate preparation. Required operational
image/formula manifests may carry filenames, page relationship, status, and
image manifests may carry filenames, page relationship, status, and
generation/crop/focal cues, but do not create a general resource roster or an
icon-to-page assignment;
icon-to-page assignment. Keep each selected formula's source LaTeX in active
context for direct marker authoring; retain each selected hyperlink's exact
absolute URI or 1-based same-deck target; create no formula/link manifest;
- the implementation path for each resource. An explicit user path wins;
otherwise choose the registered automatic/default path without another
interaction.
@@ -326,7 +328,7 @@ because Quick is expected to be faster is not.
| Values, categories, time, weights, or duration determine mark geometry | Value-driven chart |
| Sequence, hierarchy, role, region, or relationship determines page-local topology | Qualitative structure |
| Rows, columns, cells, headers, merges, and alignment form the information model | Cell-grid table |
| Mathematical notation is clearer as typeset math than ordinary text | Rendered formula asset |
| Mathematical notation is clearer as typeset math than ordinary text | PowerPoint-native inline or block math |
| Typography, spacing, and simple geometry already carry the message | Use no additional visual carrier |
This carrier menu does not satisfy or replace the per-page Structure decision in §3.
@@ -367,7 +369,7 @@ Prepare only the resource paths needed by the decided pages:
| Supplied/extracted image | Copy the selected file into `images/`; preserve its factual/provenance context and use the measured file rather than an invented substitute |
| Image-to-PPTX reconstruction asset | In Codex, preserve identity graphics through an exact vector, deterministic redraw, sufficient source asset, or reference-based high-resolution reconstruction; keep data graphics native-and-verified or exact. For scene imagery, build the minimum registered clean-base/midground/subject/foreground group; batch padded-bbox-disjoint objects into one shared plate, then split them with grid slicing or independent nested-SVG bbox crops |
| Bundled/custom icon | Follow the [icon library contract](../../templates/icons/README.md), choose one coherent primary library, sync a useful project pool covering recurring semantics and likely page-local needs without assigning icons to pages, and choose from that prepared pool during SVG authoring |
| Formula | Follow the [`latex_render.py` contract](../../scripts/docs/image.md), write `images/formula_manifest.json`, run the renderer, and keep the rendered PNG under `images/` |
| Formula | Create no resource file. Retain the exact source LaTeX, then choose ordinary text, an inline native marker, or a block native marker under §3; the registered SVG preview is discarded by native export |
| AI image | Follow `image-base.md` + `image-generator.md`; apply only the chosen rendering preset or exact custom bases, never blend unselected catalog identities, and keep `image_prompts.json` plus its human-readable sidecar |
| Web image | Follow `image-base.md` + `image-searcher.md`; keep query/status data and `image_sources.json`, including any required on-slide attribution |
| Illustration slice | Generate or obtain the parent sheet, run `slice_images.py`, and place only the resulting element files |
@@ -389,11 +391,12 @@ After image resources change, run `analyze_images.py` so
Operational manifests and provenance are resource truth, not a hidden design
strategy.
Every required resource must reach a usable terminal state before its page.
`Needs-Manual` blocks Quick even when an unverified file exists. After manual
supply/replacement, validate evidence and reconcile to `Existing`, `Generated`,
`Sourced`, or `Rendered`; never bypass status by file presence or substitute
unrelated material.
Every required file-backed resource must reach a usable terminal state before
its page. `Needs-Manual` blocks Quick even when an unverified file exists. After
manual supply/replacement, validate evidence and reconcile to `Existing`,
`Generated`, or `Sourced`; never bypass status by file presence or substitute
unrelated material. Native formula markers are authored page content, not
file-backed resources or terminal-status rows.
---
@@ -414,7 +417,7 @@ without reading or inventing a nearby preset.
Do not load `executor-base.md`: it owns Default's persisted-plan handoff,
first-page gate, and completion routing. Excluding that file is not a capability
exclusion; Quick loads the shared and conditional execution authorities here
directly. When any image/formula exists, read once before the first affected
directly. When any image exists, read once before the first affected
page and reuse throughout the valid execution context:
[`executor-image.md`](../../references/executor-image.md),
[`image-layout-spec.md`](../../references/image-layout-spec.md),
@@ -429,16 +432,25 @@ omit shape-composition reasoning. Reuse it throughout the valid execution
context; reread only after a known file change or context invalidation.
**Mandatory — per-image-page composition decision**: For every page with one
or more non-formula images, after its content and communication move are
or more images, after its content and communication move are
determined but before choosing geometry, apply
[`executor-image.md`](../../references/executor-image.md)'s active image-integration
decision once. Keep its role, direction source, parent
contour, slot/rhythm system, image/shape action, and any continuity only in
active context; create no artifact, spec, lock, manifest, or extra pass. A
deliberate plain or equal-grid result remains valid when it communicates the
relationship better. Formula-only pages use
[`image-layout-spec.md`](../../references/image-layout-spec.md) without forcing a
multi-image system.
relationship better.
**Mandatory — native formulas**: Quick creates no formula resource or manifest;
retain exact LaTeX in active context, then choose ordinary text, same-paragraph
native inline math, or a standalone native block and author its matching SVG
preview under [`native-formula.md`](../../references/native-formula.md).
**Mandatory — native hyperlinks**: Quick creates no hyperlink resource or
manifest. For every selected link, retain the exact target, choose an inline or
whole-object carrier, and author canonical SVG `<a href>` under
[`native-hyperlinks.md`](../../references/native-hyperlinks.md). Never guess an
unknown destination.
Image to PPTX replaces this open composition decision for its canonical page
frame: preserve the source geometry, restore text natively, preserve
@@ -465,6 +477,8 @@ capability menu, visualization recall, template geometry, or a later check.
| A selected primary Chart/Table `family/key` | [`executor-visualization.md`](../../references/executor-visualization.md), then the matching Chart/Table authority |
| Any actual value-driven geometry, including mini/inset charts and sparklines | [`executor-chart.md`](../../references/executor-chart.md) |
| Any actual row × column fact grid | [`executor-table.md`](../../references/executor-table.md) |
| Any mathematical notation that may require native math | [`native-formula.md`](../../references/native-formula.md) before choosing ordinary text, inline native math, or block native math |
| Any external or same-deck click hyperlink | [`native-hyperlinks.md`](../../references/native-hyperlinks.md) before authoring its inline or whole-object SVG anchor |
| A used preset pattern fill, or one independent Chart/Table object selected as native-ready in active context | [`native-data-interface.md`](../../references/native-data-interface.md) before drawing that object |
| Any data-driven chart geometry | [`verify-charts.md`](../stages/verify-charts.md) after the complete roster and before the one final checker |
@@ -612,7 +626,9 @@ or lock.
- [x] All required source/resource preparation is complete
- [x] One mode and visual style were resolved, and every catalog source actually used was read
- [x] Every page considered the complete visual-carrier menu without a coverage quota
- [x] Every non-formula image-bearing page made its one pre-geometry composition decision
- [x] Every image-bearing page made its one pre-geometry composition decision
- [x] Every selected formula uses the checker-valid ordinary/inline/block form with a matching visible SVG preview and no formula image resource
- [x] Every selected hyperlink uses a checker-valid inline/whole-object anchor and an exact external or same-deck target
- [x] Resolved SVG pages and their project-local references exist
- [x] Every role declared by an installed template spec is locatable in the finished pages, or its non-use is deliberate — checked per installed spec, not from memory
- [x] Every triggered capability-specific preparation and pre-checker verification completed
@@ -38,7 +38,7 @@ Verify the project's planning-session artifacts before doing anything else:
| `<project_path>/spec_lock.md` | Always | Strategist's execution anchors and routing contract; read it completely once in this fresh execution context |
| `<project_path>/design_spec.md` | Always | Complete approved design narrative and Section IX page outline; read it completely once in this fresh execution context |
| `<project_path>/notes/total.md` | Design Spec §X records a supplied final/literal narration script | Frozen verbatim narration input; read it once before SVG authoring and never reconstruct it from the planning chat |
| `<project_path>/images/` plus files whose row status requires existence | `spec_lock images` references any image | `Existing` / `Generated` / `Sourced` / `Rendered` files must exist; an absent `Needs-Manual` file remains allowed until the Step 7 readiness gate |
| `<project_path>/images/` plus files whose row status requires existence | `spec_lock images` references any image | `Existing` / `Generated` / `Sourced` files must exist; an absent `Needs-Manual` file remains allowed until the Step 7 readiness gate |
| `<project_path>/templates/` | `spec_lock page_layouts` references any | Layout / mirror prototypes required by execution |
| Resolver-returned Chart/Table SVG | `spec_lock page_visualizations` or legacy `page_charts` references a live Chart/Table key | Shared page-local SVG selected through the two live catalogs |
@@ -64,7 +64,7 @@ If any required artifact is missing, report it and stop this stage. Do not enter
- Missing `design_spec.md` / `spec_lock.md` → use [`failure-recovery.md`](../governance/failure-recovery.md) §3.
- Missing frozen `notes/total.md` when §X declares a final/literal script → return to Generate Step 4's prepared final narration branch; never rewrite the script from memory.
- Missing `images/`, or a file whose status requires existence → recover by provenance: an `Acquire Via: user` / `Status: Existing` file is a required manual artifact, so use `failure-recovery.md` §2 and wait for the user to restore that exact file; a template-bundled bitmap returns to [`generate-pptx`](../generate-pptx.md) Step 3 to restore the selected workspace; an AI, web, formula, or slice output uses its matching row in `failure-recovery.md` §1 to reacquire, rerender, or derive it. An absent `Needs-Manual` file is not a Step 1 failure.
- Missing `images/`, or a file whose status requires existence → recover by provenance: an `Acquire Via: user` / `Status: Existing` file is a required manual artifact, so use `failure-recovery.md` §2 and wait for the user to restore that exact file; a template-bundled bitmap returns to [`generate-pptx`](../generate-pptx.md) Step 3 to restore the selected workspace; an AI, web, or slice output uses its matching row in `failure-recovery.md` §1 to reacquire or derive it. An absent `Needs-Manual` file is not a Step 1 failure. Formula markers are SVG authoring content and never create a required image file.
- Missing `templates/` inputs → restore the selected workspace through [`generate-pptx`](../generate-pptx.md) Step 3 and [`apply-template-workspace`](apply-template-workspace.md). If the workspace is unavailable or invalid, run Create Template again rather than reconstructing a template inside this stage.
---
@@ -298,6 +298,12 @@ The script:
| 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) |
**Hyperlink preservation**: External hyperlinks remain unchanged. A same-deck
slide jump is retargeted only when its source destination maps unambiguously to
one output slide; a self-link maps to the current clone. If the destination was
omitted or reused into multiple output slides, `apply` fails instead of linking
to an orphan or choosing a target silently.
**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). It also preserves source page transitions by default.
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "a85299a9edd2a961e32f01ced86963a852652bd2",
"commit": "dec288ed71d60f6cb090f60f94bc648eaf1c528b",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-08-13T08:38:10Z"
"syncedAt": "2026-08-13T15:59:59Z"
}