diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 86dd6dbe..d711d432 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -280,18 +280,6 @@ }, "category": "设计" }, - { - "name": "oh-my-codex", - "source": { - "source": "local", - "path": "./plugins/codex/plugins/oh-my-codex" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "开发工具" - }, { "name": "ui-ux-pro-max", "source": { @@ -316,6 +304,18 @@ }, "category": "开发工具" }, + { + "name": "oh-my-codex", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/oh-my-codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" + }, { "name": "ppt-master", "source": { diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 0c419de1..d65db062 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -15,8 +15,8 @@ "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", "adapter": "codex-plugin", - "commit": "5d43a5bf6f008de17f9425bee4495c457c60b96a", - "syncedAt": "2026-07-10T16:00:00Z" + "commit": "fce27bfd6c17c7665a6f1505b6b8384cc2c8edd5", + "syncedAt": "2026-07-12T16:00:00Z" }, { "id": "ui-ux-pro-max", @@ -87,8 +87,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "dbf1e363abb4dba36f7c883ee7ba1495073394d0", - "syncedAt": "2026-07-11T15:59:59Z" + "commit": "8f76b6fcef5f24c8716894adeb6fe24c5a418bc3", + "syncedAt": "2026-07-12T16:00:00Z" }, { "id": "next-skills", @@ -96,8 +96,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "1bd2fd585aac793ca2589e6f18f17a412fd11005", - "syncedAt": "2026-07-11T15:59:59Z" + "commit": "31541356c21bc389150b958379acb26aa7e97b22", + "syncedAt": "2026-07-12T16:00:00Z" } ] } diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index 9654e912..fb9c6731 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -280,18 +280,6 @@ }, "category": "设计" }, - { - "name": "oh-my-codex", - "source": { - "source": "local", - "path": "./plugins/oh-my-codex" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "开发工具" - }, { "name": "ui-ux-pro-max", "source": { @@ -316,6 +304,18 @@ }, "category": "开发工具" }, + { + "name": "oh-my-codex", + "source": { + "source": "local", + "path": "./plugins/oh-my-codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "开发工具" + }, { "name": "ppt-master", "source": { diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 709a252d..43b56ef3 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-07-11T16:01:50Z" + "syncedAt": "2026-07-12T16:01:37Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index e411eb28..809d4338 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "1bd2fd585aac793ca2589e6f18f17a412fd11005", + "commit": "31541356c21bc389150b958379acb26aa7e97b22", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-07-11T15:59:59Z" + "syncedAt": "2026-07-12T16:00:00Z" } diff --git a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json index 408705be..037326f3 100644 --- a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "oh-my-codex", - "version": "0.20.0", + "version": "0.20.1", "description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。", "author": { "name": "Yeachan Heo", diff --git a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json index 1c2ff842..2206e460 100644 --- a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "oh-my-codex", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", - "commit": "5d43a5bf6f008de17f9425bee4495c457c60b96a", + "commit": "fce27bfd6c17c7665a6f1505b6b8384cc2c8edd5", "adapter": "codex-plugin", "sourcePath": "plugins/oh-my-codex", - "syncedAt": "2026-07-10T16:00:00Z" + "syncedAt": "2026-07-12T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 1949a596..c3147ba5 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "dbf1e363abb4dba36f7c883ee7ba1495073394d0", + "commit": "8f76b6fcef5f24c8716894adeb6fe24c5a418bc3", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-07-11T15:59:59Z" + "syncedAt": "2026-07-12T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md index 7cd3a3de..d7fc024a 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md @@ -7,7 +7,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶 > AI-driven multi-format SVG content generation system. Converts source documents into high-quality SVG pages through multi-role collaboration and exports to PPTX. -**Core Pipeline**: `Source Document → Create Project → [Template] → Strategist → [Image_Generator] → Executor Live Preview → Quality Check → Post-processing → Export` +**Core Pipeline**: `Source Document → Create Project → [Template] → Strategist Structured Plan → [Image_Generator] → Executor Live Preview → Quality Check → Post-processing → Export` ### SVG Page-Design Boundary @@ -34,8 +34,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶 > 5. **NO SPECULATIVE EXECUTION** — "Pre-preparing" content for subsequent Steps is FORBIDDEN (e.g., writing SVG code during the Strategist phase) > 6. **NO SUB-AGENT SVG GENERATION** — Executor Step 6 SVG generation is context-dependent and MUST be completed by the current main agent end-to-end. Delegating page SVG generation to sub-agents is FORBIDDEN > 7. **SEQUENTIAL PAGE GENERATION ONLY** — In Executor Step 6, after the global design context is confirmed, SVG pages MUST be generated sequentially page by page in one continuous pass. Grouped page batches (for example, 5 pages at a time) are FORBIDDEN -> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file /spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the confirmed `pptx_structure.template_adherence` when present, the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit), `page_charts` (which chart template to adapt, if any), and — when `pptx_structure.mode` is `template` or `preserve` — `pptx_layouts` (which native PowerPoint layout the page references). Deck/layout template routes require both mappings for every page: `strict` reuses the selected layout contract; `adaptive` may create a new explicit layout contract from the selected reference. Empty / absent `page_layouts` rows are reserved for free design and brand-only routes — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default -> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce +> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file /spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the complete `pptx_masters` / `pptx_layouts` structure, `pptx_structure.template_adherence` when present, the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (the input template SVG), and `page_charts`. Master/Layout structure exists before drawing; export never infers it later. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default +> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce. **Narrow exception**: `preset_shape_svg.py` may print one deterministic stock-shape fragment to stdout after the main agent has selected its semantic role, frame, and paint. It cannot write `svg_output/`, choose layout, batch shapes, or generate a page; the main agent reads the fragment and inserts it through the normal hand-authored page edit > 10. **FOLLOW DETERMINISTIC ROUTING RULES** — Do not add blocking routing questions when this skill defines a route. If the user request violates a route precondition, state the required prerequisite and stop that route instead of asking the user to choose around the rule. Ordinary finite options, stylistic preferences, and recoverable details are surfaced with a recommended value plus alternatives at the next existing confirmation gate. > [!IMPORTANT] @@ -86,7 +86,9 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶 | `${SKILL_DIR}/scripts/latex_render.py` | LaTeX formula rendering (manifest-driven PNG assets) | | `${SKILL_DIR}/scripts/image_gen.py` | AI image generation (multi-provider) | | `${SKILL_DIR}/scripts/slice_images.py` | Slice one AI illustration sheet into individual spot-illustration elements | +| `${SKILL_DIR}/scripts/svg_authoring_view.py` | Create a lightweight non-destructive inspection projection of PPTX-imported SVGs; never a release source | | `${SKILL_DIR}/scripts/svg_quality_checker.py` | SVG quality check | +| `${SKILL_DIR}/scripts/preset_shape_svg.py` | Print one registry-backed native PowerPoint preset fragment to stdout for hand-authored SVG insertion | | `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting | | `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) | | `${SKILL_DIR}/scripts/svg_to_pptx.py` | Export to PPTX | @@ -120,13 +122,31 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`. | Raw PPTX template plus new material/topic, generate a PPTX | [`template-fill-pptx`](workflows/template-fill-pptx.md) | | Existing PPTX, preserve page count/order and slide wording 1:1, improve layout | [`beautify-pptx`](workflows/beautify-pptx.md) | | Existing PPTX as source material, rethink outline or change page count/order | Main pipeline via `source_to_md.py` plus PPTX intake | -| Build a reusable template package from a PPTX/design reference | [`create-template`](workflows/create-template.md), then return with the generated template directory path | +| Build a reusable template package from a PPTX/design reference | [`create-template`](workflows/create-template.md), then return with the generated template workspace path | | Finished PPTX, keep content/layout stable and add notes/audio/timing/transitions | [`native-enhance-pptx`](workflows/native-enhance-pptx.md) | -**MUST**: Raw `.pptx` template plus "generate PPTX" routes to `template-fill-pptx` by default. The SVG generation route consumes only an explicit template directory path that already contains a valid template `design_spec.md`. +**MUST**: Raw `.pptx` template plus "generate PPTX" routes to `template-fill-pptx` by default. The SVG generation route consumes only an explicit template workspace path with a valid `templates/design_spec.md`, or a supported direct/legacy package root with `design_spec.md`. **MUST**: Beautify is strictly 1:1. Any split, merge, drop, reorder, or page-count change routes to the main pipeline. +**MUST — reusable template mode boundary**: `create-template` has two distinct +contracts. `standard` and `fidelity` author new SVG prototypes and their own +Master/Layout/slot system; source visuals and assets are references, and source +Master/Layout topology is neither preserved nor distilled. `mirror` is a +restoration path: preserve the source slide roster/order, visual appearance, +Master/Layout parentage and identities, placeholder type/index/bounds, native +object ownership, and supported native-shape metadata. The lossless import is +the restoration authority; the lightweight authoring projection exists only to +keep model context small. Mechanical normalization may express the source facts +in the current explicit SVG contract and expand fixed-layer group wrappers into +direct atoms, but it MUST NOT merge, split, promote, demote, rename, or +re-parent source structure. Export compiles the selected contract and never +infers a different one. Because the current structured roster materializes only +identities referenced by emitted page prototypes, `mirror` is available only +when every source Layout is used by a source Slide and every source Master is +reachable through those Layouts. Otherwise stop and report the exact +unreachable identities; never drop them or invent a carrier page. + **FALLBACK**: Ambiguous requests such as "make this PPT more professional" require exactly one discriminator question: preserve original page count/order and slide wording, or treat the deck as source material and restructure it? --- @@ -214,20 +234,22 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project **Default — free design.** Proceed directly to Step 4. Do NOT query any `*_index.json` unless triggered. Do NOT ask the user. Do NOT proactively suggest, hint at, or fuzzy-match any template based on content, slug-like words, or vague style descriptions. -**Hard boundary — raw PPTX template references are not Step 3 templates.** PPTX-as-source remains valid in Step 1 / Step 2, and raw PPTX template + generated PPTX routes to `template-fill`. But if the user wants the SVG/template-based generation route from that PPTX, stop before Step 3. The user must first run [`workflows/create-template.md`](workflows/create-template.md), then return with the generated template directory path. Step 3 only consumes an explicit template directory that already contains `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck`. +**Hard boundary — raw PPTX template references are not Step 3 templates.** PPTX-as-source remains valid in Step 1 / Step 2, and raw PPTX template + generated PPTX routes to `template-fill`. But if the user wants the SVG/template-based generation route from that PPTX, stop before Step 3. The user must first run [`workflows/create-template.md`](workflows/create-template.md), then return with the generated template workspace path. Step 3 consumes an explicit workspace whose `templates/design_spec.md` declares `kind: brand` / `kind: layout` / `kind: deck`, or a compatible legacy-flat package whose root `design_spec.md` declares one of those kinds. Do **not** reinterpret this boundary as 1:1 redesign or free SVG generation. Use `template-fill` for raw PPTX template + generated PPTX requests; use `beautify` only when the source deck's page count, order, and wording are preserved. -**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message, plus one narrow workflow handoff: a project-scoped `create-template` run in the current conversation may pass its exact validated `/templates/` output directly into this Step. The trigger rule is mechanical, not interpretive: +**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message, plus one narrow workflow handoff: a project-scoped `create-template` run in the current conversation may pass its exact validated project workspace root directly into this Step. The trigger rule is mechanical, not interpretive: | User input contains | Step 3 action | |---|---| -| One or more explicit template directory paths (each resolves to a directory containing `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck` in its YAML frontmatter) | Read each spec's `kind`, dispatch per the kind matrix below, fuse if multiple | -| Current `create-template` workflow just completed project scope and validated its exact `/templates/` output | Consume that single directory in place; it cannot join multi-path fusion | +| One or more explicit template workspace paths (each resolves to `templates/design_spec.md`, or to a compatible legacy-flat root `design_spec.md`, with `kind: brand` / `kind: layout` / `kind: deck` in YAML frontmatter) | Normalize each source directory, read its `kind`, dispatch per the kind matrix below, fuse if multiple | +| Current `create-template` workflow just completed project scope and validated its exact `/` workspace | Consume that single workspace in place; it cannot join multi-path fusion | | Anything else — bare template names ("用 academic_defense"), style descriptions ("麦肯锡风格"), brand mentions ("招商银行风格"), vague intent ("想用个模板"), or silence | Skip Step 3, free design | There is no slug matching, no name lookup, no fuzzy resolution. A name without a path does not trigger — the user must give a path the AI can `cd` into. +**Structured-template preflight (before copy)**: For every deck/layout workspace, inspect all SVG roots and slots under its normalized template source. Every page must declare root Master/Layout key and picker names; Master/Layout visuals must be direct atoms rather than ``; every slot must be a top-level `` with positive bounds and exactly one compatible carrier, or an explicit composite `object` proxy. A zero-slot Layout is valid. If the SVG package uses a legacy semantic contract, run [`restore-pptx-structure`](workflows/restore-pptx-structure.md) first and return to Step 3 with the migrated workspace. A legacy flat directory shape alone is read compatibility and does not trigger restoration. + > Style descriptions ("麦肯锡风格" / "Keynote 风" / "极简风" / etc.) never trigger Step 3. They flow into the Strategist confirmation stage as a style brief (color / typography / tone in fields e–g). > Bare names ("academic_defense", "招商银行", "anthropic") do NOT trigger Step 3 even if a matching directory exists in the library. The user must give a path. AI must not "helpfully" resolve a name to a path. @@ -242,9 +264,9 @@ The architecture has three independent reference bundles. Full schema in [`docs/ | Kind | Physical dir | Contains | Frontmatter | |---|---|---|---| -| **brand** | `templates/brands//` | identity-only segment: color / typography / logo / voice / icon style | `kind: brand` | -| **layout** | `templates/layouts//` | structure-only segment: canvas / page structure / page types / SVG roster | `kind: layout` | -| **deck** | `templates/decks//` | full replica: identity + structure + middle (template overview) segments | `kind: deck` | +| **brand** | `templates/brands//templates/` inside a complete workspace | identity-only segment: color / typography / logo / voice / icon style | `kind: brand` | +| **layout** | `templates/layouts//templates/` inside a complete workspace | structure-only segment: canvas / page structure / page types / SVG roster | `kind: layout` | +| **deck** | `templates/decks//templates/` inside a complete workspace | full identity + structure reference with the middle (template overview) segment | `kind: deck` | **Segment ownership** (governs fusion override priority): @@ -258,48 +280,30 @@ The architecture has three independent reference bundles. Full schema in [`docs/ | User path's `kind` | Step 3 action | |---|---| -| `kind: brand` | `design_spec.md` + non-image assets → `/templates/`; logo / illustration / icon **bitmaps** → `/images/`. Strategist locks identity segment as truth; structure stays free. | -| `kind: layout` | `design_spec.md` + SVG roster → `/templates/`; any **bitmap** assets → `/images/`. Strategist locks structure; identity decided in Strategist confirmation stage e–g. | -| `kind: deck` | `design_spec.md` + template SVGs → `/templates/`; logos / backgrounds / other **bitmaps** → `/images/`. Strategist locks all segments; Strategist confirmation stage narrows to deck-content fields (audience / page count / outline / tone tweaks). | +| `kind: brand` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks identity; structure stays free. | +| `kind: layout` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks structure; identity is decided in confirmation fields e–g. | +| `kind: deck` | Install `templates/` plus any existing `images/` and `icons/` into the matching project roots; ignore `exports/`. Strategist locks all segments; confirmation narrows to deck-content fields. | -```bash -TEMPLATE_DIR="" -PROJECT_TEMPLATES="/templates" -resolve_path() { - python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$1" -} -# Bitmaps join the project's single runtime image pool (images/, referenced as -# ../images/); the spec + template SVGs + other non-image assets stay in -# templates/ as design reference the Strategist/Executor read but never render. -if [ "$(resolve_path "${TEMPLATE_DIR}")" = "$(resolve_path "${PROJECT_TEMPLATES}")" ]; then - # Project-scoped create-template output is already staged in place. - true -else - cp -r "${TEMPLATE_DIR}"/. "${PROJECT_TEMPLATES}/" - find "${PROJECT_TEMPLATES}" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.bmp' \) -exec mv {} "/images/" \; -fi -``` +Normalize every explicit path before any write: -The same split applies to all three kinds — bitmaps always land in `images/`, the rest in `templates/`. The spec's `kind` field tells Strategist how to read the `templates/` side; downstream code doesn't distinguish. (Template SVGs in `templates/` are reference material only — the rendered pages live in `svg_output/` and reference images via `../images/`.) +| Input shape | Spec / SVG source | Asset source | Install rule | +|---|---|---|---| +| Current workspace: `/templates/design_spec.md` | `/templates/` | Any existing `/images/`, `/icons/` | Map the existing portable roots to the target project's matching roots; ignore `/exports/` | +| Compatible legacy-flat package: `/design_spec.md` | `/` | Package-local files | SVG/spec/non-bitmaps → project `templates/`; bitmaps → project `images/`; route declared icons to project `icons/` | -When `create-template` used project output scope, its directory is already the -target project's `templates/` root and its bitmap/icon runtime copies are already -in their final project pools. Resolve both paths before copying: equality means -**in-place consumption**, so skip both the copy and bitmap move. An in-place -directory is one complete bundle and cannot participate in multi-path fusion; -use external library bundles for fusion. Never place the local bundle under a -nested `templates/local_master/` directory because the confirmation and quality -gates read the project `templates/` root. +**Atomic install preflight (mandatory)**: Resolve source and destination paths, enumerate the complete file mapping, and reject every destination collision before copying any file. Equality between a current project workspace root and the target project root means in-place consumption and no copy. For an external single path, a collision stops Step 3 rather than overwriting. For multi-path fusion, do not copy packages sequentially: resolve segment conflicts and asset-name conflicts first, construct one final mapping, then write it once. Never use recursive copy as an implicit conflict policy. -A project-scoped bundle belongs only to its target project: its SVGs may refer -to sibling `../images/` and runtime `../icons/` pools that are outside the -template root. Do not copy another project's `templates/` root as an external -bundle. For cross-project reuse, recreate/promote the design with -`create-template` library scope so the package is self-contained. +Never infer that a flat directory has legacy Master/Layout semantics solely from packaging. -Legacy template packages may ship `native_structure.json` + `source_template.pptx`; keep the pair together in `/templates/` for backward-compatible `preserve` export. Current `create-template` output does not package this pair: it rebuilds complete, explicitly layered SVG templates instead. During fusion, never mix one legacy contract with another template's source package. +The same current-workspace routing applies to all three kinds: source/spec in `templates/`, visual assets in `images/`, runtime icons in `icons/`, and on-demand review artifacts in `exports/`. Empty optional roots are omitted rather than retained with placeholder files, so a normal workspace has no `exports/` until a review file is explicitly generated. The spec's `kind` tells Strategist how to read the installed source. Template SVGs are not export-time overlays: visible output still lives completely in `svg_output/`. Their complete visuals and explicit Master/Layout/placeholder metadata are nevertheless the authoring prototypes selected by `page_layouts`. -The Strategist confirmation stage decides whether the selected template is used `strict` or `adaptive`. Both use `pptx_structure.mode: template` for current templates and map every page to one reference SVG plus one output PowerPoint layout. `strict` keeps the referenced layout contract unchanged; `adaptive` may create a new layout key while keeping the template Master contract. Legacy `preserve` remains strict-only compatibility behavior. +When `create-template` used project output scope, its workspace root is the target project itself and all core directories are already final. Resolve both roots before copying: equality means **in-place consumption**, so skip the installation. An in-place workspace cannot participate in multi-path fusion; use external workspaces for fusion. Never place the local source under a nested `templates/local_master/` directory because the confirmation and quality gates read the project `templates/` root. + +A project-scoped workspace has the same portable routing as a library workspace. It may be copied or promoted across roots as one unit (`templates/` plus any existing `images/` and `icons/`); `exports/` stays review-only. Do not pass only another project's `templates/` subdirectory because that would omit sibling assets. + +Legacy template packages may ship `native_structure.json` + `source_template.pptx`, omit root Master identity, use direct atomic placeholders, or carry old baseline/distillation metadata. Do not copy or consume those semantic contracts through Step 3. Run [`restore-pptx-structure`](workflows/restore-pptx-structure.md) on the package first, then return with the migrated workspace path. Old flat packaging remains readable when its SVG structure is already current. + +The Strategist confirmation stage decides whether the selected template is used `strict` or `adaptive`. New projects use `pptx_structure.mode: structured`, map every page to one input SVG in `page_layouts`, and write complete `pptx_masters` / `pptx_layouts` output mappings before SVG generation. Strict preserves the template's declared Master/Layout/slot contract. Adaptive keeps the template Master and may assign a new Layout key during authoring when the composition genuinely changes. Non-mirror paint and typography follow the project skin rules. #### Multi-path fusion @@ -411,16 +415,16 @@ Steps: python3 ${SKILL_DIR}/scripts/confirm_ui/server.py --daemon --wait ``` Page opens at the launch-log URL such as `http://127.0.0.1:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Stage 1: it shows a "deriving…" state and polls for Stage 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** for a fresh `status: stage1-confirmed` before dropping to the chat fallback. **On success (exit 0 with a stage-1 result), do not pause or report — go straight to step 3 in the same turn.** -3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page; if Windows cleaned up the server, `--wait-only` auto-recovers it on the recorded/default port so the browser reconnects: +3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample); enumerable `icons` / `formula_policy` (recommended `id`). **Stage 2 is never skipped** — an active deck/layout template (`strict` adherence included) does not exempt it: the template skin becomes the recommended color / typography candidate and real alternatives fill the remaining cards. Never jump `recommendations.json` from `stage1` to `stage3`: the server refuses to render a skipped stage (the page stays on "deriving…") and the waits exit non-zero until the stages are written in order. The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page; if Windows cleaned up the server, `--wait-only` auto-recovers it on the recorded/default port so the browser reconnects: ```bash python3 ${SKILL_DIR}/scripts/confirm_ui/server.py --wait-only --wait-stage stage2 ``` - This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat. + This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat — except a `stage skip detected` error, which is not a page failure: you wrote a stage out of order; rewrite `recommendations.json` with the stage the error names and re-attach. 4. **Re-derive Stage 3 from the confirmed anchors + design system, then wait for the final confirmation.** Read the stage-2 `result.json`. Author the image and execution recommendations and **overwrite** `recommendations.json` with `"stage": "stage3"`: `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive); `image_strategy.candidates` as **exactly three non-custom** rendering × palette recommendations from h.5 when `image_usage` includes `ai` (the page adds the fourth Custom card itself); enumerable `image_ai_path` / `generation_mode` and `refine_spec` (recommended `id` / boolean). If the recommendation involves several image sources, keep the source list structured in `recommend.image_usage` and write the usage rationale / page-role guidance into `image_notes` (for example, "封面和章节页用 AI 主视觉,产品页优先用户素材,行业背景页可用网络参考"). Write `image_ai_path` only when `image_usage` includes `ai`. Spot-illustration lean is **not** a candidate field here: it derives from the locked `visual_style`'s illustration propensity and is expressed only in the recommendation rationale / `image_notes`, never as a new confirmation field. Generated-image style palettes are **color behavior only**; final image colors follow the confirmed Stage-2 `color`. Custom image-strategy dimensions are handled by the built-in Custom card, are prose-only, and should not promise a gallery reference image. Then attach to the already-running page; `--wait-only` auto-recovers a dead server as above (same 600000 ms budget): ```bash python3 ${SKILL_DIR}/scripts/confirm_ui/server.py --wait-only ``` - This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying Stage 1 + Stage 2 + Stage 3 fields). On a non-zero exit, re-check `result.json` once. Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices. + This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying Stage 1 + Stage 2 + Stage 3 fields). On a non-zero exit, re-check `result.json` once (a `stage skip detected` error means Stage 2 was never confirmed — go back to step 3, not the chat fallback). Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices. 5. **Close the confirm page (Mandatory cleanup — every path).** Shut the server down before leaving Step 4 so it cannot keep holding port 5050 (which Step 6 live preview reuses): ```bash python3 ${SKILL_DIR}/scripts/confirm_ui/server.py --shutdown @@ -593,11 +597,12 @@ Read the execution references for this deck's locked `mode` + `visual_style` (fr ``` Read references/executor-base.md # REQUIRED: common guidelines Read references/shared-standards.md # REQUIRED: SVG/PPT technical constraints +Read references/native-shape-authoring.md # REQUIRED: stock-shape selection and fragment helper contract Read references/modes/.md # narrative skeleton (spec_lock.md `mode`) Read references/visual-styles/.md # aesthetic (spec_lock.md `visual_style`) ``` -> Read executor-base + shared-standards + the one locked mode file + the one locked visual-style file. For `mode: custom` or `visual_style: custom`, skip that preset file and follow `mode_behavior` / `visual_style_behavior` from `spec_lock.md` instead. Never glob `modes/` or `visual-styles/`. +> Read executor-base + shared-standards + native-shape-authoring + the one locked mode file + the one locked visual-style file. For `mode: custom` or `visual_style: custom`, skip that preset file and follow `mode_behavior` / `visual_style_behavior` from `spec_lock.md` instead. Never glob `modes/` or `visual-styles/`. **Design Parameter Confirmation (Mandatory)**: before the first SVG, output key design parameters from the spec (canvas dimensions, color scheme, font plan, body font size). See executor-base.md §2. @@ -616,16 +621,26 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py --live --daemon > Image facts: trust the `analysis/image_analysis.csv` regenerated at the end of Step 5. If `images/` changed since (the user swapped or added files), re-run `python3 ${SKILL_DIR}/scripts/analyze_images.py /images` before laying images out — facts are re-derived on use, never a stale store (Step 4 image-facts note). -**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file /spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups and the route-specific `pptx_structure` / `pptx_layouts` native-layout contract. The contract is absent only on legacy/free inputs that have not yet been normalized; it is mandatory for every current template page. Resists context-compression drift on long decks. See executor-base.md §2.1. +**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file /spec_lock.md` and use only its colors / fonts / icons / images, plus the complete `pptx_masters` / `pptx_layouts` structure and the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups. Resists context-compression drift on long decks. See executor-base.md §2.1. > ⚠️ **Main-agent only**: SVG generation MUST stay in the current main agent — page design depends on full upstream context. Do NOT delegate to sub-agents. > ⚠️ **Generation rhythm**: generate pages sequentially, one at a time, in the same continuous context. Do NOT batch (e.g., 5 per group). **Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `/svg_output/` -Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. +Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. Before drawing a literal stock shape, apply [`native-shape-authoring.md`](references/native-shape-authoring.md): use the stdout-only helper when one PowerPoint preset exactly matches, keep basic SVG primitives for rect/round-rect/ellipse, and keep free SVG for custom semantics. Never infer a preset from contour similarity. -Every `baseline` / free-design page root MUST declare one canonical `data-pptx-page-role`; `template` / `preserve` pages already declare `data-pptx-layout` and MUST NOT duplicate that identity merely to satisfy this rule. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by `data-pptx-layer`, `data-pptx-placeholder`, or `data-pptx-native`; such an element needs a stable unique `id`. Do not add generic content roles to ordinary titles, body text, cards, KPIs, diagrams, charts, icons, or images. Full contract: [`references/semantic-svg.md`](references/semantic-svg.md). +Template pages MUST start from the complete `page_layouts` SVG, keep all inherited visible objects in `svg_output/`, and preserve the locked root Master/Layout identity plus stable atomic Master/Layout and slot ids. Strict keeps the prototype structure unchanged. Adaptive keeps its Master contract and, when Layout atoms or slot topology/bounds genuinely evolve, assigns a new key/name and updates `spec_lock.md` immediately. Non-mirror fill/stroke/effects/font sizes still follow `spec_lock`. + +Free-design and brand-only pages are also structured from the first draft. Use the planned Master roster and page Layout mapping, author Master/Layout fixed visuals only as direct root atoms (never ``), and author reusable content zones as top-level slot groups with positive bounds plus exactly one compatible carrier. Every mapped page MUST mark the standard slots it actually has — `title` / `subtitle` / `body` / `picture` / `slide-number` / `footer` — and its `data-pptx-layer` marks: the deck-wide background and every-page chrome as `master`, this layout key's static framing (including chrome repeated on content pages but absent from the cover) as `layout`, per [`executor-base.md`](references/executor-base.md) §1.2. Repeated chrome stays on its appropriate Master/Layout layer; a per-page heading that should not remain Slide-local is authored as a `title` or `subtitle` slot, never as a fixed Layout atom. Any text or image whose value varies across pages sharing one Layout key MUST be carried by a slot or remain Slide-local. A page shipping only a marked background exports a bare Master and an empty Layout. A Layout may intentionally have zero slots — a stated decision for a fixed composition, never a silent default. Do not add `data-pptx-layout-kind`, `distilled`, `utility`, or a full-page fake slot. + +Do not duplicate specialized identity with `data-pptx-role`. Add it only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by `data-pptx-layer`, `data-pptx-placeholder`, or `data-pptx-native`; such an element needs a stable unique `id`. Do not add generic content roles to ordinary titles, body text, cards, KPIs, diagrams, charts, icons, or images. Full contract: [`references/semantic-svg.md`](references/semantic-svg.md). + +**First-page gate (Mandatory)** — after the **first** SVG page, before drawing page 2: +```bash +python3 ${SKILL_DIR}/scripts/svg_quality_checker.py /svg_output/.svg +``` +Fix every `error` on page 1 first — structural violations are systematic, and a first-page error repeated deck-wide costs a whole-deck rewrite. **Quality Check Gate (Mandatory)** — after all SVGs, BEFORE annotation handling and speaker notes: ```bash @@ -633,16 +648,19 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py ``` - Any `error` (banned SVG features, viewBox mismatch, spec_lock drift, etc.) MUST be fixed before proceeding — return to Visual Construction, regenerate that page, re-run check. - `warning` entries (low-res image, non-PPT-safe font tail, etc.): fix when straightforward, otherwise acknowledge and release. +- **PPTX-structure warnings are the exception — never acknowledge-and-release.** For each empty-Layout / framing-only-Layout / bare-Master / duplicate-layout-key warning, output one disposition line: either the fix applied (merge keys in `spec_lock.md pptx_layouts` + SVG roots, mark the missing slots/layers) or why the flagged state is intended (e.g. "P01 cover is a fixed composition, zero-slot by design"). "0 errors" alone does not pass this gate. - Run against `svg_output/` (not after `finalize_svg.py` — finalize rewrites SVG and masks violations). **Logic Construction Phase**: generate speaker notes → `/notes/total.md` -**✅ Checkpoint — Confirm all SVGs and notes are fully generated and quality-checked. Proceed directly to Step 7 post-processing**: +**✅ Checkpoint — Confirm all SVGs and notes are fully generated and quality-checked. Run the applicable conditional gates below, then proceed to Step 7**: ```markdown ## ✅ Executor Phase Complete - [x] Live preview started before the first SVG and kept available at the reported URL +- [x] First-page gate run after page 1 (errors fixed before page 2) - [x] All SVGs generated to svg_output/ - [x] svg_quality_checker.py passed (0 errors) +- [x] PPTX-structure warnings dispositioned one by one (each fixed or stated as intended) - [x] Speaker notes generated at notes/total.md ``` @@ -687,9 +705,6 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py # Output (default-flow mode): # exports/_.pptx ← native pptx (canonical output, reads svg_output/) # backup//svg_output/ ← Executor SVG source backup (always written) -# backup//templates/{native_structure.json,source_template.pptx} -# ← preserve mode only -# backup//spec_lock.md ← preserve mode only # Add --native-objects to emit real editable chart/table objects instead of flattened shapes: # exports/__native_charts.pptx ← native chart/table objects (data-pptx-native markers) # Re-export with --recorded-narration audio (generate-audio workflow) embeds per-slide narration: @@ -711,9 +726,8 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py > snapshot in `backup//` is always written so the project can be > re-exported from frozen SVG sources without re-running the LLM. The SVG-rendered > preview remains the mandatory `svg_final/` artifact from Step 7.2; it is not -> packaged as a second PPTX. Preserve mode also backs up the paired native -> structure/source files and `spec_lock.md` under the same timestamp. Use the -> default source selection for release exports. `-s final` is diagnostic-only +> packaged as a second PPTX. Use the default source selection for release +> exports. `-s final` is diagnostic-only > when comparing conversion behavior against the post-processed SVGs; it does > not change `svg_output/` ownership or establish a supported release route. @@ -724,115 +738,53 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py > pages remain ordinary SVG pictures unless the user independently accepts the > results of an unsupported Office conversion. -> **PPTX structure mode** — native export first reads -> `spec_lock.md` `pptx_structure.mode`, then falls back to `baseline` when the -> section is absent. An explicit `--pptx-structure` CLI value overrides the -> lock for diagnostics. Baseline keeps the real slide master/layout -> relationship from the base deck and promotes the strict-majority identical -> native slide background into the slide master (every slide must carry an -> explicit background; minority slides keep their own, which overrides the -> master fill). Baseline may also promote a shared **leading** prefix of -> top-level SVG elements whose explicit `data-pptx-role` marks page chrome -> (`logo`, `footer`, `header`, `watermark`, `chrome`, `page-number`). Exact id -> tokens remain a compatibility fallback only when the role marker is absent. -> Promotion requires identical generated OOXML on a strict majority of slides -> sharing the master, no slide-timing reference, and z-order safety; overlay -> chrome stays slide-local. Minority slides (covers, section pages) keep every -> shape slide-local and are bound to a generated `Cover` layout with -> `showMasterSp="0"`, so promoted master chrome never appears on them. Image -> relationships for promoted chrome are copied to the master. After the SVG -> pages are complete, baseline assigns conservative semantic layout families: -> root `data-pptx-page-role` values `cover` / `toc` / `section` / `ending` become -> `Cover` / `Agenda` / `Section` / `Closing`, while `content` becomes `Content`. -> Filename tokens remain a compatibility fallback for marker-free legacy SVGs. -> It also -> preserves an existing safety `Cover` assignment made while hiding promoted -> Master chrome. It does not inspect visual similarity or infer placeholders, -> and all actual page content remains slide-local. When every slide in one -> family carries the exact same explicit background, that background moves to -> the family layout. The same family may also promote an identical leading -> prefix of explicitly named chrome when every family member carries the same -> generated OOXML, relationships are safe, no timing targets it, and z-order is -> preserved. This is -> an export-only package pass: SVG authoring, live preview, and model context are -> unchanged. Baseline also prunes base-template slide layouts no generated slide -> references, so the PowerPoint new-slide picker only offers layouts that belong -> to the deck, and -> converts `data-pptx-role="page-number"` chrome whose text exactly equals the -> slide's display number into an auto-updating PowerPoint slide-number field -> (legacy page-number id tokens remain a fallback; other numbering schemes keep -> their literal text). For projects with a -> `spec_lock.md` typography contract, baseline also installs `title_family` as -> the PowerPoint theme major font and `body_family` / `font_family` as the minor -> font; matching SVG runs use theme tokens so later theme edits can cascade -> without changing initial rendering. Baseline/template export likewise maps -> the locked background, text, primary/accent, and border roles into the -> PowerPoint color scheme. Exact matching SVG/native-object colors use -> context-safe theme tokens; unrelated local colors, inverse white/black, and -> effects stay concrete, so the first rendering remains the SVG design while -> later theme edits can cascade selectively. Add -> `--pptx-structure flat` only for debugging/comparison when all generated -> backgrounds and chrome must remain slide-local and fonts must stay concrete. - -> **Legacy preserved source-template export** — an existing template package -> from a structured PPTX may ship `native_structure.json` plus -> `source_template.pptx`. This pair records a reusable capability; it does not -> select the downstream route by itself. Only a confirmed -> `template_adherence: strict` deck may set `pptx_structure.mode: preserve`. -> Add project-relative -> `source_template` / `native_structure` rows, and map every page in -> `pptx_layouts` to an exact imported layout key/name. Native export verifies the -> source SHA-256 and package parts, creates the generated slides against the -> original layouts (including multiple source masters), removes SVG-only preview -> copies of inherited master/layout visuals, and binds slide content to the -> original placeholder type/index. Preserve mode keeps the source layout picker -> roster; it does not prune unused source layouts. +> **PPTX structure mode** — release export requires +> `spec_lock.md` `pptx_structure.mode: structured`, a complete +> `pptx_masters` roster, and exactly one `pptx_layouts` row per page in +> ` | | ` form. Every SVG +> root repeats the Master/Layout keys and picker names. Master/Layout fixed +> visuals are direct root atoms; a `` is +> forbidden. Reusable slots are direct root `` elements with positive +> design-zone bounds and exactly one compatible carrier. A composite region +> uses only the explicit `object` + `proxy` fallback, and a Layout may +> intentionally have zero slots. > -> Current template creation no longer emits this pair. `preserve` is retained -> only so existing template packages and projects remain exportable; do not -> select it for newly created templates. +> Export creates the declared Masters and Layouts, promotes the represented +> atoms, binds slot carriers, installs the locked theme/text defaults, and +> reopens the candidate package to verify Presentation → Master → Layout → +> Slide registration, picker names, static-object rosters, placeholder +> type/index/bounds, carrier bindings, hidden proxies, and zero-slot Layouts. +> It never selects pages, clusters visuals, promotes repeated chrome by +> heuristic, or invents missing structure. Legacy `baseline`, `template`, +> `preserve`, `layout_strategy`, `data-pptx-layout-kind`, +> `distilled`/`utility`, direct atomic placeholders, or missing Master +> identity must run +> [`restore-pptx-structure`](workflows/restore-pptx-structure.md) before +> export. -> **Explicit reusable template export** — deck/layout template routes set -> `spec_lock.md` `pptx_structure.mode: template`. Every SVG root declares -> `data-pptx-layout`, and direct children -> explicitly declare master/layout layers, an optional Slide background, or -> PowerPoint placeholders. This -> mode builds named reusable layouts and placeholder mappings; it does not -> infer them from visual similarity. Static master/layout elements must be -> repeated consistently in PowerPoint paint order. Chart/table placeholders -> additionally require `--native-objects`. The metadata contract and authoring -> example live in -> [`references/shared-standards.md`](references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export). -> Raw PPTX templates still route to `template-fill-pptx`; `template` structure -> mode is for explicitly annotated SVG authoring. Both `strict` and `adaptive` -> template use stay on this deterministic route. Adaptive pages may define new -> layout keys, but they repeat the same Master layer and one internally -> consistent Layout/placeholder contract. -> Template export also installs the locked `typography.title` size into every -> Master `titleStyle` level and the locked `typography.body` size into every -> `bodyStyle` and `otherStyle` level. Each text-bearing Layout placeholder also -> writes its prototype run size to `a:lstStyle/a:lvl1pPr/a:defRPr@sz`, so a -> newly inserted or reset placeholder keeps that Layout's local size before it -> falls back to the Master. Generated slide runs and Layout prompt runs keep -> their direct sizes. Missing or invalid locked title/body sizes fail template -> export. Baseline, preserve, and flat modes do not apply either text-default -> update. -> Before publishing the temporary PPTX, template export reopens the finished -> package and verifies `Presentation → Master ↔ Layout ← Slide` registration, -> named custom Layout parts and content types, collision-free placeholder -> identities, prototype bounds, and prompt/level-one sizes. This read-back gate -> is template-only and does not compare later same-Layout slides' local geometry -> or direct text formatting. -> In template mode, `pptx_layouts` contains exactly one locked -> layout key/name per page; reuse a key for a shared structure instead of -> creating one key per content instance. + +> **Template structured export** — `page_layouts` records the complete +> input prototype per page while `pptx_masters` / `pptx_layouts` record the +> output structure from planning onward. Strict keeps the prototype +> Master/Layout/slot contract. Adaptive keeps its Master and may assign a new +> Layout identity during page authoring only when fixed Layout atoms or slot +> topology/bounds change; the lock is updated immediately. Non-mirror skin +> remains project-controlled, mirror preserves the reused visual identities, +> and the exporter never reads a template to add visible objects missing from +> `svg_output/`. Raw PPTX templates still route to `template-fill-pptx`; +> reusable template creation goes through `create-template`. > **Paragraph editability vs line fidelity** — by default, mergeable dy-stacked > paragraph blocks collapse into one editable PowerPoint text frame with multiple > ``, improving body-text editing and resize/reflow behavior. Add `--no-merge` > only when the user explicitly asks for strict line-layout fidelity or when a > layout-tight page must keep every dy-stacked line as its own text frame. The -> merge detector is conservative; mixed-layout text falls back to per-line frames. +> merge detector is conservative: adjacent lines with different effective font +> sizes retain a paragraph break, and mixed-layout text falls back to per-line frames. +> A multiline +> text carrier inside a slot must remain one native text frame; do not combine +> it with `--no-merge`. Strict-line text stays Slide-local rather than claiming +> one PowerPoint placeholder. > **Native table/chart objects** — supported data charts and pure text-grid > tables carry `data-pptx-native` markers by default (Executor transcribes @@ -840,12 +792,49 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py > stay dormant. > Add `--native-objects` only when the user explicitly wants > PowerPoint-editable native tables/charts and accepts that those objects may -> render differently across PowerPoint / Keynote / LibreOffice / WPS. Without +> render differently across PowerPoint / Keynote / LibreOffice / WPS; marker-local +> details not represented by native metadata may be omitted. This is a lossy +> editable-first contract, not a reason to disable an otherwise supported marker. Without > the flag, marked groups export through their SVG fallback children like > ordinary SVG content. Imported objects that carry > `data-pptx-native-status` are fallback-only; the quality checker and > `--native-objects` export surface their reason as warnings rather than silently -> claiming editability. +> claiming editability. An imported chart with no baked preview is a different +> case: `data-pptx-visual-status="placeholder"` plus +> `data-pptx-route-status="reconstruction-only"` records a diagnostic route. +> Default export keeps that placeholder with a warning; when the same group has +> a valid active `data-pptx-native="chart"` payload, `--native-objects` may still +> reconstruct the editable chart. Invalid or contradictory status declarations +> remain export errors. For supported parsed classic families, the importer +> instead emits a deterministic visible fallback with +> `data-pptx-visual-status="normalized"`; this is readable reconstruction, not +> a claim of Office pixel parity. Active imported table/chart markers also carry +> `data-pptx-fallback-sha256`. If their fallback, reachable SVG fragment +> definition, local reference target, or marker transform changes later, default +> export keeps that SVG, the mandatory quality checker warns, and +> `--native-objects` fails rather than discard the edit. Legacy markers +> without a baseline remain native-compatible and only warn that stale detection +> is unavailable. +> Imported table markers may also cover the verified narrow P2 subset: +> exact physical row/grid topology, canonical rectangular merges with blank +> covered cells, safe per-side borders, plain multi-paragraph cells, and closed +> run-level rich text. A rich paragraph contains non-empty `runs`; each run +> requires `text` and may use only +> `bold` / `italic` / `underline` / `strike` / `color` / `font_size` / +> `font_family` / `lang` / `alt_lang`. Presentation-only source run XML +> normalizes, while relationship-bearing text, extensions, line breaks, fields, +> tabs, bullets, broken text topology, noncanonical merges, and unsafe direct +> formatting remain fallback-only. Imported classic charts +> additionally cover verified column/line/area combos, canonical OHLC stock, +> area date-axis cases, verified scatter/bubble axes, radar, safe `of_pie` +> `serLines`, and the closed axis/title/legend plus bar-gap/overlap normalization +> cases. The importer also accepts the seven closed ChartEx data models: +> treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel. +> ChartEx data topology is retained for native read-back, but style, axis, +> labels, and binning details may normalize. These additions do not create a +> full `AxisSpec`, arbitrary ChartEx import, arbitrary rich OOXML, or new +> normalized-renderer coverage; unmodeled semantics continue to fail closed +> without reducing existing active-marker SVG-to-native conversion. **Optional animation flags** (page transitions are on by default; per-element entrance is off by default — turn it on only when the user asks for it): - `-t ` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`. `none` removes only the visual transition; an explicit automatic advance remains valid. @@ -911,6 +900,7 @@ Before switching roles, **MUST first read** the corresponding reference file. Ou | Resource | Path | |----------|------| | Shared technical constraints | `references/shared-standards.md` | +| Native preset shape authoring | `references/native-shape-authoring.md` | | Semantic SVG marker contract | `references/semantic-svg.md` | | Canvas format specification | `references/canvas-formats.md` | | Image-text layout patterns (Primary structures + Modifier layers — combine freely) | `references/image-layout-patterns.md` | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md index 51b03d96..9d08f526 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md @@ -18,7 +18,7 @@ Global artifact ownership rules for PPT Master projects. | `analysis/.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches, SmartArt nodes/connections | Direct PPTX workflows use as native fill/structure contract | | `analysis/image_analysis.csv` | Regenerated image fact view | Measured facts about the current `images/` folder | Re-run `analyze_images.py` before reading image facts after changes | | `design_spec.md` | Human design narrative | Explains design intent, outline, rationale, and resource plan | Strategist writes; humans and later roles read for intent | -| `spec_lock.md` | Execution contract | Literal colors, typography, icons, images, page rhythm, templates, and charts | Executor re-reads before every page; values must be used verbatim | +| `spec_lock.md` | Execution contract | Literal colors, typography, icons, images, page rhythm, input template prototypes, charts, Master roster, and complete page-to-Master/Layout mapping | Strategist writes the initial structured contract; Executor re-reads it before every page and may add a new adaptive Layout identity only while authoring the page that first needs it; `restore-pptx-structure` owns legacy migration | | `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Step 5 writes here; `analysis/image_analysis.csv` derives from current contents | | `icons/` | Project icon inventory | Icons copied by `icon_sync.py` for this project | Executor uses locked project icons; exporter may fall back to global library only as documented | | `templates/` | Project template reference | Step 3 imported specs, template SVGs, and non-image assets | Strategist/Executor read only when Step 3 is triggered | @@ -42,6 +42,9 @@ Global artifact ownership rules for PPT Master projects. | Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `.slide_library.json`) are read per Step 4 / direct-PPTX workflow, not in the `sources/` content scan. | | PPTX structure | `slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. | | Design contract | `design_spec.md` explains; `spec_lock.md` executes. Executor must not infer execution values from prose. | +| Free-design structure authority | Strategist plans the Master roster and complete page-to-Master/Layout mapping before SVG generation. `spec_lock.md` owns that mapping; `svg_output/` owns the matching root identities, atomic Master/Layout visuals, slot groups, bounds, and carriers. Export never infers structure. | +| Template structure authority | `page_layouts` owns the per-page input prototype. `pptx_masters` and `pptx_layouts` own the complete output structure from planning onward; strict keeps the prototype contract, while adaptive may create a new Layout identity during page authoring and updates the lock immediately. Templates validate provenance but never add missing visible objects during export. | +| Legacy structure migration | [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) owns conversion of old unmapped/distilled/preserve SVG projects and legacy template packages into the current structured contract. The exporter does not migrate or visually cluster them. | | Image facts | `images/` is live state; `analysis/image_analysis.csv` is a regenerated view, not a durable cache. | | SVG source | `svg_output/` is the only author source for generated pages. | | Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md index 9ce469ad..2baa15a1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md @@ -4,7 +4,7 @@ **Hard rule — complete page SVG**: Every visible object intended for the exported slide MUST exist in the final page SVG or be explicitly referenced by it. Templates and `spec_lock.md` guide construction; they are not export-time overlays for missing visible content. -**Hard rule — minimal semantics without semantic loss**: `baseline` / free-design roots declare `data-pptx-page-role`; `template` / `preserve` roots already use `data-pptx-layout` and do not duplicate that identity. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. Ordinary page content keeps normal SVG structure without duplicate semantic classification. See [`semantic-svg.md`](./semantic-svg.md). +**Hard rule — structure from the first page draft**: Every new page reads its locked Master/Layout row and declares `data-pptx-master`, `data-pptx-master-name`, `data-pptx-layout`, and `data-pptx-layout-name` on the root. Do not add `data-pptx-layout-kind` or duplicate identity with `data-pptx-page-role`. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. See [`semantic-svg.md`](./semantic-svg.md). **Hard rule — supported PPTX route**: The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter. Step 7.2 still generates `svg_final/` as a mandatory self-contained visual preview that may be inserted as an SVG picture. Do not treat PowerPoint's manual Convert-to-Shape operation as an authoring target or compatibility requirement. @@ -21,7 +21,6 @@ | Source list | Read path | |---|---| | Chosen template's `design_spec.md` (read frontmatter to detect `replication_mode`) | `templates/design_spec.md` | -| Legacy preserve-mode native layout/placeholder contract, when present | `templates/native_structure.json` | | Every distinct `` in `spec_lock.md page_layouts` | `templates/.svg` | | Every distinct chart name in `spec_lock.md page_charts` | `templates/charts/.svg` | | Chart types in `design_spec.md §VII` not covered above | `templates/charts/.svg` | @@ -36,17 +35,16 @@ > Note: batched prefix reads stay in the cached prompt prefix; per-page `spec_lock.md` re-reads append below and benefit from that cache. Scattered on-demand reads of layout/chart SVGs would invalidate downstream cache and sit in the compression-vulnerable mid-context region. -Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative). The legacy page-type table below is a **last-resort fallback** for legacy decks where `page_layouts` is missing. +Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative). There is no filename/page-type fallback. **Resolution order (per page):** 1. **Mirror-mode template** (template's `design_spec.md` frontmatter has `replication_mode: mirror`) → see §1.1 below. The page is consumed as a **visual reference**, not as a placeholder shell. 2. `spec_lock.md page_layouts` has `P: ` for this page → inherit the structure of `templates/.svg` (already in context from §1.0). -3. `pptx_structure.mode: template` but this page has no `page_layouts` entry → stop; the template contract is incomplete. Adaptive mode must still select a reference SVG. -4. `page_layouts` section absent in a legacy project and `templates/` exists → fall back to the page-type table below, matching by SVG filename keyword. Read the matched file at first use if §1.0 did not cover it. -5. No deck/layout template at all → free design. +3. `template_adherence` is present but this page has no `page_layouts` entry → stop; the template contract is incomplete. Adaptive mode must still select a reference SVG. +4. No deck/layout template at all → free design using the complete `pptx_masters` / `pptx_layouts` contract already planned in `spec_lock.md`. -> Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot. +> Note: `page_layouts` disambiguates the multiple content variants a template may ship; missing mappings are contract errors. **Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, palette, **and font sizes** are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim. @@ -78,15 +76,7 @@ When the project's chosen template is a `mirror` template (`design_spec.md` fron **Mirror + chart pages**: chart structures inside a mirror SVG are already drawn (axis, series, labels). Treat them as visual references — replace the data labels and series text content to match the project's chart spec, but do not redraw the chart from a `templates/charts/.svg` baseline. A mirror template's `page_charts` entries are normally absent for this reason. -**Legacy fallback table** (used only when `page_layouts` is absent): - -| Page Type | Corresponding Template | Adherence Rules | -|-----------|----------------------|-----------------| -| Cover | `01_cover.svg` | Inherit background, decorative elements, layout structure; replace placeholder content | -| Chapter | `02_chapter.svg` | Inherit numbering style, title position, decorative elements | -| Content | `03_content.svg` | Inherit header/footer styles; **content area may be freely laid out** | -| Ending | `04_ending.svg` | Inherit background, thank-you message position, contact info layout | -| TOC | `02_toc.svg` | **Optional**: Inherit TOC title, list styles | +**Legacy template boundary**: A template with missing root Master identity, direct atomic placeholders, `data-pptx-layout-kind`, unmapped `baseline`, `preserve`, or `layout_strategy: distill` is not a fallback input. Stop and run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation. ### Page-Template Mapping Declaration (Required Output) @@ -102,25 +92,35 @@ Before generating each page, output which template is used: ### 1.2 PowerPoint Master / Layout Mapping -`page_layouts` selects an SVG design reference; `pptx_layouts` declares the native PowerPoint layout produced at export. They are independent contracts. +`page_layouts` selects an input SVG prototype only on deck/layout template routes. `pptx_masters` and `pptx_layouts` always declare the output PowerPoint structure before the first page is drawn. -`pptx_structure.template_adherence` records the Strategist's confirmed template-use policy. Both `strict` and `adaptive` require one real `page_layouts` reference and one `pptx_layouts` output mapping per page. Strict keeps the referenced Layout contract; adaptive may create a new explicit Layout while retaining the template Master. +**Hard rule — current mode only**: `pptx_structure.mode` is `structured`. Missing mode or legacy values (`baseline`, `template`, `preserve`, `flat`), `layout_strategy`, Layout-kind fields, partial mappings, and old direct placeholders must stop generation and route to [`restore-pptx-structure`](../workflows/restore-pptx-structure.md). -| `pptx_structure.mode` | Executor behavior | -|---|---| -| `baseline` or missing | Free design or brand-only route. Author complete standalone SVG pages with root page-role and only necessary structural hints; export assigns conservative marker-backed Layout families after generation. | -| `template` | Every generated SVG MUST implement the matching `pptx_layouts` row and the explicit structure contract below. | -| `preserve` | Legacy strict-only compatibility. Use the locked source layout key/name and retain marked preview layers for source-package reuse. | +**Hard rule — root identity**: A row `P: | | ` binds the page to a Master listed in `pptx_masters`. Put that Master key/name and Layout key/name on the root SVG. A Layout key belongs to exactly one Master and remains globally unique. -**Hard rule — every structured page references one layout**: In `template` or `preserve` mode, read `P: | ` from `pptx_layouts`, then put both values on the root SVG as `data-pptx-layout` and `data-pptx-layout-name`. Strict copies the selected template key/name. Adaptive may use the new key/name already locked by Strategist; Executor must not improvise another. +**Hard rule — atomic fixed layers**: Every `data-pptx-layer="master|layout"` visual is one direct root child that compiles to one DrawingML object. A marked `` is forbidden. When reconstructing source PPTX groups, recursively push supported transforms, paint, opacity, and z-order into atomic children. Repeat the identical ordered Master atom contract on every page using that Master and the identical ordered Layout atom contract on every page sharing that `(master, layout)` pair. -**Hard rule — PowerPoint paint order**: Direct visual children appear in this order: Master background, Layout background, optional Slide background, shared Master shapes, same-key Layout shapes, then slide-local content/placeholders. Backgrounds are the special plane beneath all inherited shapes. Repeat the same Master contract on every page and the same Layout contract on every page sharing a key. +**Hard rule — PowerPoint paint order**: Direct children appear in this order: Master background atoms, Layout background atoms, optional Slide background, remaining Master atoms, remaining Layout atoms, then slot groups and Slide-local content groups. Backgrounds are the inheritance plane beneath all shapes. -**Placeholder ownership**: Keep actual title/subtitle/body/picture/chart/table/object/media/date/footer/slide-number content on the Slide and add the matching `data-pptx-placeholder`. Do not duplicate placeholder identity with `data-pptx-role`; in particular, `data-pptx-placeholder="slide-number"` already owns page-number field behavior. A reconstructed `title` normally omits `data-pptx-placeholder-idx`; preserve an explicit imported title index when present. Every indexed placeholder on one layout uses a unique index. Chart/table placeholders require native markers and a later `--native-objects` export; object/media placeholders must each resolve to one top-level DrawingML object. +**Mandatory — slot authoring**: A reusable content slot is one direct root `` carrying `data-pptx-placeholder` and positive `data-pptx-placeholder-bounds`. A normal slot contains exactly one compatible direct drawable child marked `data-pptx-placeholder-carrier="true"`. Export unwraps that child into the real Slide placeholder binding. Decorations do not belong in the slot; move reusable decoration to a root Layout atom and keep page-specific labels/captions in another slot or Slide-local group. -**Adaptive Layout creation**: Start from the selected template SVG, keep every Master element identical, and change only the Layout layer/placeholder contract needed for the new composition. Pages sharing the new key repeat the same static Layout elements and placeholder ids/types in the same order. +**Mandatory — slot identity**: Preserve imported `data-pptx-placeholder-idx` values where available; otherwise omit the title index and assign unique indices only where repeated roles need disambiguation. Pages sharing one Layout key repeat the same slot ids/types/effective indices/default bounds/binding modes. Current text, crop, and Slide-local carrier geometry may differ. -**Legacy preserve identity**: In `preserve` mode only, copy source `data-pptx-placeholder-idx` values and retain the source placeholder type/index pairing. Master/Layout preview elements keep `data-pptx-layer` plus `data-pptx-editable="false"`. +**Composite proxy fallback**: A genuinely composite region may use a direct `` with positive bounds. Its visible group remains Slide-local and export creates one hidden transparent matching placeholder proxy. This downgrade is valid only for `object`; do not use it for an ordinary title, body, picture, chart, table, or media slot. + +**Zero-slot Layout**: A Layout may have no slot groups. Covers, posters, and fixed visual pages still declare their named Master/Layout and fixed atoms. Do not manufacture a full-page `object` slot or empty `utility` identity. + +**Mandatory — per-page slot coverage**: On every mapped page, declare a slot for each standard role the page actually has: the page heading as `title`, a cover tagline as `subtitle`, the page number as `slide-number`, running footer text as `footer`, a hero / content image as `picture`, and a body block already authored as one merged text frame as `body`. A page shipping zero slots exports a Layout with no insertable placeholders — valid only for a genuinely fixed composition (see Zero-slot Layout above), never as the deck-wide default. Pages sharing one layout key ship the same slot set. + +**Hard rule — variable slot content**: “Per-page headings never stay Slide-local by default” means authoring them as `title` / `subtitle` slots; it never permits page-varying text or images to become fixed Layout atoms. Any such value that varies across pages sharing one Layout key MUST be carried by a slot or remain Slide-local. + +**Mandatory — master/layout layer coverage**: On every mapped page, mark the deck-wide background and every-page chrome (footer bar, running logo) `data-pptx-layer="master"`, and mark the static framing that defines this layout key's composition (header rule, divider band, zone panels — including chrome repeated on every content page but absent from the cover) `data-pptx-layer="layout"`. A mapped page with zero `data-pptx-layer` marks exports a bare Master and an empty Layout — the layer marks, not the slide content, give each Layout its visible design. + +**Layout identity**: Different keys differ in fixed Layout atoms or slot topology/default bounds/binding modes. Identical contracts should share one key. Current wording, imagery, crop, and Slide-local geometry never define identity. + +**Template adherence**: Strict copies the prototype Master/Layout/slot contract exactly. Adaptive keeps the prototype Master and may change reusable Layout atoms or slots only under a new explicit Layout key/name. When the completed composition genuinely needs that change, update `spec_lock.md pptx_layouts` immediately while authoring the first affected page; later pages may reuse the new key only by repeating its exact contract. Changing only a label is not a new Layout. + +**Layout-content boundary**: Mark only genuinely reusable fixed framing as a Master/Layout atom. Concrete titles, body copy, metrics, chart marks, images, and page-specific groups remain inside slot groups or ordinary Slide-local content groups. The exporter never infers or clusters structure. **Background ownership**: @@ -146,7 +146,7 @@ Before the first SVG page, output a confirmation listing: canvas dimensions, bod **Per-block expression**: render each `design_spec.md §IX Content` block in its written texture — a full-sentence block as wrapped prose, a fragment/label block as bullets/keywords. **Never split a full-sentence block into a bullet list** — splitting loses the information that the block was continuous reasoning, not a set of parallel points; not because a bullet lays out easier, and not because an inherited template slot is shaped as a list. If a block carries no clear texture, infer the mode from its wording and the page layout. -- **Prose render recipe**: one `` per paragraph; wrap lines with sibling `` that reset `x` to the block's left edge and advance `dy` by the font size × a line-height factor. **Default — line-height by density (may override per content fit)**: ~1.4–1.5× for dense / small-body blocks (CLReq comfortable minimum), 1.6–2.0× for large-type, sparse, or `breathing` blocks. Fit about width ÷ font-size CJK glyphs per line (Latin fits roughly twice that); the last line runs short. Use the body ramp size, not a new one. +- **Prose render recipe**: one `` per paragraph; wrap lines with sibling `` where the first line uses `dy="0"` and every subsequent line repeats the parent ``'s **exact `x`** and the **same positive relative `dy`** (the line-height). Equal relative `dy` + matching `x` + the same effective `font-size` lets lines flow inside one PowerPoint paragraph; a font-size change preserves a new paragraph inside the same text frame, while a growing/cumulative `dy`, an irregular gap, or a mismatched `x` (e.g. `x="0"` under ``) may split them into separate single-line boxes. Set the line-height `dy` from the font size × a line-height factor. **Default — line-height by density (may override per content fit)**: ~1.4–1.5× for dense / small-body blocks (CLReq comfortable minimum), 1.6–2.0× for large-type, sparse, or `breathing` blocks. Fit about width ÷ font-size CJK glyphs per line (Latin fits roughly twice that); the last line runs short. Use the body ramp size, not a new one. - **Template precedence**: when an inherited template slot is a bullet list but the §IX block is prose, the prose wins — widen or reflow the container to hold the paragraph, or drop that card; do not pour the sentence back into the list slot. - **Mode precedence**: the locked mode shapes voice / register, not §IX's authored titles or page order. When a `§IX` title is a user-authored topic label, keep it — do not upgrade it to an assertion just because the mode (e.g. `pyramid`) favors them; mode title-tendencies apply only to AI-drafted titles. @@ -188,17 +188,49 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P` Before drawing each page, look up its entry in `page_layouts` to decide which basename to inherit (the SVG itself was loaded in §1.0): - Entry present (e.g., `P04: 03a_content_image_text`) → inherit the corresponding SVG already in context. The basename **must match** an actual file in the chosen template directory. If it does not, stop before drawing and report the invalid mapping; neither `strict` nor `adaptive` may fall back to free design inside a template deck. -- No entry for this page with `template_adherence: strict|adaptive` → stop before drawing and report the missing Strategist mapping. Adaptive mode still selects the closest template SVG as its visual/structural reference; flexibility applies to the new output Layout, not to whether a template reference exists. -- Whole section absent with `pptx_structure.mode: template` → stop before drawing; the current template contract is incomplete. -- Whole section absent only in a legacy project without the current template contract → see §1 fallback (legacy page-type matching). +- No entry for this page with `template_adherence: strict|adaptive` → stop before drawing and report the missing Strategist mapping. Adaptive mode still requires one selected complete template SVG; flexibility applies to the post-design output Layout, not to whether an input prototype exists. +- Whole section absent while `template_adherence` is present → stop before drawing; the current template contract is incomplete. -Do **not** invent a layout entry, and do **not** assume a template just because `templates/` exists. For either template-adherence value, a missing or invalid `page_layouts` row is an upstream contract error. Free design is a separate deck route, never a per-page fallback inside template mode. +Do **not** invent a prototype entry, and do **not** assume a template just because `templates/` exists. For either template-adherence value, a missing or invalid `page_layouts` row is an upstream contract error. Free design is a separate deck route, never a per-page fallback. **Per-page PowerPoint layout lookup — `pptx_layouts` section**: -- `pptx_structure.mode: template` or `preserve` → a `P` row is mandatory; apply §1.2 to the root and direct children. Preserve mode uses the exact source key/name and placeholder indices. -- `pptx_structure.mode: baseline` or missing → omit explicit PPTX layer/layout metadata, but keep the root page role and only necessary structural hints. Baseline export owns conservative Master/background/chrome promotion plus page-role-backed Layout family assignment; filenames and ids are legacy fallbacks only when the corresponding marker is absent. -- A layout key may repeat across non-adjacent pages. Reuse is based on identical static/placeholder contracts, not page proximity or content wording. +- `pptx_structure.mode` must equal `structured`; any other or missing value routes to legacy restoration. +- Read the current page row as ` | | ` and resolve `master_key` in `pptx_masters`. Missing, malformed, or partial mappings stop before drawing. +- Write matching root Master/Layout key and picker names. Do not write `data-pptx-layout-kind` or `data-pptx-page-role`. +- On strict template use, the row and SVG contract match the selected prototype exactly. +- On adaptive template use, retain the prototype Master. If the final composition changes fixed Layout atoms or slot topology/bounds, allocate a new key/name and update this row before completing the page. +- A Layout key may repeat across non-adjacent pages only when its fixed atoms and slot contracts are identical. + +**Structured-page scaffold**: + +```xml + + + + + + + + + + + + + + + + +``` + +Master/Layout atoms and slot groups are direct root children and precede ordinary content groups. Structural metadata nested inside an ordinary content group fails export. **Per-page chart reference — `page_charts` section**: @@ -213,16 +245,55 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha ## 3. Execution Guidelines - **Proximity**: group related elements with tight spacing; separate unrelated groups +- **Element grouping (Mandatory)**: wrap every logical Slide-local content unit — title, core-message line, each content block, card, list item, and diagram — in a top-level `` with a descriptive id. Slot `` elements are already semantic groups and are excluded from the 3–8 ordinary content-group budget. Direct Master/Layout atoms are the required exception to grouping and may never be wrapped in a layer ``. Authored native preset fragments (`preset_shape_svg.py`) already are one atomic `` each and count as one ordinary content group; keep their labels in a sibling parent ``. - **Spec adherence**: follow color, layout, canvas format, and typography in the spec - **Template structure**: if templates exist, inherit the visual framework - **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity - **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time). +- **Default — stage each page with the style's composition geometry (may override when the content genuinely calls for a plain grid)**: an SVG page is a canvas, not a DOM. Before defaulting to stacked rounded-rect cards or uniform equal columns, pick one page-scale move from the locked visual style's §1 `Composition geometry` (a bleed shape, diagonal split, oversized numeral, orbit rings, …) to stage the page's primary zone. Card grids are one option among many, not the house layout. - **Reference — image-led promotional pages (not a constraint)**: for travel, venue, product-introduction, hospitality, event, real-estate, and brochure-style decks, let images define the page skeleton before placing text. Consult [`image-layout-patterns.md`](image-layout-patterns.md) §Imported Deck Patterns and prefer patterns such as `#74` TOC image-navigation cards, `#75` asymmetric chapter banners, `#77` photo mosaic with a text cell, `#78` ambient banner + evidence photo + text panel, `#79` ribbon-header image cards, and `#80` side hero image + staggered evidence cards before falling back to plain left/right image-text splits. - **Phased batch generation** (recommended): - 1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers — and **native object metadata** per §3.2 on every eligible data-chart page. - 2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py ` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations. + 1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers — and **native object metadata** per §3.2 on every eligible data-chart page. **First-page gate (Mandatory)**: after completing the first page, run `python3 scripts/svg_quality_checker.py /svg_output/.svg` and fix every error before drawing page 2 — structural violations are systematic, and a first-page error repeated deck-wide costs a whole-deck rewrite. + 2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py ` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. **PPTX-structure warnings (empty Layout, framing-only Layout, bare Master, duplicate layout keys) are never acknowledge-and-release**: list each one and either fix the page/lock or state per warning why the flagged state is intended (e.g. a zero-slot cover) before proceeding. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations. 3. **Logic Construction Phase**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity. +### 3.0 Native Preset Shape Selection + +**Reach for a native preset whenever one expresses a complete object — this is +the default, not the exception.** Block arrows, chevrons, banners / ribbons, +callouts, flowchart nodes, stars, and other Office symbols should be **authored +as presets** via `preset_shape_svg.py`, not drawn as plain ``s or faked +with rectangles: presets are what give the slide real PowerPoint shapes with +adjustment handles and the designed, non-flat-card look. When a page calls for +one of these, use the preset. Apply the decision gate in +[`native-shape-authoring.md`](./native-shape-authoring.md) to pick the right +shape and to keep only the exceptions below as ordinary SVG. + +| Decision | Action | +|---|---| +| Plain rect / symmetric round rect / circle / ellipse | Keep the ordinary SVG primitive; it is already natively editable. | +| Exact single-preset match | Call `preset_shape_svg.py render` and paste its complete stdout fragment into the current hand-authored SVG. | +| Page-specific, compound, organic, branded, icon, or data geometry | Keep ordinary SVG path/polygon geometry. | +| Similar-looking contour only | Never guess; keep ordinary SVG. | + +This automatic decision applies only before drawing a new object. Do not scan +existing SVG, classify path contours, or upgrade ordinary SVG during export. + +**Hard rule**: do not hand-write `data-pptx-authoring`, `data-pptx-prst`, +`data-pptx-frame`, adjustment, carrier, preview, or fingerprint metadata. The +helper generates them atomically from the shared 187-shape registry. Rerun the +helper when geometry or paint changes. + +Connector-family presets require `--object-kind connector`, `fill="none"`, and +a visible stroke. They export as unconnected `p:cxnSp`; do not hand-add +endpoint/site metadata. `actionButton*` presets provide visual geometry only, +not actions or hyperlinks. + +**Hard rule — narrow helper scope**: the helper prints one shape fragment to +stdout. It does not write a page or choose layout. Read the fragment and insert +it through the normal `apply_patch` page edit; never redirect, loop, or batch it +into `svg_output/`. + ### 3.1 Chart Plot-Area Marker (MANDATORY on every chart page) > The [`verify-charts`](../workflows/verify-charts.md) workflow enumerates chart pages from `design_spec.md §VII`, then reads each page's plot-area marker to feed `svg_position_calculator.py`. Missing marker → verify-charts has to re-derive the plot area from axis lines, paying the cost on every run. @@ -273,7 +344,7 @@ grep "chart-plot-area" /svg_output/.svg **Hard rule**: every data chart whose type appears in the **Supported chart types** list of [shared-standards.md](shared-standards.md) "Native PPTX Table / Chart Markers" (the single authority for the eligible set, marker contract, and JSON schemas) gets `data-pptx-native="chart"` plus a `` JSON child on its top-level ``, transcribing the same data just plotted. Every pure text-grid data table gets `data-pptx-native="table"` the same way, transcribing all visible cell text into `columns` / `rows`. - Chart types absent from that list and conceptual/diagrammatic graphics (process flows, cycles, quadrant cards, timelines, KPI cards) get **no marker** — `svg_quality_checker.py` rejects unsupported marker types. -- Tables with merged, spanning, or graphical cells (icons, harvey balls, rating dots) get **no table marker** — the exporter rejects merged-cell metadata; they stay on the SVG fallback route. +- Canonical rectangular merged text cells may carry a table marker by putting anchor-only `row_span` / `col_span` in metadata and leaving covered cells blank. Nonrectangular/overlapping merges, nonblank covered cells, and graphical cells (icons, harvey balls, rating dots) get **no table marker** and stay on the SVG fallback route. - Transcribe, don't restyle: `categories` / `series[].values` are the numbers just plotted; `style.colors` carries the series HEX values already used on the page (from `spec_lock.colors`). - Data-point color: when a single column/bar series uses data-point colors in the fallback, copy those fills into `series[].point_colors` in category order. - Data labels: when visible point values are part of the fallback chart, write `data_labels` instead of companion text; use `data_labels.points` for selected labels, and use `number_format`, `font_size`, `font_family`, and per-point `colors` / `color` when the fallback labels carry suffixes or color-coded text. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md new file mode 100644 index 00000000..cb5a2474 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md @@ -0,0 +1,152 @@ +> See [`shared-standards.md`](./shared-standards.md) §§1.4–1.5 for the native-shape metadata and validation contracts. + +# Native Preset Shape Authoring Reference + +Use this reference during Executor SVG construction when one standard +PowerPoint shape can express one complete geometric object. The helper does +not create the preset shape's own `p:txBody`; keep visible text outside the +atomic fragment. + +## 1. Selection Gate + +Apply this decision order before drawing a stock geometric object. + +> This gate is for picking the **right** native shape, not for avoiding presets. +> When a page needs an arrow, chevron, callout, banner, flowchart node, or a +> literal Office symbol, authoring it as a preset is the **default** — the +> ordinary-SVG rows below are deliberate exceptions, not the norm. + +| Condition | Action | +|---|---| +| Plain rectangle, symmetric rounded rectangle, circle, or ellipse | Write the ordinary SVG primitive; the exporter already emits an editable native shape. | +| One DrawingML preset exactly expresses the intended object | Run `preset_shape_svg.py render`, then insert its complete stdout fragment into the hand-authored page. | +| The visual meaning or contour exceeds one stock shape | Write ordinary `` / `` geometry; export keeps it as editable custom geometry. | +| The shape only resembles a preset | Keep ordinary SVG; never infer a preset from contour similarity. | +| Mirror/preserve input already owns native-shape metadata | Keep the existing object and metadata; never reselect its preset. | + +**Hard rule**: `preset_shape_svg.py` is the only authoring entry for +`data-pptx-authoring="preset"`. Never add `data-pptx-prst`, frame, adjustment, +carrier, preview, or fingerprint attributes to a hand-written path. + +--- + +## 2. Semantic Preset Candidate Guide + +Use the table below as the **go-to menu**: match the page's visual intent to a +candidate preset *before* defaulting to a plain rect or path. Reaching here +first is exactly how presets get used instead of forgotten. + +"Automatic" means the Executor independently applies this semantic decision +gate before drawing a new object. It does not scan existing SVG, classify +paths or contours, or upgrade ordinary SVG during export. + +| Visual intent | Candidate presets | Boundary | +|---|---|---| +| Literal geometric body | `triangle`, `diamond`, `pentagon`, `hexagon`, `octagon`, `star5` | Use only when the named geometry itself is the intent. | +| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Thin relationship geometry remains an ordinary SVG `` / `` with no attachment semantics. | +| Standard flowchart node | `flowChartProcess`, `flowChartDecision`, `flowChartInputOutput`, `flowChartTerminator`, `flowChartDocument` | Use only for an actual flowchart; ordinary content cards remain cards. | +| Standard connector route | `straightConnector1`, `bentConnector*`, `curvedConnector*` | The SVG route must match the selected preset; otherwise use a free path. | +| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | Brand-specific or custom-tail callouts remain free SVG. | +| Stock ribbon or scroll | `ribbon*`, `ellipseRibbon*`, `verticalScroll`, `horizontalScroll` | Select only when the stock contour is visually acceptable. | +| Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Inline formulas and prose symbols remain text/formula assets. | +| Literal Office symbol | `heart`, `sun`, `moon`, `lightningBolt`, `gear6`, `gear9` | Never replace an icon required by `spec_lock.icons`. | + +Use registry search for a less common literal shape: + +```bash +python3 ${SKILL_DIR}/scripts/preset_shape_svg.py list --search arrow +python3 ${SKILL_DIR}/scripts/preset_shape_svg.py describe rightArrow +``` + +**Forbidden — false native semantics**: + +- `actionButton*` when navigation or trigger behavior is expected; the helper + maps its visual preset geometry only and never creates an action or hyperlink; +- `chartX`, `chartStar`, or `chartPlus` as a substitute for native charts; +- logo, icon glyph, illustration, brand contour, or data-chart marks. + +--- + +## 3. Fragment Generation + +Run one command for one selected object. Colors must come from the current +page's re-read `spec_lock.md`. + +```bash +python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \ + --id p03-growth-arrow \ + --frame 160 210 320 112 \ + --fill "#2563EB" \ + --stroke none \ + --adjust "adj1=val 50000" +``` + +For a native connector: + +```bash +python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render bentConnector3 \ + --id p03-flow-connector \ + --object-kind connector \ + --frame 420 180 220 140 \ + --fill none \ + --stroke "#475569" \ + --stroke-width 2 +``` + +Every connector-family preset requires `--object-kind connector`, `--fill none`, +and a visible stroke. It exports as an unconnected `p:cxnSp`; a connector +preset can never be authored as an ordinary `shape`. + +**Hard rule — stdout-only exception**: the helper prints one deterministic +`` fragment. Read that output and insert it with the normal page +`apply_patch` edit. Do not redirect it into `svg_output/`, loop over pages, +batch shapes, or let it choose layout. The main Agent still authors every +complete SVG page sequentially. + +--- + +## 4. Atomic Fragment Contract + +The helper emits one logical group containing exactly one hidden native carrier +and one complete visible preview. + +| Component | Ownership | +|---|---| +| Logical `` | Stable id, object kind, preset, frame, adjustments, and preview fingerprint. | +| `path[data-pptx-part="geometry"]` | Native PowerPoint export authority; hidden from the browser. | +| `g[data-pptx-part="geometry-preview"]` | Browser-visible preset drawing. | +| `path[data-pptx-part="geometry-detail"]` | One visible layer per DrawingML path, including path-level fill/stroke behavior. | + +**Hard rule**: treat the returned group as atomic. Put labels, icons, or other +decorations beside it in a parent ``, never inside the preset group. + +**Frame coordinate space**: `--frame x y w h` is expressed in the coordinate +space where you insert the fragment. At the page root that is page coordinates; +inside a `` use **group-local** coordinates — the +ancestor transform stacks on top, so page-absolute values would double-offset +the shape off-canvas. + +**Regeneration rule**: rerun the helper when preset, frame, adjustment, fill, +stroke, or stroke width changes. Moving, scaling, rotating, or flipping the +complete logical group is allowed; zero-scale transforms and shear/skew are +forbidden, and the transformed frame must remain inside DrawingML's coordinate +range. Stroke width must remain inside DrawingML's line-width range. To freely +edit the contour, replace the whole fragment with ordinary SVG rather than +modifying a `geometry-detail` path. + +--- + +## 5. Boundaries + +| Concern | Behavior | +|---|---| +| Shape text | Keep visible SVG `` outside the atomic fragment. It remains editable but may export as a grouped text box rather than the preset's own `p:txBody`. | +| Connector attachment | Authoring helper v1 creates an unconnected `p:cxnSp` and does not accept endpoint/site metadata. Do not hand-add it. The imported-shape contract may preserve an attachment that already exists in a source PPTX; creating a new attached connector is currently unsupported. | +| Action button behavior | `actionButton*` presets map visual geometry only. No action, navigation target, or hyperlink is created automatically. | +| Gradient/pattern paint | Authoring helper v1 accepts solid HEX paint only. Use ordinary SVG when a complex paint treatment is essential. | +| Multi-path darken/lighten | Browser preview uses the shared normalized paint behavior from the PPTX importer. | +| External edits | Any preview or semantic mismatch fails quality check and export; regenerate the fragment. | + +**Validation**: `svg_quality_checker.py` independently rerenders every authored +preset from registry metadata and compares the carrier, visible layers, style, +and fingerprint before export. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/semantic-svg.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/semantic-svg.md index e37ebb8f..6a60767e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/semantic-svg.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/semantic-svg.md @@ -1,126 +1,126 @@ # Minimal Semantic SVG Markers -PPT Master uses a small set of rendering-neutral compiler hints only where -ordinary SVG cannot reliably express a required PowerPoint packaging decision. -These markers are not a second content model and do not abbreviate SVG. +PPT Master uses rendering-neutral compiler hints only where ordinary SVG cannot express PowerPoint Master, Layout, placeholder, native-object, or package behavior. ## 1. Boundary | Marker | Placement | Purpose | |---|---|---| -| `data-pptx-page-role` | Root `` | Select the baseline PowerPoint Layout family. Required on newly generated baseline/free-design pages; template/preserve pages already use `data-pptx-layout`. | -| `data-pptx-role` | A structural page-frame element | Identify the few objects whose package or animation behavior is not already expressed by specialized metadata. The element also needs a stable unique `id`. | +| `data-pptx-master` / `data-pptx-master-name` | Root `` | Bind the page to one named PowerPoint Slide Master. | +| `data-pptx-layout` / `data-pptx-layout-name` | Root `` | Bind the page to one named Layout under that Master. | +| `data-pptx-layer="master"` | Direct atomic child of root | Promote one fixed visual object to the named Master. | +| `data-pptx-layer="layout"` | Direct atomic child of root | Promote one fixed visual object to the named Layout. | +| `data-pptx-placeholder` | Direct child `` of root | Declare one reusable Layout slot whose visible content remains Slide-local. | +| `data-pptx-role` | Structural page-frame element | Supply package, page-number, or animation behavior not already expressed by specialized metadata. | -The complete geometry, text, styles, grouping, and asset references remain in -ordinary SVG. Removing these markers must not change browser rendering. Do not -copy visible values into metadata, and do not mark ordinary titles, body text, -cards, KPIs, diagrams, charts, icons, or images merely to describe their -content. +The completed SVG remains the full visible page. Removing the metadata must not change browser rendering. Do not copy visible text, geometry, style, or asset values into metadata. -Use the existing specialized contracts for specialized facts: +**Hard rule — structure exists at design time**: Every newly generated page declares its final Master and Layout before drawing begins. The exporter compiles this contract; it never selects, clusters, distills, or visually infers it. -- `data-pptx-layout` and `data-pptx-layer` own Master/Layout/Slide structure; -- `data-pptx-placeholder` owns PowerPoint placeholder identity; -- `data-pptx-native` owns native chart/table reconstruction. +**Hard rule — specialized metadata wins**: Use Master/Layout/placeholder metadata for native structure, `data-pptx-native` for chart/table reconstruction, and the imported/authored shape metadata defined in [`shared-standards.md`](./shared-standards.md) §§1.4–1.5. Do not duplicate those facts with `data-pptx-role`. -Do not duplicate those facts with `data-pptx-role`. Consumers resolve semantics -in this order: specialized metadata, minimal compiler hints, then legacy -filename/id conventions. +--- -## 2. Canonical Values +## 2. Master and Layout Atoms -### Page roles +Master and fixed Layout visuals are atomic root children: -| Value | Meaning | Baseline Layout | -|---|---|---| -| `cover` | Opening cover | `Cover` | -| `toc` | Agenda or contents page | `Agenda` | -| `section` | Chapter divider or transition | `Section` | -| `content` | Ordinary information page | `Content` | -| `ending` | Closing, thanks, Q&A, or contact page | `Closing` | +```xml + + + + +``` -### Structural roles +| Requirement | Rule | +|---|---| +| Placement | Every Master/Layout atom is a direct child of the root SVG and has a stable unique `id`. | +| Grouping | A `` may not carry `data-pptx-layer="master|layout"`. Imported PowerPoint groups are recursively flattened and their transform/style/opacity/z-order semantics are pushed into atomic children. | +| Atomicity | One marked child must compile to one DrawingML object. A nested crop `` is allowed only when it is the supported single-picture carrier, not an arbitrary container. | +| Consistency | Pages sharing one Master key repeat the identical ordered Master atom contract. Pages sharing one `(master, layout)` pair repeat the identical ordered Layout atom contract. | +| Ownership | Concrete titles, body text, metrics, charts, tables, images, and page-specific decoration stay Slide-local or inside a declared slot. | + +> Note: Flattening a source PPTX group preserves supported appearance and native-layer ownership, but intentionally does not preserve the source group-editing hierarchy. + +--- + +## 3. Layout Slots + +### 3.1 Carrier-bound slot + +Use one direct root group as the authoring boundary and one compatible direct child as the visible PowerPoint placeholder carrier: + +```xml + + Actual title + +``` + +| Requirement | Rule | +|---|---| +| Placement | The slot `` is a direct root child. Structural metadata may not be nested below it. | +| Bounds | `data-pptx-placeholder-bounds="x y width height"` is mandatory, finite, and positive. It describes the reusable design zone, not the current glyph/content tight bounds. | +| Carrier | The group contains exactly one compatible direct drawable child marked `data-pptx-placeholder-carrier="true"`. Export unwraps that child into the real Slide placeholder binding. | +| Identity | `data-pptx-placeholder-idx` is optional; effective indices must be unique within one Layout. Preserve a source index when reconstructing an existing PPTX. | +| Fixed decoration | Reusable decoration does not belong in the slot. Author it as a root Layout atom. Page-specific labels/captions use another slot or remain Slide-local. | + +Canonical placeholder values are `title`, `subtitle`, `body`, `picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, and `slide-number`. Carrier compatibility is defined in [`shared-standards.md`](./shared-standards.md) §7. + +### 3.2 Explicit composite proxy + +When one reusable region is a composite object that cannot bind to one real PowerPoint placeholder, declare the downgrade explicitly: + +```xml + + + Visible composite content + +``` + +The visible group stays Slide-local. Export creates one hidden transparent matching placeholder proxy. Proxy binding is valid only for `object`; it is an explicit fallback, not the default slot form. + +### 3.3 Zero-slot Layout + +A Layout may contain no slot groups. Cover, poster, full-visual, or other fixed-composition pages still declare their Master/Layout root identity and any fixed atoms; do not manufacture a full-page `object` placeholder merely to make the Layout non-empty. + +--- + +## 4. Minimal Structural Roles + +Use `data-pptx-role` only when no specialized marker owns the behavior: | Value | Compiler behavior | |---|---| -| `background` | Treat an otherwise unmarked background as static page framing for animation purposes. | -| `decoration` | Treat decorative page framing as static for animation purposes. | -| `header` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. | -| `footer` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. | -| `logo` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. | -| `watermark` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. | -| `chrome` | Generic repeated page-frame object eligible for conservative promotion. | -| `page-number` | Identify a free-design page-number object; template `data-pptx-placeholder="slide-number"` already owns this behavior. | +| `background` | Treat an otherwise unmarked background as static page framing for animation. | +| `decoration` | Exclude decorative framing from automatic entrance animation. | +| `header`, `footer`, `logo`, `watermark`, `chrome` | Identify Slide-local static framing without claiming Master/Layout ownership. | +| `page-number` | Identify a Slide-local number when no `slide-number` placeholder exists. | -`background` and `decoration` do not by themselves authorize Master/Layout -promotion. The existing background and exact-shared-structure safety checks -continue to own that decision. +Do not add structural roles to ordinary titles, body copy, cards, KPIs, diagrams, charts, icons, or images. -## 3. Examples +--- -### Free-design page +## 5. Validation and Migration -```xml - - +The quality checker rejects: - - - Quarterly growth - +- a missing root Master/Layout identity or a page-to-lock mismatch; +- a Master/Layout ``, nested structure marker, missing/stale id, or inconsistent shared atom contract; +- a slot without positive bounds, a carrier-bound slot without exactly one compatible carrier, or a proxy binding on a non-`object` slot; +- incomplete page mappings, cross-Master Layout-key reuse, or conflicting same-key Layout contracts. - 7 - -``` - -### Reusable template page - -```xml - - - - - - - - - {{PAGE_TITLE}} - - - ACME - - - 7 - -``` - -## 4. Validation and Compatibility - -The quality checker validates marker placement, canonical values, and stable -unique IDs. Baseline export consumes explicit markers before compatibility -heuristics: - -- root page role is preferred over filename-based Layout classification; -- `data-pptx-placeholder="slide-number"` is preferred over a generic role or id; -- explicit structural role is preferred over id-token chrome detection; -- animation target scanning uses the structural role before id-token fallback. - -Filename and id heuristics remain compatibility fallbacks only for older SVGs -that lack the corresponding marker. A canonical page role is authoritative over -the filename. Any explicit structural role prevents id-based reinterpretation; -an unknown role remains renderable but produces a quality-check warning. +Legacy SVGs using unmapped `baseline`, `preserve`, `layout_strategy: distill`, `data-pptx-layout-kind`, `distilled`, `utility`, direct atomic placeholders, or missing Master identity are not a second supported authoring contract. Run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation or export. When original PPTX/native facts exist, migration restores those identities first; otherwise the main Agent explicitly derives a structure from the complete SVG pages. Export never performs that derivation. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards.md index 05fe67b3..3bb33135 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards.md @@ -7,7 +7,7 @@ Other files link here instead of restating its contracts. | Section | Owns | Strength | |---|---|---| -| §1 Required Foundation, Forbidden Features, and Conditional Interfaces | XML validity, the exhaustive structural blacklist, native line ends, image clipping, and static local reuse | Required / Forbidden / Conditional | +| §1 Required Foundation, Forbidden Features, and Conditional Interfaces | XML validity, the exhaustive structural blacklist, native line ends, image clipping, static local reuse, and imported/authored native-shape semantics | Required / Forbidden / Conditional | | §2 Conditional Compatibility Mappings | Inline geometry and approximate group opacity | Conditional | | §3 Canvas Format Quick Reference | Pointer to the complete canvas catalog | Reference | | §4 Required Page Contract and Conditional Packaging | Complete-page authority, semantic markers, editable text/grouping, and package promotion | Required / Conditional | @@ -28,6 +28,8 @@ Other files link here instead of restating its contracts. | Text treatments | Mixed runs, tracking, underline, strikethrough, gradient fill, outline, transparency, watermark text, and text glow | §4.2, §6.7 | | Transforms and composition | Translate, scale, rotate, mirror, supported matrix composition, layering, and static local reuse | §1.3, §6.8 | | Freeform geometry | Full SVG path vocabulary, curves, organic containers, multi-subpaths, and asymmetric rounded rectangles | §6.9 | +| Imported PowerPoint shapes | Lossless import payload, lightweight inspection projection, and selective restoration of preset/custom geometry, connectors, and unchanged native text bodies | §1.4 | +| Authored PowerPoint preset shapes | Registry-generated visible fragments that export as one native preset shape or connector | §1.5; [`native-shape-authoring.md`](./native-shape-authoring.md) | | Radial/chart geometry | Pie/donut arcs, dashed-circle ring segments, gauges, progress rings, sunbursts, and diagonal polygon arrowheads | §6.10 | | Constructed visual styles | Faux glass, hand-drawn marks, ink wash, Riso offset, pixel grid, halftone, isometric facets, paper cut, and line-plus-area data treatment | §6.11 | | Unsupported-effect fallbacks | Raster baking or explicit-geometry alternatives for blur, inner shadow, soft edge, reflection, turbulence, blend modes, and arbitrary masks | §6.12 | @@ -100,6 +102,10 @@ under the conditional contracts below. > > **Static same-document `` is conditional** — see §1.3. > +> **Imported native-shape metadata is conditional** — see §1.4. +> +> **Authored native preset fragments are conditional** — see §1.5. +> > **Inline CSS geometry, group opacity, simple gradients, and filters are > conditional** — see §2 and §6. > @@ -205,6 +211,146 @@ the original `` / `` structure. --- +### 1.4 Imported Native PowerPoint Shapes (Conditional Contract) + +`pptx_to_svg.py` emits rendering-neutral metadata when a visible SVG object +originates from `p:sp`, `p:cxnSp`, or `p:grpSp`. This contract is for lossless +import SVGs and unchanged imported objects that remain Slide-local or inside a +slot during mirror restoration. Ordinary authored SVG does not need these +attributes, and no separate source-payload opt-in marker exists. + +| Metadata | Placement | Required behavior | +|---|---|---| +| `data-pptx-object` | Logical `` and native carrier | `shape`, `connector`, `group`, or `picture`; never infer the object kind from path appearance. | +| `data-pptx-shape-id` + `data-pptx-shape-scope` | Logical `` and carrier | Preserve the source part-scoped identity. Export remaps duplicate Master/Layout/Slide ids into page-unique ids before restoring connector references. | +| `data-pptx-frame="x y width height"` | Logical `` and carrier | Own native `a:xfrm` position and size. Values use sufficient precision for exact EMU recovery; path bounds, stroke, markers, shadows, and text glyph bounds never replace this frame. | +| `data-pptx-prst` | Preset carrier and logical `` | One of the locked 187 DrawingML `ST_ShapeType` values. | +| `data-pptx-av-*` | Preset carrier and logical `` | Preserve the complete validated DrawingML adjustment formula, including non-`val` formulas. | +| `data-pptx-part="geometry"` | One hidden carrier path | The single native export authority for frame, base fill/line/effect, preset/custom geometry, and object identity. | +| `data-pptx-part="geometry-preview"` / `geometry-detail` | Visible preview group/paths | Render the preset's independent path fill/stroke layers. These elements are never emitted as duplicate PowerPoint shapes. | +| `data-pptx-preview-sha256` | Logical preset `` and carrier | Detect edits to visible preset paths or paint. A stale preview fails quality check/export instead of silently restoring old native metadata. | +| `data-pptx-geometry-kind="custom"` + `data-pptx-custgeom` | Custom-geometry carrier | Preserve the validated original `a:custGeom` subtree. If the visible path hash is unchanged, export restores formulas, handles, connection sites, text rectangle, and path list exactly; edited paths compile from current SVG geometry. | +| `data-pptx-start/end-shape-id/site` | Connector logical `` and carrier | Restore `a:stCxn` / `a:endCxn` after scoped shape-id allocation. A connector may retain one zero frame axis; it must not be expanded from visible stroke or marker bounds. | +| `data-pptx-shape-style` | Native carrier | Preserve a relationship-free `p:style` independently of text, including shapes with no visible text. | +| `metadata[data-pptx-part="txbody"]` | Logical shape `` | Preserve unchanged `p:txBody`, including an empty text body. Content, whitespace, positioning, or visible typography edits invalidate the payload and use the normal SVG text fallback. | + +**Import/authoring representation split**: + +| Representation | Contract | +|---|---| +| Lossless import SVG | Keep complete native payload, hidden carriers, and preview evidence in the temporary analysis workspace. This is the round-trip source, not the model-facing authored page. | +| Lightweight authoring projection | Exclude opaque payload and duplicate hidden carriers from model context while retaining visible shape intent and logical ids needed to locate an adopted object in the lossless import. It is not an export source. | +| `standard` / `fidelity` output | Use compact canonical metadata for newly authored shapes; do not transplant opaque import payload or source topology. | +| `mirror` output | Keep supported imported metadata only on unchanged Slide-local/slot objects. Expand fixed Master/Layout group wrappers into direct atoms while preserving source ownership, paint order, and visible appearance. | + +**Hard rule — structural-layer boundary**: An unchanged imported logical object +may keep currently supported metadata while it remains Slide-local or inside a +slot. A logical `` cannot be assigned to Master/Layout because those layers +require direct atoms. Mechanically expand a fixed-layer source group into direct +atoms, rebuilding a preset when supported and otherwise retaining the visible +SVG fallback. Do not use this normalization to change ownership or appearance. + +**Hard rule — selective payload**: Do not copy every imported metadata block into +an authored template. Keep the full lossless import SVG separately as the +audit/fallback source. Mirror may reuse only metadata already supported by the +converter on unchanged Slide-local/slot objects; unsupported or edited objects +use the current SVG fallback. `data-pptx-native` remains reserved for native +chart/table markers. + +**Registry and rendering rules**: + +- The hash-locked shared registry must equal the independent 187-value shape + catalog. Missing, duplicate, unknown, or corrupt definitions fail closed. +- Preset preview paths come from the shared DrawingML formula evaluator; do not + add per-shape Python geometry handlers. +- Preset size is controlled only by `data-pptx-frame` / `a:xfrm`. Adjustment + formulas control the contour inside that frame and are not rescaled when the + frame changes. +- A group transform may move, scale, rotate, or flip the complete logical + shape without invalidating its preview fingerprint. Editing a generated + `geometry-detail` path directly is unsupported unless the carrier metadata + and preview fingerprint are regenerated together. +- Unknown or malformed SVG transform operations fail closed. DrawingML cannot + represent arbitrary shear, so a non-orthogonal transform must stop native + export instead of being silently approximated as rotation and scale. +- Opaque XML payloads containing any `r:*` relationship attribute are never + copied into a new slide part. Relationship-bearing text content and + shape-level `a:blipFill` use the existing rebuilt visual fallback and are + not covered by atomic `p:sp + p:txBody` restoration. +- Unknown future presets and explicit `unsupported` geometry status never + downgrade silently to `rect`; native export stops with the recorded reason. + +**Fidelity boundary**: native preset/custom geometry, logical frame, scoped +identity, connector topology, and relationship-free unchanged horizontal +text-body semantics on ordinary shape fills are `Native-stable`. The SVG +preview paint for gradient/pattern +`darken`/`lighten` layers is `Native-normalized`; original group child +coordinates, shape-level image-fill reconstruction, and vertical-text +reconstruction are also normalized rather than byte-identical OOXML. + +--- + +### 1.5 Authored Native PowerPoint Presets (Conditional Contract) + +New SVG pages may opt one complete geometric object into a native DrawingML +preset through the deterministic fragment helper. Selection behavior lives in +[`native-shape-authoring.md`](./native-shape-authoring.md); this section owns +the machine contract. This is compact canonical authoring metadata: it describes +the intended preset, frame, adjustments, paint, and preview fingerprint without +embedding source OOXML or relying on an imported-payload marker. + +| Metadata / structure | Required behavior | +|---|---| +| `data-pptx-authoring="preset"` | Appears on the logical group and hidden carrier; distinguishes the strict new authoring contract from legacy/imported metadata. | +| `data-pptx-object` | `shape` or `connector`; connector-family presets must use `connector`, and `connector` must use a connector-family preset. Authored connectors require `fill="none"` plus a visible stroke and export as unconnected `p:cxnSp`. | +| `data-pptx-prst`, `data-pptx-frame`, `data-pptx-av-*` | Generated together from the locked registry; group and carrier values must be identical. | +| One direct `path[data-pptx-part="geometry"]` | Hidden native export authority; carries the same semantics and solid paint used for regeneration. | +| One direct `g[data-pptx-part="geometry-preview"]` | Complete browser-visible preview with one ordered detail path per registry path. | +| `data-pptx-preview-sha256` | Identical on group/carrier and equal to an independent registry rerender of metadata, paint, and preview. | + +Generate one fragment at a time: + +```bash +python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \ + --id p03-growth-arrow \ + --frame 160 210 320 112 \ + --fill "#2563EB" \ + --stroke none \ + --adjust "adj1=val 50000" +``` + +**Hard rule — helper-only metadata**: never add or edit authored preset +metadata on a hand-written leaf. The helper output is atomic. Regenerate it +when preset, frame, adjustment, fill, stroke, or stroke width changes. Replace +the whole fragment with ordinary SVG when free contour editing is required. + +**Hard rule — visible page closure**: the helper prints a complete visible +fragment to stdout; export never invents its preview. The main Agent inserts +that output into the hand-authored page. The helper cannot write a project, +select layout, or generate a page. + +**Authoring paint boundary**: v1 accepts `none` or six-digit solid HEX fill and +stroke, optional fill/stroke opacity, stroke width, line cap, and line join. +Colors come from `spec_lock.md`. Use ordinary SVG for gradients, patterns, +filters, or other treatments outside this narrow contract. + +**Validation**: quality check and export both rerender authored fragments from +`preset + frame + adjustments + carrier paint`. They require one carrier, one +preview, equal group/carrier semantics, exact ordered registry paths, and a +matching fingerprint. Preview edits, metadata-only edits, unknown adjustments, +out-of-range frames/transforms, zero-scale transforms, and shear/skew fail +closed. + +**Fidelity boundary**: an unchanged authored fragment is `Native-stable` as +one `p:sp` or `p:cxnSp`. Text remains outside the atomic fragment and may export +as a grouped editable text box. Authoring v1 creates only unconnected +`p:cxnSp`; it does not accept hand-written endpoint/site metadata. An +`actionButton*` preset maps visual geometry only. Preset appearance never +invents connector attachment, action behavior, navigation targets, or +hyperlinks. + +--- + ## 2. Conditional Compatibility Mappings ### 2.1 Literal Inline Geometry @@ -269,17 +415,11 @@ table/chart markers are rejected; omit that flag to export their SVG fallback. ### 4.1 Semantic SVG Marker Contract Semantic markers are minimal compiler hints orthogonal to native SVG semantics. -Existing `data-pptx-layout` / layer / placeholder / native-object metadata is -authoritative and read first. A `baseline` / free-design root declares -`data-pptx-page-role`; template/preserve roots already declare their Layout. -Add `data-pptx-role` only when no specialized marker expresses the required -page-frame behavior; the element also uses a stable unique `id`. Do not classify -ordinary page content or move visible facts out of SVG attributes/text into -metadata. See -[`semantic-svg.md`](semantic-svg.md) for the canonical vocabulary and examples. +Root Master/Layout identity, atomic layer elements, grouped slots, and native-object metadata are authoritative and read first. Every new page carries its final structured contract from the start of SVG authoring. Add `data-pptx-role` only when no specialized marker expresses the required page-frame behavior; the element also uses a stable unique `id`. Do not classify ordinary page content or move visible facts out of SVG attributes/text into metadata. See [`semantic-svg.md`](semantic-svg.md) for the canonical vocabulary and examples. - **Canvas authority**: `viewBox` MUST match the selected canvas dimensions. - Root `width` and `height` are optional and do not override it. + Root `width` and `height` are optional and do not override it. Root `` + `transform` is forbidden; apply transforms to child elements or groups. - **Font portability**: font families used by the deck must resolve to installed export faces. `@font-face` remains forbidden; the typography contract lives in [`strategist.md §g`](strategist.md). @@ -294,13 +434,56 @@ These forms are needed only when the stated PPT behavior matters: | Desired behavior | Required form | |---|---| -| One editable PPT text frame with mixed inline formatting | Put the logical line in one `` and use non-positional `` children. A `tspan` with `x`, `y`, or `dy` starts a new positioned line and is flattened to another text frame. Separate `` elements remain valid when separate frames are intended. | -| Stable object grouping or object-level animation anchor | Wrap the intended object in ``. Raw top-level primitives and anonymous groups remain valid when neither behavior is needed. | +| One editable PPT text frame with mixed inline formatting | Put the logical line in one `` with non-positional `` children. A `` with `x`/`y`/`dy` starts a new positioned line. Evenly `dy`-stacked lines that repeat the parent ``'s `x` stay in one frame: equal effective `font-size` may flow in the current paragraph, while a font-size change, list marker, or accepted larger gap starts a new paragraph. An unmergeable gap or mismatched `x` flattens to separate frames. Separate `` elements stay valid when separate frames are intended. | +| Stable object grouping or object-level animation anchor | Wrap the intended object in ``. Content grouping is **mandatory** per §4.3 — a top-level `` is also the animation anchor; it is not an optional convenience. | | Native PowerPoint background promotion | Use a direct, full-canvas, solid `` without transform, filter, clip, rounding, or visible stroke. Other SVG backgrounds remain ordinary slide shapes. Template routes add the ownership metadata in §7. | -| Free-design page family/chrome extraction | Use the semantic markers in §4.1. Marker-free pages retain conservative filename/id fallbacks, but no visual content is inferred. | +| Reusable free-design PowerPoint Layout | Strategist declares the Master roster and complete page-to-Layout map before drawing. Executor writes the root identities, atomic fixed layers, and grouped slots while authoring each page. Export compiles only those declarations. | +| Reusable template-based PowerPoint Layout | Select one complete input SVG per page in `page_layouts` and declare the output Master/Layout mapping at planning time. Strict preserves the prototype contract; adaptive retains its Master and may assign a new explicit Layout key during page authoring. Non-mirror skin follows `spec_lock`. | **Hard rule — supported shape conversion**: Every PPT editability claim in this specification refers to the project converter reading `svg_output/` and emitting native DrawingML. `svg_final/` is a self-contained visual preview that may be inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is unsupported; do not narrow the authoring contract to its undocumented SVG subset. +### 4.3 Element Grouping (Mandatory) + +Wrap logically related Slide-local elements in top-level `` groups. This is **required on every generated page**, not an optional convenience: it produces real PowerPoint groups in the exported PPTX and gives each content unit a stable animation anchor. Plain `` is the normal grouping primitive; `` additionally maps to the per-descendant alpha approximation in §2.2. Direct atomic Master/Layout elements are the required exception and may never be layer groups. A top-level slot `` is already a semantic group and is excluded from the ordinary content-group budget. + +**Semantic-group rule**: apart from atomic Master/Layout objects and the optional Slide background, direct Slide content uses semantic groups. Aim for **3–8 ordinary top-level content `` groups per slide**; slot groups and structural atoms are excluded. Each ordinary group becomes one entrance step under the chosen animation trigger. Leaving Slide-local titles, body lines, list items, cards, or decorative clusters as ungrouped top-level atoms is a contract violation. + +**Structural atoms and slots are excluded automatically.** `data-pptx-layer` and `data-pptx-placeholder` semantics are read first; otherwise explicit `data-pptx-role` values (`background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, `logo`) mark Slide-local static framing (§4.1, [`semantic-svg.md`](semantic-svg.md)). A normal slot group has exactly one direct compatible carrier; several drawing atoms require the explicit composite `object` proxy fallback. Native chart/table carrier groups retain their specialized §7 contract. + +**What to group** (one `` per unit): + +| Grouping unit | Contains | +|---|---| +| Card / panel | Background rect + optional shadow (only if it floats over a photo/colored panel, §6.4) + icon + title + body text | +| Process step | Number/marker + icon + label + description | +| List item | Bullet / number + icon + title + description | +| Icon-text combo | Icon element + adjacent label | +| Page header | Title + subtitle + accent decoration | +| Page footer | Page number + branding | +| Decorative cluster | Related decorative shapes (rings, dots, orbs) | + +An authored native preset fragment (§1.5) is already an atomic `` and counts as one content group; keep its labels / decorations in a sibling parent ``, never inside the preset group. + +**Forbidden**: + +- One giant `` around the whole slide (collapses to a single animation step). +- Many ungrouped Slide-local `` / `` / `` atoms — fallback animation caps at 8 primitives, dense pages may skip animation, and selection/editing degrades. +- One group per icon / text line / mark (too many steps). +- Anonymous top-level groups — every top-level semantic group needs a descriptive `id`. + +**Naming — required.** A descriptive `id` on every top-level content `` (`card-1`, `step-discover`, `header`, `footer`) is mandatory; it is the animation anchor and the group identity in PPTX. Without it, the exporter falls back to at most 8 top-level primitives or skips animation on dense pages. + +```xml + + + + + 10× + Faster + Reduce production time from days to hours. + +``` + --- ## 5. Workflow Authority @@ -454,7 +637,15 @@ color for glow; black reads as diffuse shadow. | Raised | Primary CTA, focused card, overlay | 6–10 | 10–16 | 0.12–0.20 | | Glow | Short display text, metric, focus accent | 0 offset | 4–8 | 0.35–0.55 | -**Reference — not a constraint**: keep one light direction and at most two +**Strong default — single light source per page**: every `feOffset` shadow on +one slide shares the same `dx`/`dy` direction (default `dx="0"`, `dy="4"`–`dy="8"`, +light from upper front). Contradictory shadow directions read as multiple light +sources — a clear low-quality tell. The one sanctioned exception is a deliberate +upward paper-layer light, where every affected layer flips direction together; +never mix directions on the same plane. This is a strong default, not a +checker-enforced hard rule. + +**Reference — not a constraint**: keep at most two non-floor tiers; two or three shadowed objects usually suffice. Do not lift every peer card or stack strong shadow, border, gradient, and tint on one container. Same-family colored shadow is reserved for a focal accent. On dark @@ -807,10 +998,12 @@ the preset is outside this enum. Native PowerPoint tables and Excel-backed charts activate at export time only. The default chart/table route remains hand-authored SVG geometry so the deck stays pixel-stable across PowerPoint / Keynote / LibreOffice / WPS. -**Authoring — markers are standard on supported data charts and text-grid tables**: Executor writes the marker at draw time on every data chart whose type falls in the supported set and on every pure text-grid data table ([executor-base.md §3.2](executor-base.md)), so any deck can later form native objects without regeneration. Tables with merged or graphical cells stay unmarked on the SVG fallback route. The marker group supplies both: visible SVG fallback children for browser/live-preview rendering, and JSON metadata for `svg_to_pptx` native export. +**Authoring — markers are standard on supported data charts and text-grid tables**: Executor writes the marker at draw time on every data chart whose type falls in the supported set and on every pure text-grid data table ([executor-base.md §3.2](executor-base.md)), so any deck can later form native objects without regeneration. Canonical rectangular merged text cells may use the narrow `row_span` / `col_span` contract below; graphical cells stay unmarked on the SVG fallback route. The marker group supplies both: visible SVG fallback children for browser/live-preview rendering, and JSON metadata for `svg_to_pptx` native export. **Hard rule — activation is the opt-in, dormant unless exported with `--native-objects`**: A marker only declares that a group is eligible for native export. Normal `svg_to_pptx.py` runs keep the fallback SVG children. Pass `--native-objects` only when editability in PowerPoint matters more than cross-renderer layout fidelity: it emits the PowerPoint object and skips the fallback children to avoid duplicates. Native styling preserves the core palette, text, axis, grid, and background colors where possible, but it is still a PowerPoint chart/table object rather than a pixel-identical SVG drawing. +The native route is deliberately editable-first and may be lossy: marker-local labels, callouts, KPIs, guide lines, custom split/bin semantics, or styling that is absent from the payload may disappear or normalize. Export warns about this route-level risk and any narrower issue it can detect. Loss of visual parity is not grounds to remove an active marker that the emitter can otherwise convert; use the default SVG-fallback export when exact authored artwork matters more than editability. + | Marker | Native output | Required metadata | |---|---|---| | `` | `` with `` | bounds + `columns` or `rows` | @@ -831,7 +1024,38 @@ to at least one EMU per resolved row and column. **Validation**: `svg_quality_checker.py` validates native marker kind, JSON metadata, bounds/fallback availability, table rows/columns, supported chart -type, and chart data shape before export. +type, chart data shape, and any imported fallback baseline before export. + +**Hard rule — imported fallback freshness**: active table/chart markers emitted +by `pptx_to_svg.py` carry `data-pptx-fallback-sha256`, a canonical hash of the +marker fallback plus reachable document-level SVG fragment definitions. Editing +geometry/text/paint, switching a local `url(#...)` or `href="#..."` target, +changing a reachable definition, or changing the marker transform makes the +native metadata stale. The mandatory quality checker warns and the default route +keeps the edited SVG; `--native-objects` hard-fails before replacement so it +cannot discard that edit. Metadata/title/description nodes, `data-pptx-*` +runtime attributes, marker-local stable ID renames, and marker-local +`display:none` subtrees are excluded. `visibility:hidden` content, +marker-local unused definitions, and explicitly referenced document-level +target roots (even when hidden) remain conservatively hashed. External +image/font file bytes are not read. +Hashless legacy markers remain native-compatible and warn in the checker/native +route that stale detection is unavailable. A stale hash is an integrity mismatch, +not a visual-parity gate on an unchanged active marker. + +**Hard rule — imported visual/route status**: A PPTX chart with a complete baked preview +may carry `data-pptx-visual-status="source-preview"`. Supported parsed classic +families without a preview use a deterministic readable fallback marked +`data-pptx-visual-status="normalized"`; it is explicitly not source-exact. +When no current renderer exists, the importer emits its typed reconstruction aid with both +`data-pptx-visual-status="placeholder"` and +`data-pptx-route-status="reconstruction-only"`. The valid pair is diagnostic: +quality checking and export warn, default export keeps the placeholder, and +`--native-objects` may reconstruct an editable chart when the same group has a +valid active `data-pptx-native="chart"` payload. The allowed values and pair +remain closed; unknown, whitespace-padded, or contradictory values fail. +`data-pptx-native` and `data-pptx-native-status` are mutually exclusive on the +same visible group. ```xml @@ -861,8 +1085,29 @@ If present, `header_rows` must be an integer from `0` through the resolved row count. Write `strict_grid`, `style.band_row`, and cell `bold` as JSON booleans. Cell objects accept `text`, `fill`, `color`, `align`, `valign`, `bold`, `font_size`, `padding`, `border_color`, and -`border_width`; the same `padding`, `border_color`, and `border_width` keys may -also live under `style` as table defaults. Native table typography mirrors the +`border_width`, plus optional `lang`; the same `padding`, `border_color`, +`border_width`, and `lang` keys may also live under `style` as table defaults. +For multi-paragraph text, replace cell `text` with a non-empty `paragraphs` +list. Each entry is either a string or an object containing optional +`align: "l|ctr|r"` and exactly one of `text` or non-empty `runs`; empty +paragraph strings are preserved, and cell `text` / `paragraphs` are mutually +exclusive. Each run is an object with required string `text` and optional JSON +boolean `bold`, `italic`, `underline`, and `strike`, plus optional `color`, +`font_size`, one-typeface `font_family`, `lang`, and `alt_lang`. Unknown fields, +wrong types, empty run lists, multi-typeface `font_family`, and unsupported +colors fail fast. PPTX import requires exact physical row/grid topology and +normalizes source presentation-only run XML outside this closed schema, but +relationship-bearing text, extensions, structural line breaks, fields, tabs, +bullets, malformed run topology, and unsupported text-body structure remain +fallback-only. +Per-side cell borders use `borders.left|right|top|bottom`, where each value is +either `{ "style": "none" }` or +`{ "style": "solid", "color": "#RRGGBB", "width": }`. +Per-side borders are cell-only; legacy uniform `border_color` / `border_width` +remain supported as defaults that an individual side may override. +When `lang` is absent, export derives `zh-CN` for CJK text and `en-US` +otherwise. `style.band_row: false` disables both `` and +materialized alternating row fills. Native table typography mirrors the visible SVG fallback: put `style.font_family` and `style.font_size` on the marker from the table text already drawn, then use `style.header_font_size` or per-cell `font_size` only when the fallback visibly differs. If the fallback @@ -877,12 +1122,16 @@ fallback `` inside a native table marker does not appear in metadata. For numeric or currency columns, use cell objects with `align: "r"`; SVG `text-anchor="end"` does not carry into the native table. -**Forbidden — native merged table cells**: Do not use `rowSpan`, `colSpan`, -`gridSpan`, `hMerge`, `vMerge`, or top-level merge lists in native table -metadata. `svg_to_pptx.py --native-objects` rejects them so merged-cell tables -do not silently degrade into incorrect grids. Keep merged-cell tables on the -default SVG fallback route, or merge cells manually in PowerPoint after native -export. +**Merged table cells — canonical rectangular contract only**: Put positive JSON +integer `row_span` / `col_span` values on the merge anchor and keep every +covered grid cell blank. Spans must stay within the resolved rectangular grid +and may not overlap. The exporter emits the canonical DrawingML topology +(`rowSpan` on the top edge, `gridSpan` on the left edge, `hMerge` / `vMerge` on +covered cells). CamelCase aliases, raw OOXML merge fields, top-level merge lists, +nonblank covered cells, invalid spans, and overlaps fail fast. The PPTX importer +activates native reconstruction only for that same explicit rectangular topology +with empty merge-slave text bodies; other merge encodings remain fallback-only +with `unsupported-merge-topology`. **Category chart schema**: `column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, and `radar` use `categories` plus @@ -906,6 +1155,16 @@ zero-based `idx` plus optional per-point `position`, `number_format`, **Combo chart schema**: `combo` uses shared `categories` plus either `plots[]` or typed `series[]`. Each plot supports `type: "column" | "line" | "area"`, its own `series`, and optional `axis: "secondary"` for a right-side value axis. +When primary and secondary plots genuinely use different category caches, +`plots[]` may also carry its own `categories` and `category_numeric`; the +workbook writer allocates independent category/value ranges. Typed `series[]` +continues to require the shared top-level categories. +Imported `plots[]` may carry `series_indices` so the verified source identity +where each `c:idx` equals its `c:order` survives when physical plot order differs +from legend order. If one plot supplies it, every plot must supply a same-length +list of unique non-negative JSON integers, and the combined values must form one +contiguous `0..N-1` range. Sources whose `idx` and `order` differ stay +fallback-only; typed `series[]` does not accept this plot-scoped field. Typed `series[]` accepts the same `type` and `axis` fields per series, and adjacent compatible series are grouped into the same PowerPoint plot. Area series may set `fill_opacity` / `fillOpacity` as a `0..1` SVG opacity value @@ -916,7 +1175,35 @@ area plots below columns and lines while preserving the original series indices. Line and area series may set `line_width` / `lineWidth` in SVG px units to match fallback `stroke-width`. -**XY chart schema**: `scatter` and `bubble` use `series[].x` + `series[].y`; `bubble` also requires one `series[].size` / `series[].sizes` value per point. `series[].points` is also accepted as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects. +**Narrow classic-axis schema**: `axes` is a closed object with the roles +`category`, `value`, `secondary_category`, and `secondary_value`. Each role may +set only `kind` (`text`, `date`, or `value`, as appropriate), `position`, +`visible`, `label_position` (`next_to`, `none`, `low`, or `high`), +`number_format`, `minimum`, `maximum`, `major_unit`, `reverse`, and +`major_gridlines`. `major_unit` applies to value axes only. PPTX date-axis +**import** is deliberately narrow: numeric Excel date serials are accepted for +area charts and OHLC stock charts; arbitrary date-axis source families are not. +This contract is not a full `AxisSpec`: logarithmic scales, minor units/gridlines, +crossing values, display units, tick skipping, and other unlisted OOXML semantics +remain unsupported and fail closed on import. + +**Narrow XY-axis schema**: `scatter` and `bubble` may use a closed `axes` object +with only `x` and `y` roles. Both roles have `kind: "value"`; `x.position` +is `bottom` or `top`, while `y.position` is `left` or `right`. Each accepts the +same closed fields above, and `major_unit` is valid on both value axes. PPTX +import requires the plot to reference exactly two mutually cross-linked +`c:valAx` nodes and separately enforces the closed field/topology gates. The +native writer emits and the importer reads back every field in this closed +contract. Scatter import derives the effective `scatter_style` from a uniform +per-series line/marker/smooth state; unsupported or nonuniform states remain +fallback-only. The normalized SVG fallback newly consumes only +`axes.x.major_gridlines` and `axes.y.major_gridlines`; the other fields do not +imply full visual-axis parity. + +**XY chart schema**: `scatter` and `bubble` use `series[].x` + `series[].y`; +`bubble` also requires one `series[].size` / `series[].sizes` value per point. +`series[].points` is also accepted as `[x, y]` / `[x, y, size]` tuples or +`{x, y, size}` objects. **Chart typography**: Metadata sizes use the same px-style unit as SVG text (`1px = 0.75pt`). `style.font_family` and the role-specific @@ -972,6 +1259,10 @@ OOXML RGB. Bar and column series also disable PowerPoint's negative-value inversion so negative bars keep the same series fill instead of turning into white/theme fill. +For ChartEx native charts, valid payload `style.colors` (or root `colors`) +populate the ChartEx color-style part instead of being replaced by a fixed +accent1–accent6 list. Other ChartEx style semantics remain normalized. + **PowerPoint chartEx schema**: `treemap`, `sunburst`, `histogram`, `pareto`, `boxWhisker`, `waterfall`, and `funnel` use Office 2016+ chartEx parts. Use these input shapes: @@ -990,7 +1281,34 @@ these input shapes: **Stock chart schema**: `stock` uses numeric Excel date serials in `categories` or `dates`, plus exactly four series in open / high / low / close order. Use either `series` with four entries, or top-level `open`, `high`, -`low`, and `close` arrays. +`low`, and `close` arrays. PPTX import currently recognizes only canonical OHLC +stock charts with shared numeric date caches, `hiLowLines`, and `upDownBars`. +Safe stock series style may pass the structural gate, but stock series, +`hiLowLines`, and up-down bar local styling can still normalize under the +editable-first contract. HLC, volume, noncanonical structure, and style XML +outside the safe parsing boundary stay fallback-only. + +**PPTX chart-import boundary**: The importer recognizes conservative classic +single-plot charts plus the verified scatter/bubble XY-axis, column/line/area +combo, area date-axis, canonical OHLC stock, radar, safe `of_pie` `serLines`, +axis/title/legend normalization, and bar/column gap/overlap subsets. Imported +`gapWidth` must be one canonical integer in `0..500`; imported `overlap` must be +one canonical integer in `-100..100`. Both values intentionally normalize to +the native writer contract rather than claiming exact source-style retention. +Malformed, duplicate, or out-of-range values fail closed. + +ChartEx import is closed to seven validated data models: `treemap`, `sunburst`, +`histogram`, `pareto`, `box_whisker`, `waterfall`, and `funnel`. The importer +retains their supported hierarchy/category/value/series/subtotal topology for +native read-back. Numeric cache values must be non-empty and finite, and cache +counts/indexes must be canonical non-negative decimal integers with exact, +contiguous topology; malformed, non-numeric, `NaN`, infinite, sparse, duplicate, +or mismatched caches fail closed. ChartEx style, axis, label, and binning details +outside the payload normalize. Full `AxisSpec`, arbitrary ChartEx families or +presentation fidelity, arbitrary stock variants, and axis/combo/date-axis +semantics outside the closed fields above remain fallback-only. The C4/C5 +import work does not expand the normalized SVG renderer and does not reduce +existing SVG-marker-to-native writer support. **Deferred chart types**: Exploded pie / doughnut variants, `map`, `heatmap`, `bullet`, and `gantt` are intentionally outside the current native-object @@ -1023,66 +1341,29 @@ defaults to `bottom` and accepts `top`, `left`, or `right`. **Forbidden — native marker transforms**: Do not rotate, skew, or matrix-transform native table/chart marker groups. Translate / scale is accepted; complex transforms fail export because PowerPoint native table/chart frames do not preserve arbitrary SVG transforms. -### Baseline Layout Family Extraction +### Structured Layout Routing -Native `baseline` export assigns layout families after every SVG page has been -converted. This package-only pass does not change SVG authoring or live preview. +Every new SVG project has one deterministic route: `pptx_structure.mode: structured`. `spec_lock.md` contains a complete Master roster and one page-to-Master/Layout row before the first page is drawn. Free design defaults to one Master. `standard` / `fidelity` templates use their newly authored contract; mirror templates use the restored source identities and parentage expressed through the same explicit interface. -| Root `data-pptx-page-role` | Output layout | -|---|---| -| `cover` | `Cover` | -| `toc` | `Agenda` | -| `section` | `Section` | -| `ending` | `Closing` | -| `content` | `Content` | +**Hard rule — no structure inference**: Export does not assign Layout families, promote repeated chrome, cluster pages, infer placeholders, or repair missing metadata. It compiles only the declared root identities, atomic fixed layers, and slot groups. Legacy or unmapped projects must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) first. -Marker-free legacy SVGs retain conservative filename-token fallback: explicit -cover / agenda / section / closing tokens select those families, and every -other page becomes `Content`. When a valid root marker exists, it is -authoritative even if the filename suggests another family. +**Layout reuse**: Reuse one Layout key only when its ordered fixed Layout atoms and slot ids/types/effective indices/default bounds/binding modes are identical. Different wording, data, imagery, crop, or Slide-local carrier geometry does not create a new Layout. A genuinely different reusable contract gets a new key even when both pages are semantically `content`. -Keep an existing `Cover` assignment when the Master chrome safety pass already -used it to hide promoted Master shapes from a minority page. +**Zero-slot Layout**: A named Layout may contain no slots and no fixed Layout atoms. This is valid for a cover, poster, full-visual page, or other fixed composition. Do not manufacture an empty `utility` kind or full-page fake `object` slot. -**Hard rule — no visual inference**: Keep every actual title, body, picture, -chart, table, and page-specific shape on the Slide. Baseline layouts do not -infer placeholders or promote visually similar content. +**Adaptive change**: Template `strict` preserves the selected prototype contract. `adaptive` retains the prototype Master and may create a new Layout identity only when fixed Layout atoms or slot topology/bounds change. Update the page mapping immediately while authoring the first such page; never mutate a reused key silently. -**Background rule**: Move a Slide `p:bg` to its family Layout only when every -slide in that family carries exactly the same explicit background. Otherwise, -keep each background on its Slide. Preserve whether each family shows or hides -the parent Master shape tree. +### Explicit PPTX Master / Layout / Placeholder Metadata -**Layout chrome rule**: After family assignment, move only the identical -leading prefix of explicitly marked chrome (`logo`, `footer`, `header`, -`watermark`, `chrome`) carried by every family member. Legacy id tokens are -consulted only when `data-pptx-role` is absent. Generated OOXML and -image relationships must match exactly, no animation may target the shapes, -and moving them behind Slide content must preserve z-order. Keep page numbers -and every non-identical object Slide-local. +**Trigger**: Every newly generated SVG page uses this interface. `spec_lock.md` declares `pptx_structure.mode: structured`, a complete `pptx_masters` roster, and one `pptx_layouts` row per page before authoring begins. Template routes additionally keep `page_layouts` as input-prototype provenance. -### Explicit PPTX Master / Layout / Placeholder Metadata (Template Export) +**Project lock**: A Master row is `: `. A page row is `P: | | `. The SVG root values MUST match those rows. A Layout key belongs to exactly one Master and must be globally unique. Reuse one key only when pages share identical ordered Layout atoms and slot ids/types/effective indices/default bounds/binding modes. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows. -**Trigger**: Deck/layout template routes set `spec_lock.md` -`pptx_structure.mode` to `template`; direct diagnostics may pass -`--pptx-structure template`. Both strict and adaptive template adherence use -this mode. Without either trigger, metadata stays visually dormant. +**Template behavior**: Strict preserves the selected prototype's declared Master/Layout/slot contract. Adaptive retains its Master and may allocate a new Layout key/name only when fixed Layout atoms or slot topology/bounds change; update the lock during authoring. Mirror-created prototypes preserve restored source identity, literal paint, typography, effects, atomic geometry, and referenced assets. `standard` / `fidelity` never make source topology authoritative; mirror does not synthesize a replacement topology. -**Project lock**: In the standard project pipeline, template mode requires one -`pptx_layouts` row per page using -`P: | `. The SVG root values MUST -match that row. Strict uses the selected template key/name. Adaptive may create -a new key/name while repeating the same Master contract. Reuse one layout key -only when pages share the same static Layout layer and placeholder contract; -different content is not a reason to create a new layout. Direct diagnostic -exports may select the mode with the CLI flag instead of a locked -`pptx_structure.mode` row, but still require `spec_lock.md` typography -`title` / `body` rows. - -**Master text-style contract**: Template mode requires numeric `title` and -`body` rows under `spec_lock.md` `typography`. Export maps the locked `title` -size to every `a:defRPr` in Master `p:titleStyle`, and maps the locked `body` -size to every level in both `p:bodyStyle` and `p:otherStyle`. +**Master text-style contract**: Structured export maps the +locked `title` size to every `a:defRPr` in Master `p:titleStyle`, and map the +locked `body` size to every level in both `p:bodyStyle` and `p:otherStyle`. | Master style | Locked source | XML field changed | |---|---|---| @@ -1093,11 +1374,10 @@ size to every level in both `p:bodyStyle` and `p:otherStyle`. **Hard rule — narrow scope**: This Master update changes only Master `p:txStyles//a:defRPr@sz`. It does not rewrite direct run sizes on generated slides, so the initial slide rendering remains controlled by the authored SVG. -Missing `title` or `body` rows fail template export. `baseline`, `preserve`, -and `flat` modes do not apply this Master text-style update. +Missing `title` or `body` rows fail structured export. **Layout level-one text-default contract**: For every text-bearing placeholder -whose first prototype run has a direct `a:rPr@sz`, template export copies that +whose first prototype run has a direct `a:rPr@sz`, explicit Layout export copies that size to the generated Layout prompt run and `p:txBody/a:lstStyle/a:lvl1pPr/a:defRPr@sz`. It does not rewrite Slide direct runs or Layout levels 2–9. This preserves the layout-specific size when @@ -1106,24 +1386,25 @@ prototype size remain unchanged. | Metadata | Placement | Behavior | |---|---|---| +| `data-pptx-master="master-default"` | root `` | Binds the slide to one generated Slide Master key | +| `data-pptx-master-name="Default Master"` | root `` | Sets the Master picker/display name | | `data-pptx-layout="content"` | root `` | Binds the slide to one generated reusable layout key | | `data-pptx-layout-name="Title and Content"` | root `` | Sets the PowerPoint layout-picker name; defaults from the layout key | -| `data-pptx-layer="master"` | direct visual child | Moves one repeated static object/background into the slide master | -| `data-pptx-layer="layout"` | direct visual child | Moves one repeated static object/background into the selected layout | +| `data-pptx-layer="master"` | direct atomic visual child | Moves one repeated static object/background into the named Slide Master; `` is forbidden | +| `data-pptx-layer="layout"` | direct atomic visual child | Moves one repeated static object/background into the selected Layout; `` is forbidden | | `data-pptx-layer="slide"` | direct full-canvas solid `` only | Writes a one-page override as Slide `p:bg` | -| `data-pptx-placeholder="..."` | direct visual child | Keeps actual content on the slide and maps it to a generated layout placeholder | -| `data-pptx-placeholder-bounds="x y width height"` | placeholder element | Overrides the reusable placeholder frame in SVG user units | -| `data-pptx-placeholder-idx="1"` | placeholder element | Retains an imported source layout placeholder index; optional for reconstructed layouts | +| `data-pptx-placeholder="..."` | direct slot `` | Declares a reusable Layout slot whose visible content remains Slide-local | +| `data-pptx-placeholder-bounds="x y width height"` | slot `` | Supplies the positive reusable design-zone frame in SVG user units | +| `data-pptx-placeholder-idx="1"` | slot `` | Retains an imported source Layout placeholder index; optional for reconstructed layouts | +| `data-pptx-placeholder-carrier="true"` | one compatible direct child of a normal slot | Binds that visible child as the real Slide placeholder carrier | +| `data-pptx-placeholder-binding="proxy"` | composite `object` slot `` only | Keeps the visible group ordinary and creates one hidden transparent binding proxy | | `data-pptx-editable="false"` | master/layout element or slide background | Declares intentional editing outside ordinary slide content | -**Hard rule — explicit only**: Template export never promotes visually similar -content by inference. Every SVG requires a root `data-pptx-layout`; every -master/layout/placeholder element requires a unique `id` and must be a direct -child of the root SVG. +**Hard rule — explicit only**: Structured export never promotes visually similar content by inference. Every SVG requires the four root Master/Layout identity attributes. Every Master/Layout atom and slot requires a unique stable `id` and is a direct root child. Layouts with zero slots are valid. `data-pptx-layout-kind`, `distilled`, and `utility` are legacy metadata and fail the current contract. **Layer order**: Author the SVG in PowerPoint paint order: Master background, -Layout background, optional Slide background, Master shapes, Layout shapes, -then slide-local content/placeholders. Backgrounds are a special inheritance +Layout background, optional Slide background, remaining Master atoms, remaining Layout atoms, +then slot groups and Slide-local content groups. Backgrounds are a special inheritance plane beneath every shape; this order keeps standalone SVG preview and PowerPoint rendering aligned. The exporter rejects interleaved layers. @@ -1138,105 +1419,70 @@ cover/section/content variants under the same design language. Gradients, images, textures, transformed rects, and visible-stroke rects are not promoted by this solid-background rule. -| Placeholder value | SVG element | PowerPoint placeholder | +| Placeholder value | Direct carrier inside slot `` | PowerPoint placeholder | |---|---|---| -| `title`, `subtitle`, `body` | direct `` | `title`, `subTitle`, `body` | -| `date`, `footer`, `slide-number` | direct `` | `dt`, `ftr`, `sldNum` | -| `picture` | direct `` or imported crop `` | `pic` | -| `chart`, `table` | direct matching `data-pptx-native` marker group | `chart`, `tbl` | -| `object` | one direct text, image, or basic SVG shape | `obj` | -| `media` | direct `` or imported crop `` | `media` | +| `title`, `subtitle`, `body` | one `` | `title`, `subTitle`, `body` | +| `date`, `footer`, `slide-number` | one `` | `dt`, `ftr`, `sldNum` | +| `picture` | one `` or supported imported crop ``, marked as carrier | `pic` | +| `chart`, `table` | one matching `data-pptx-native` marker group, marked as carrier | `chart`, `tbl` | +| `object` | one text, image, or basic SVG shape marked as carrier; alternatively the slot group declares `binding="proxy"` | `obj` | +| `media` | one `` or supported imported crop ``, marked as carrier | `media` | + +**Text slot carrier**: A multiline text placeholder must remain one +native text frame. Use the default paragraph merge; `--no-merge` cannot supply +several line shapes as one +PowerPoint placeholder prototype/binding. Leave strict-line text Slide-local +when separate frames are the required result. `title` is normally type-matched without an index in reconstructed layouts; if an imported source title explicitly has one, preserve that exact index. Every -indexed placeholder on one layout uses a unique OOXML UInt32 index. Template -export writes the semantic type on both the Layout and Slide placeholder -(except `obj`, whose OOXML default is already -`obj`) so PowerPoint and `python-pptx` retain the same identity. Date, footer, -and slide-number placeholders enable their matching Layout `p:hf` flags; a -date placeholder also gets a `datetimeFigureOut` field in the reusable Layout -definition. The current Slide keeps its authored date content. +indexed placeholder on one layout uses a unique OOXML UInt32 index. Structured export writes the semantic type on both the Layout and Slide carrier (except `obj`, whose OOXML default is already `obj`) so PowerPoint and `python-pptx` retain the same identity. A composite object slot instead keeps its visible group ordinary and uses a hidden transparent proxy. +Date, footer, and slide-number placeholders enable their matching Layout `p:hf` +flags; a date placeholder also gets a `datetimeFigureOut` field in the reusable +Layout definition. The current Slide keeps its authored date content. Because an omitted `p:ph@idx` has the effective value `0`, an omitted-index title reserves `0`; no other placeholder on that Layout may use the same effective index. -**Placeholder prototype**: The first slide using a layout key supplies that -layout's placeholder formatting. `data-pptx-placeholder-bounds` supplies the -reusable frame; when omitted, the exporter uses the prototype object's native -DrawingML bounds. Repeat the same placeholder ids/types on every slide using -that layout. Actual slide content and local geometry may differ. +**Slot prototype**: The first slide using a Layout key supplies that Layout's placeholder formatting. `data-pptx-placeholder-bounds` supplies the reusable default frame and is mandatory on every slot. Derive it from +the intended design zone, column, panel inset, safe area, or picture frame — +never from text length, glyph width, line count, or a tight content bounding +box. Repeat the same slot ids/types/effective indices/default bounds/binding modes on every slide using that Layout. The Layout owns the reusable `p:ph`; normal visible carriers keep a matching Slide binding so approved rendering stays identical. A composite `object` proxy adds one hidden transparent binding shape to suppress empty inherited placeholder paint. Bounds define the Layout default only; actual Slide content and local carrier geometry may differ. -**Final-package read-back gate**: After writing the temporary template-mode -PPTX and before publishing it, export reopens the package and verifies that +**Final-package read-back gate**: After writing a temporary structured PPTX and before publishing it, export reopens the package and +verifies that each Slide targets exactly one Layout, one layout key always resolves to the same part, different keys do not collapse onto one part, and every Layout is -registered through its Master and the Presentation. It also verifies the -Layout picker name/content type, placeholder type and effective index, -matching `p:hf` flags, prototype-derived or explicitly overridden frame, -direct prompt size, and level-one default size. Later slides may keep different -slide-local geometry; only the reusable Layout frame is checked against the -explicit/prototype contract. Any mismatch fails export without replacing the -requested output. -This gate applies only to `template`; `baseline`, `preserve`, and `flat` retain -their existing behavior. +registered through its Master and the Presentation. Physical Slide/Layout/ +Master part rosters, their content-type overrides, and their Presentation/ +Master registrations must be exact. It also verifies the Layout picker name, +Master picker identity, placeholder type and effective index, matching `p:hf` flags, explicit design-zone frame, direct prompt size, and level-one default size. +Every owned `p:bg` is checked as an exact zero-or-one payload against the pre- +promotion result; this includes preserving the base Master background when no +authored Master background replaces it. During the same export, every finished +Slide, Layout, and Master must reproduce its exact top-level shape-name roster +and order after packaging. The gate verifies that each carrier-bound slot owns the expected Slide binding, each composite visible carrier remains ordinary, and every composite binding proxy is hidden. A zero-slot Layout must read back with no placeholder. Later slides may keep different Slide-local geometry; only the reusable +Layout frame is checked against the explicit/prototype contract. Any mismatch +fails export without replacing the requested output. **Static structure consistency**: Repeat the same master element ids on every slide and the same layout element ids on every slide sharing a layout. Their generated OOXML must be identical within the affected master/layout group. -Static structure may carry shapes, text, or images; non-image/external -relationships are rejected. A full-canvas first rect/group may be marked as a -master or layout background. +Static structure may carry shapes, text, or images; non-image/external relationships are rejected. Every static object is atomic; a `` is forbidden. A full-canvas first rect may be marked as a Master or Layout background. -**Native object placeholders**: `chart` / `table` placeholders require +**Native object slot carriers**: `chart` / `table` slots require `--native-objects`; fallback groups contain several shapes and cannot map to one PowerPoint placeholder. `object` is the generic PowerPoint content slot and -must still resolve to one top-level DrawingML object. `media` currently binds +uses either one carrier object or the explicit composite proxy downgrade. `media` currently binds an authored image/crop to a native `media` placeholder; it does not synthesize video or audio media from a decorative SVG group. -### Legacy Preserved Source Master / Layout Contract +### Legacy Structure Migration Boundary -**Trigger**: An existing project already ships `native_structure.json` and `source_template.pptx`, has strict template adherence, and sets `pptx_structure.mode: preserve`. Current `create-template` output does not emit this pair; retain this contract only for backward compatibility. +Existing projects or packages that carry `native_structure.json` / `source_template.pptx`, `pptx_structure.mode: baseline|template|preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled` / `utility`, direct atomic placeholders, or no root Master identity must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before generation or export. -| Artifact | Authority | -|---|---| -| `source_template.pptx` | Original master/layout/theme/package parts | -| `native_structure.json` | Stable layout keys, picker names, parent masters, placeholder types/indices, source SHA-256 | -| `pptx_layouts` | Per-generated-page source layout selection | -| SVG metadata | Standalone preview layers and slide-content placeholder binding | - -**Hard rule — source package wins**: Mark source master/layout visuals as direct `data-pptx-layer="master|layout"` preview children. Preserve export removes those generated copies and renders the original source parts. Unmarked content stays slide-local. - -**Placeholder identity**: Keep actual content on the slide. Copy the source placeholder index into `data-pptx-placeholder-idx` when present; the exporter restores the source placeholder type/idx pair. Imported `subTitle`, `obj`, `media`, and `dt` placeholders retain distinct `subtitle`, `object`, `media`, and `date` semantic roles instead of collapsing into body/other. Multiple placeholders with the same semantic role require explicit indices. - -**Multi-master boundary**: Preserve every source master already present in the package. Do not synthesize a new master merely for cover/section differences; rebuilt templates continue to prefer one master plus semantic layouts. - -```xml - - - - - - - Actual page title - - -``` +When original PPTX/native facts exist, migration preserves the reachable source Master roster, Layout parent relationships and picker names, placeholder type/index/bounds, and visible supported geometry while normalizing the package into the explicit contract. Source Master/Layout groups are recursively flattened into atomic SVG elements. The current structured roster cannot materialize a source Layout that no output page references, or a Master reachable only through such Layouts; stop and report those identities instead of silently dropping them or inventing a carrier page. A subsequent `create-template` run treats the result according to its selected mode: `standard` / `fidelity` author a new topology, while mirror keeps the restored source topology only when the source graph satisfies that reachability boundary. When no native facts exist, the main Agent explicitly derives a structured contract from the complete SVG pages; the exporter never performs that derivation. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md index 80bb18e8..a6f9c588 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md @@ -100,13 +100,13 @@ Write the locked value to `spec_lock.md` `- visual_style:` and the rationale to > **Template vs preset**: a style mention may sound like a template name ("academic style" vs the `academic_defense/` template directory). Step 3 only triggers on an explicit template directory path supplied by the user — bare names and style words never copy templates; they map to a visual-style preset here. If a template was triggered upstream, its files are already in `/templates/` and its fused design_spec governs. -**Legacy native contract**: When `/templates/native_structure.json` exists, treat it as backward-compatible `preserve` input only. Current templates declare their PowerPoint structure directly in the SVG roster. +**Legacy template boundary**: A template containing `native_structure.json`, `source_template.pptx`, missing root Master identity, direct atomic placeholders, or old `baseline` / `preserve` / distillation metadata must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before Step 3. Do not plan against a legacy compatibility contract. **Template adherence confirmation**: Surface this closed Stage-1 choice only when Step 3 loaded a `kind: deck` or `kind: layout` template. Omit it for free design and brand-only templates. | Value | Planning and export behavior | |---|---| -| `strict` | Map every page to one template SVG. Keep its explicit Master/Layout/placeholder contract and output layout key unchanged. | +| `strict` | Map every page to one template SVG. Keep its explicit Master/Layout/slot contract and output Layout key unchanged. | | `adaptive` | Map every page to the closest template SVG. Keep the template Master contract, but allow a new explicit Layout key when the selected composition must adapt to the content. | **Default — adaptive (may override when the user requests exact template use)**: Recommend `adaptive` when the user supplied only a template path with no stricter instruction. Preselect `strict` when the user explicitly asks to follow or preserve the template closely; the Stage-1 confirmation still records the final choice. Record the confirmed value in `design_spec.md §I` and as `template_adherence` under `spec_lock.md pptx_structure`. @@ -845,17 +845,20 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck 1. Read reference template: `templates/design_spec_reference.md` 2. Generate complete spec from scratch based on analysis 3. Save to: `projects/.../design_spec.md` -4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. +4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/.../spec_lock.md` — a compact, machine-readable form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** / **pptx_masters** / **pptx_layouts** decisions above. Master/Layout structure is part of this planning contract before the first SVG. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. - **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`. - **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota. - **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.) - **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim. - **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths. - - **pptx_structure is mandatory**: Write `mode: baseline` for free design and brand-only routes. When a deck/layout template is confirmed, write `mode: template` plus `template_adherence: strict|adaptive`. Both adherence values stay on explicit template export. Use `mode: preserve` only when reopening a legacy strict project that already carries a validated `native_structure.json` + `source_template.pptx` pair. Do not choose `flat`; it is a CLI-only diagnostic override. - - **pptx_layouts (template/preserve structure)**: In template mode, write one `P: | ` row for every page. Strict uses the selected template SVG's exact key/name. Adaptive may introduce a new stable key/name, but pages reuse a key only when their static Layout layer and placeholder contract match. In legacy preserve mode, use the exact source key/name. Omit the whole section in baseline mode. - - **page_layouts (deck/layout templates only)**: Add one `P: ` row for every page. Strict inherits that SVG contract unchanged. Adaptive uses it as the architecture reference and may evolve the Layout layer under a new `pptx_layouts` key; it must not drop into free design. Omit the section only for free design and brand-only routes. + - **pptx_structure is mandatory**: Every new project writes `mode: structured`. Free design and brand-only routes default to one Master; add another only when the design genuinely needs it. Deck/layout templates also record `template_adherence: strict|adaptive`. Do not write `baseline`, `template`, `preserve`, `flat`, `layout_strategy`, or Layout-kind rows into a new project. + - **Master roster**: Write one `pptx_masters` row per Master as `: `. For a template, copy the restored prototype roster. For free design, define stable deck-wide background/chrome atoms at planning time; Master visuals are root-level atomic elements and may never be ``. + - **pptx_layouts (native output contract)**: Write exactly one row per page before generation as ` | | `. Plan semantic Layout families from the outline and reuse a key only when fixed Layout atoms plus slot ids/types/indices/bounds/binding modes are identical. Name each key after its composition (`timeline-spine`, `kpi-band-trio`, `content-image-right`), never after a PowerPoint stock role or the page's topic. A Layout may intentionally have zero slots; never manufacture an empty `utility` kind or full-page fake slot. **Self-check before writing the section**: compare distinct keys against the distinct compositions in §IX. Distinct compositions collapsing into a handful of stock-role keys (`cover` / `title-content` / `section`) means pages were clustered by role; one shared skeleton splitting into per-topic keys (`allocation-fengqing` / `allocation-luoping`) means pages were split by topic. Both misstate the roster — re-derive from composition. + - **Slot planning**: Each reusable slot is a direct root `` with `data-pptx-placeholder`, positive design-zone bounds, and exactly one compatible direct carrier. Bounds come from the intended safe area, column, panel inset, or media frame—not sample text ink. A genuinely composite region may use only the explicit `object` + `proxy` downgrade. + - **Adaptive refinement boundary**: The initial mapping is complete, not provisional. If actual adaptive construction genuinely changes reusable framing or slot topology/bounds, Executor creates a new Layout key and updates `spec_lock.md` while authoring that first page. It never mutates a reused contract silently. Export only compiles declared structure and never discovers or clusters Layouts. + - **page_layouts (deck/layout templates only)**: Add one `P: ` row for every page. This is the complete input-prototype mapping. Strict preserves that SVG's structural contract; adaptive keeps its Master and may explicitly assign a new output Layout identity during page authoring. Omit the whole section for free-design and brand-only routes. - **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/.svg`, add `P: `. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section. - - **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible template shell. For a chart page without an exact roster match, adaptive mode selects the closest neutral content template and assigns a new explicit Layout key; strict mode must select an existing compatible Layout or revise the outline. Never omit `page_layouts` inside a deck/layout template route. + - **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible template shell. For a chart page without an exact roster match, adaptive mode selects the closest neutral input prototype and assigns an explicit output Layout key in the planning map; strict mode must select an existing compatible Layout or revise the outline. Never omit `page_layouts` inside a new deck/layout template route. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md index d78725eb..d0f0c2e6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md @@ -4,32 +4,34 @@ ## Core Mission -Generate reusable page templates at the output scope confirmed by `create-template`, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable. +Generate reusable page templates inside the complete workspace selected by `create-template`, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable. -> This is a standalone role: only triggered via the `/create-template` workflow. It may write a global library package or a project-local thin bundle; it is not the template selection step in the main PPT generation pipeline. +> This is a standalone role: only triggered via the `/create-template` workflow. Library and project outputs use one workspace shape; it is not the template selection step in the main PPT generation pipeline. ## Usage - **Trigger**: `/create-template` workflow -- **Output location**: `library` (default) → `skills/ppt-master/templates///`; `project` → the confirmed `/templates/` root with no nested template-ID directory +- **Workspace root**: `library` (default) → `skills/ppt-master/templates///`; `project` → the confirmed `/` +- **Template source**: `/templates/` in both scopes - **Input**: finalized template brief (output scope, target project when project-scoped, template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms) -**Hard rule — scope is execution metadata**: Use `output_scope` and `target_project` to route files, but do not write either field into portable `design_spec.md` frontmatter. Do not create a new PPTX structure mode; deck/layout output still declares `native_structure_mode: template`. +**Hard rule — scope is execution metadata**: Use `output_scope` and `target_project` to route files, but do not write either field into portable `design_spec.md` frontmatter. Do not create a new PPTX structure mode; deck/layout output declares `native_structure_mode: structured`. -**Project-scope precondition**: The workflow has already confirmed an initialized target project, an empty `/templates/`, and collision-free destination filenames in `images/` and `icons/`. Do not begin final writes before that all-at-once preflight passes. +**Workspace precondition**: The workflow has already resolved the selected root, confirmed an empty `/templates/`, and checked collision-free destination filenames in `images/`, `icons/`, and `templates/icons/`. Check `exports/` only when an on-demand review PPTX was requested. Optional directories may be absent until their first real file is written. Project scope additionally requires an initialized target project. Do not begin final writes before that all-at-once preflight passes. When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes: - finalized template brief -- `manifest.json` — single source of truth (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates) +- `manifest.json` — single source of truth for source-deck facts (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates) - `native_structure.json` — stable source master/layout keys, picker names, parent-master relationships, placeholder type/index/geometry, source hash, and source-graph quality facts - `source_template.pptx` — byte-preserved analysis copy for visual/package cross-checking; never a final template asset - `summary.md` — short orientation digest derived from manifest.json - exported `assets/` -- `svg/master_*.svg` / `svg/layout_*.svg` — every master / layout in the deck rendered once as standalone SVG, including ones no sample slide references (template packages often ship more design surfaces than the embedded samples exercise) -- `svg/slide_NN.svg` — each slide's own shapes and slide-local background only; master / layout decoration and backgrounds are **not** inlined here +- `svg/master_*.svg` / `svg/layout_*.svg` — lossless layered import evidence; every master / layout in the deck rendered once, including ones no sample slide references +- `svg/slide_NN.svg` — lossless slide-local import evidence; do not bulk-read because opaque native payload is retained - `svg/inheritance.json` — which layout / master each slide consumes -- `svg-flat/slide_NN.svg` — companion view; each slide is self-contained so you can preview or screenshot a single page without losing the surrounding chrome. Use it as a sanity check for "what would PowerPoint actually show", not as an authoring source — the master/layout chrome will be duplicated across every flat slide. +- `svg-flat/slide_NN.svg` — lossless complete-page import evidence; use it as mirror restoration authority, not as model-facing context +- `authoring-svg/` / `authoring-svg-flat/` — lightweight non-destructive projections created by `svg_authoring_view.py`; these are the model-facing layered and complete-page views - optional screenshots for visual cross-checking PPTX import interpretation: @@ -38,20 +40,38 @@ PPTX import interpretation: - Charts, SmartArt, diagrams, and OLE objects may appear as typed placeholders in layered SVGs. In flat SVGs they may show preview images. Treat them as source intent markers, not reusable decorative assets. - The asset filenames referenced by SVGs are governed by the manifest asset map. Prefer those references over inventing duplicate asset names. -Input priority for PPTX-backed template creation: +Input priority for PPTX-backed template creation depends on replication mode: -1. `manifest.json` for all factual metadata (theme, assets, unique layout/master structure, slide reuse, page-type guidance) -2. `native_structure.json` for source PowerPoint identities, placeholder indices, relationship completeness, and reconstruction facts -3. `svg/master_*.svg` + `svg/layout_*.svg` — the **primary source for the deck's shared visual language**: backgrounds, page chrome, decorative bars, recurring brand motifs. These are what the new template's fixed structure should adopt or reinterpret. Read these before any slide SVG. -4. `svg/inheritance.json` for confirming which slide uses which layout / master -5. exported `assets/` for reusable visual resources -6. `svg/slide_NN.svg` — each slide's unique content, useful for judging composition rhythm and content density (not for fixed structure) -7. `summary.md` only as a fast scan; never as the canonical fact source -8. screenshots / original PPTX only for style verification +| Mode | Authoritative inputs | Model-facing inputs | +|---|---|---| +| `standard` / `fidelity` | Finalized brief for the newly designed output; `manifest.json` for factual canvas/theme/assets | Lightweight `authoring-svg-flat/` pages and exported assets as visual references. Source Master/Layout topology is informational only and is not mined into output structure. | +| `mirror` | `manifest.json`, `native_structure.json`, lossless layered `svg/`, lossless complete-page `svg-flat/`, and `svg/inheritance.json` | Matching lightweight `authoring-svg/` and `authoring-svg-flat/` projections for inspection. The projections never replace the lossless restoration source. | -**Native structure output**: Always set `native_structure_mode: template`. Do not ship `native_structure.json` or `source_template.pptx`. Reconstruct one clean Master plus semantic Layouts through explicit SVG structure metadata. Every page remains a complete standalone SVG preview. +Use `summary.md` only for orientation. Open screenshots or the original PPTX only for visual cross-checking. -**Downstream boundary**: The Strategist confirmation stage selects `strict` or `adaptive` when a project consumes this package. Both export through explicit template mode. Strict keeps the referenced Layout contract; adaptive may create a new Layout while retaining the template Master. Template_Designer does not preselect that project-level choice. +**Native structure output**: Always set `native_structure_mode: structured`. + +| Mode | Output structure contract | +|---|---| +| `standard` / `fidelity` | Author new SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. Use compact canonical metadata for authored objects. | +| `mirror` | Restore the source graph one-to-one: keep source Master/Layout identities and parentage, slide assignments, placeholder type/index/bounds, and supported visual/native-object facts. Mechanical normalization maps fixed-layer source groups into the direct atoms required by the current explicit SVG contract while preserving ownership, paint order, and appearance; it must not semantically redesign the graph. | + +Every page remains a complete standalone SVG preview. + +**Hard rule — mirror graph reachability**: The current structured template +compiler materializes only Master/Layout identities referenced by emitted SVG +prototypes. Before accepting `mirror`, verify that every source Layout has a +non-empty `usedBySlides` set and that every source Master owns at least one such +Layout. If the source package contains an unused Layout or an otherwise +unreachable Master, stop and report that full-graph mirror is not representable +by the current template roster. Never silently drop it, merge it, or invent a +carrier page. + +**Hard rule — no duplicate authored Layout contracts**: In `standard` / `fidelity`, distinct output Layout keys must differ in fixed Layout atoms or slot topology/type/index/bounds/binding. Topic, sample wording, or Slide-local content alone never justifies another authored key. Mirror keeps source Layout identities even when two source contracts are visibly equivalent. + +**Downstream boundary**: The Strategist confirmation stage selects `strict` or `adaptive` when a project consumes this package. Both export through `pptx_structure.mode: structured`. Strict keeps the referenced Layout contract; adaptive may create a new Layout identity while retaining the template Master. Template_Designer does not preselect that project-level choice. + +For `mirror`, `design_spec.md §V` must be followed by a `Source Restoration Map` that records each source slide's Master/Layout assignment and output file. The map is evidence of one-to-one preservation, not a design-decision log. `standard` and `fidelity` record only their newly authored output roster and structure; do not add a source-topology disposition table. --- @@ -62,8 +82,10 @@ The output page set is determined by **replication mode**, declared in the final | Mode | When to use | Roster | |------|-------------|--------| | `standard` (default) | Most templates — clean, reusable, balanced coverage | `01_cover`, `02_chapter`, `03_content`, `04_ending`, optional `02_toc` | -| `fidelity` | User explicitly wants strict replication of a source PPTX, but still wants the AI to clean / cluster / cap variants | Standard roster + one variant per distinct layout cluster found in `manifest.json` | -| `mirror` | Creation-time harvesting mode: every source slide keeps literal visual geometry while gaining explicit Master/Layout metadata | One SVG per source slide, named `_.svg` by source order | +| `fidelity` | User wants a broader, source-aligned but newly designed template | Standard roster + intentionally designed variants that cover useful reference compositions | +| `mirror` | User wants the source template restored | One SVG prototype restored from the lossless source per source slide, named `_.svg` by source order | + +**Hard rule — mode controls authorship**: `standard` and `fidelity` create new SVG documents and their own Master/Layout system. `mirror` restores the imported source contract and must not reauthor, distill, or reinterpret its structure. ### Standard mode @@ -81,7 +103,7 @@ The output page set is determined by **replication mode**, declared in the final ### Fidelity mode -When the brief sets `Replication mode: fidelity`, derive the page roster from `manifest.json` page-type clusters and emit one SVG per distinct visual cluster. +When the brief sets `Replication mode: fidelity`, design a broader reusable roster that stays close to the source's visual language and useful composition examples. The output Master/Layout system is authored independently from source topology. **Variant naming**: append a lowercase letter suffix to the parent type's index, preserving sort order: @@ -95,27 +117,28 @@ Extension page types beyond the canonical four (transition / appendix / disclaim **Roster decision**: -- Cluster slides from `manifest.json` by `pageType` + visual structure (column count, hero-image vs. icon-grid vs. quote, etc.) -- One SVG per cluster — do **not** emit a variant for a cluster represented by a single source slide unless that slide is structurally distinct from existing variants -- One variant per visually distinct cluster — let the source's structural diversity drive the count. Collapse only **near-duplicates** (same column count, same hero element, same content density); do not collapse genuine structural differences just to keep the variant count down. If you find yourself wanting one variant per source slide, that is the signal the user should be in `mirror` mode, not `fidelity` -- Record every emitted page in `design_spec.md §V Page Roster`; in library scope, `register_template.py` generates the corresponding index entry from the directory's actual SVG files. Project scope skips registration +- Choose variants from useful visual composition types such as two-column content, hero image, icon grid, data card, and quote +- Keep only variants that add a genuinely useful authored composition; source Layout keys and repeated source chrome are not clustering inputs +- Design each variant's Master/Layout/slot contract directly from its intended reusable behavior +- Record every emitted page in `design_spec.md §V Page Roster`; in library scope, `register_template.py` generates the corresponding index entry from `/templates/*.svg`. Project scope skips registration > Variants reuse the parent type's placeholder set — see §4 (Placeholder Reference) below. ### Mirror mode -When the brief sets `Replication mode: mirror`, preserve literal page appearance while reconstructing layer ownership: +When the brief sets `Replication mode: mirror`, restore the imported template rather than designing a new one: -- Visual source: `/svg-flat/slide_NN.svg` (the self-contained "what PowerPoint shows" view). Structural source: `svg/master_*.svg`, `svg/layout_*.svg`, and `svg/inheritance.json`. -- Output: `/_.svg`, where `` is the library package directory or the project `templates/` root. `` is the zero-padded source slide index (3 digits) and `` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix. -- Required metadata rewrite: declare the output Layout on the root, mark inherited Master/Layout visuals as direct preview layers, and map source content slots to semantic `data-pptx-placeholder` markers where the imported contract exposes them. Add `data-pptx-role` only to structural page-frame objects whose behavior is not already expressed by those specialized markers. -- Other allowed modifications: rewrite `` paths to local assets and rename assets semantically. Keep geometry, decoration, sprite-sheet wrappers, original example text, chart previews, and fonts visually unchanged. -- Forbidden: simplifying decorative complexity, merging similar slides, or dropping inherited preview chrome. -- `design_spec.md` §V Page Roster lists every emitted file with a one-line content-fit description; SVG metadata owns native reconstruction. +- Restoration source: lossless `svg/master_*.svg`, `svg/layout_*.svg`, `svg/slide_NN.svg`, `svg-flat/slide_NN.svg`, `svg/inheritance.json`, and `native_structure.json`. Use matching lightweight projections only to inspect the result without loading opaque payload into model context. +- Precondition: every source Layout is referenced by at least one source slide, and every source Master is reachable through those referenced Layouts. Otherwise report that full-graph mirror is currently unsupported and stop before writing output. +- Output: `/templates/_.svg` in both scopes. `` is the zero-padded source slide index (3 digits) and `` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix. +- Required restoration: preserve source Master/Layout keys and picker names, Layout-to-Master parentage, slide assignments, placeholder type/index/bounds, supported native-object metadata, geometry, decoration, sprite-sheet wrappers, original example text, chart previews, fonts, effects, and paint order whenever the importer represents them. +- Allowed normalization: add or normalize explicit root declarations and asset paths, and recursively expand fixed Master/Layout group wrappers into direct atoms. The mapping must remain one-to-one at the ownership level and must not change paint order or appearance. +- Forbidden: commonality extraction, semantic synthesis, merging, splitting, promotion, demotion, renaming, re-parenting, decorative simplification, placeholder invention, or replacement of supported source-native metadata / SVG fallback with a model-authored approximation. +- `design_spec.md` §V Page Roster lists every emitted file, and `Source Restoration Map` records the preserved source Master/Layout assignment. **Mirror consumption boundary**: `mirror` applies only while creating the template package from the source deck. Once created, the package is consumed as an ordinary deck / layout template roster: downstream generation may select, repeat, skip, reorder, or adapt pages according to the new content. The `replication_mode: mirror` field must not force the generated deck to preserve the source page count, source order, or one-output-slide-per-template-slide mapping. -**What mirror is not**: a pixel-perfect re-rendering pipeline test. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — the user should be told before generation begins. +**What mirror is not**: a redesign or topology-cleanup mode. It may mechanically transcode the imported representation into the current explicit SVG/package contract, so byte identity is not promised. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — report them before restoration begins. --- @@ -155,7 +178,7 @@ source_canvas_height: 720 source_viewbox: "0 0 1280 720" replication_mode: standard | fidelity | mirror # Required for every deck/layout template. Source packages remain analysis-only. -native_structure_mode: template +native_structure_mode: structured # Optional — only when this template overrides canonical placeholder vocabulary. # Omit only when the page truly exposes no replaceable content slots. # placeholders: @@ -184,7 +207,9 @@ native_structure_mode: template - Optional XML snippet for any reusable component unique to this template ## V. Page Roster -One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). Record the rebuilt Layout key and PowerPoint picker name. For `fidelity` mode, note the cluster source and visual differentiator. For `mirror` mode the roster is the load-bearing content-fit index, so each row must distinguish siblings by column count, hero element, density, and suitable content. Roster entries must match the actual SVG files on disk. +One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). For `standard` / `fidelity`, record the newly authored Layout key and PowerPoint picker name. For `mirror`, record the restored source Master/Layout keys and picker names without redesigning them. Roster entries must match the actual SVG files on disk. + +For `mirror`, add `### Source Restoration Map` immediately after the roster with columns `Source slide`, `Source Master`, `Source Layout`, `Output SVG`, and `Restoration status`. This is a one-to-one mapping record. Do not add synthesis rationale or source-structure disposition rows to `standard` / `fidelity` templates. ## VI. Assets (omit when none) Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage. @@ -198,11 +223,11 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi | Don't write | Source | |---|---| | General SVG technical / compatibility rules | `shared-standards.md` | -| Generic layout pattern library (centered card / 三栏 / timeline / …) | `design_spec_reference.md` §V | +| Generic layout pattern library (centered card / three-column / timeline / …) | `design_spec_reference.md` §V | | Generic spacing bands (margin 40-60px, card gap 20-32px, etc.) | `design_spec_reference.md` §V | | Generic font-size hierarchy (cover 2.5-5x body, page title 1.5-2x, …) | `design_spec_reference.md` §IV | | Canonical placeholder table (`{{TITLE}}`, `{{PAGE_NUM}}`, …) | §4 below | -| Content methodology (pyramid / SCQA / MECE / 金字塔) | `strategist.md` | +| Content methodology (pyramid / SCQA / MECE) | `strategist.md` | | "Usage Instructions" boilerplate (copy template / select page / …) | `create-template.md` | | Created Date / Page Count rows | not a library-level field | @@ -228,66 +253,96 @@ If PPTX import output exists: **Precondition**: -- When PPTX import output is provided, do not generate any template SVG or `design_spec.md` until every file under `/svg/` has been read — including `master_*.svg`, `layout_*.svg`, and every `slide_*.svg` -- Before template generation begins, explicitly report the read slide indexes +- For `standard`, inspect enough lightweight complete-page projections to understand the requested visual direction and reusable assets; do not analyze source topology. +- For `fidelity`, inspect every lightweight complete-page projection so the newly designed roster covers the useful source composition range; do not derive output ownership from source Master/Layout recurrence. +- For `mirror`, verify every projected Master, Layout, and Slide against `native_structure.json` and `svg/inheritance.json`, then restore from the matching lossless files. Before restoration begins, report the verified source slide indexes. -### 2.1 PPTX Import Simplification Rule +### 2.1 PPTX Import Mode Rule -The imported PPTX is a **reference source**, not a direct conversion target. +The imported PPTX has a different authority level in each replication mode. -Do: -- preserve brand assets, recurring backgrounds, and stable structural motifs -- rebuild the layout into a clean SVG structure aligned with PPT Master constraints -- simplify repeated decorative fragments into a smaller number of maintainable SVG elements -- use a background image asset when the original decorative layer is too complex to recreate cleanly -- use cleaned slide SVG references to inspect composition, spacing, text hierarchy, and fixed decorative structure only after factual metadata has been anchored -- read every reference SVG under `svg/` — `master_*.svg`, `layout_*.svg`, and every `slide_*.svg` regardless of slide count. Master / layout files describe the deck's shared visual language (read first); slide files describe per-page content (read after). Partial coverage drops template fidelity. -- rename adopted assets to semantic names (`cover_bg.png`, `brand_emblem.png`) rather than carrying raw `image3.png` into the final template +| Mode | Required behavior | +|---|---| +| `standard` | Use source visuals/assets as references, then author the compact canonical roster and its Master/Layout/slot structure from the confirmed brief. Do not preserve or distill source topology. | +| `fidelity` | Use the complete visual roster as design reference, then author a broader canonical roster and its own Master/Layout/slot structure. Match the source visual language closely, but do not cluster, merge, or split source Layouts into output families. | +| `mirror` | Restore source pages, inheritance, placeholders, native objects, and visuals from the lossless import. Do not simplify, redesign, rename structure, or infer new common structure. | -Do not: -- attempt 1:1 translation of every PowerPoint shape, group, shadow, or decorative fragment -- mirror PPT-specific complexity when it makes the resulting SVG brittle or hard to edit -- introduce dense low-value vector detail that does not materially improve template reuse +**Hard rule — restoration is mechanical**: Mirror may normalize namespaces, +portable asset paths, explicit root declarations, and fixed-layer group wrappers +required by the current compiler. Expanding a source Master/Layout group must +produce direct atoms with the same ownership, transforms, paint order, and +appearance. A maintainability preference is not authority to alter the source +template. + +### 2.2 Native Shape Payload and Authoring Projection + +| Representation | Purpose | Payload rule | +|---|---|---| +| Lossless import SVG | Round-trip and mirror-restoration source | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity. It is authoritative for mirror restoration. | +| Lightweight authoring projection | Model-readable inspection surface | Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and logical ids needed to locate an adopted object in the lossless import | +| `standard` / `fidelity` output | Newly authored contract | Use compact canonical metadata for new shapes. Reuse exported image/vector assets, not opaque source shape payload or source topology. | +| `mirror` output | Restored contract | Keep currently supported imported metadata on unchanged Slide-local/slot objects. Normalize fixed structural layers into direct atoms from the lossless source. The projection never becomes the restoration source. | + +**Validation**: Mirror does not silently use stale metadata or a lightweight +projection. If an imported object cannot use the converter's supported native +metadata after normalization, keep its current SVG fallback and report the +limitation. `standard` / `fidelity` regenerate compact canonical shapes instead +of transplanting opaque source payload. `data-pptx-native` remains reserved for +native chart/table markers. **Explicit template SVG contract**: -| Rebuilt fact | Template SVG declaration | +| Authored/restored fact | Template SVG declaration | |---|---| -| Output semantic layout | Root `data-pptx-layout` + stable `data-pptx-layout-name` | -| Rebuilt master/layout visual | Direct preview child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"` | -| Semantic content slot | Direct content child with `data-pptx-placeholder`; add `data-pptx-placeholder-idx` when same-role slots require stable disambiguation | +| Master/Layout identity | Root `data-pptx-master` / `data-pptx-master-name` plus `data-pptx-layout` / `data-pptx-layout-name`; authored keys for `standard` / `fidelity`, source keys for `mirror` | +| Authored Master/Layout visual | In `standard` / `fidelity`, use a direct atomic child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"`; `` is forbidden for a structural layer | +| Restored Master/Layout visual | In `mirror`, recursively expand each fixed-layer source group into direct atoms with the same Master/Layout ownership, transforms, styles, paint order, and appearance; semantic regrouping is forbidden | +| Content slot | Direct `` with `data-pptx-placeholder` and explicit `data-pptx-placeholder-bounds`; `standard` / `fidelity` author the slot, while `mirror` preserves source type/index/bounds and carrier identity | | Page-only background | Direct full-canvas solid rect with `data-pptx-layer="slide"` | | Structural page-frame hint | Optional `data-pptx-role` only when background/decoration/header/footer/logo/watermark/chrome/page-number behavior is not already expressed by layer/placeholder metadata; stable unique `id` required | -Repeat inherited visuals in every standalone SVG so browser preview remains complete. Template export validates their equality, moves one copy into the generated Master/Layout parts, and removes the repeated Slide copies. Do not flatten inherited visuals into unmarked slide content. +Repeat inherited visuals in every standalone SVG so browser preview remains complete. Template export validates their equality and restores or creates the declared Master/Layout parts. It does not infer ownership. -Use the imported placeholder types verbatim: `title`, `subtitle`, `body`, -`picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, and -`slide-number`. In particular, do not collapse source `subTitle`, `obj`, -`media`, or `dt` placeholders into a generic body marker. A reconstructed -title normally has no index. Assign stable indices only when repeated roles need -disambiguation inside the rebuilt Layout. +**Forbidden — legacy structure contract**: Do not carry `data-pptx-layout-kind`, `distilled`, `utility`, unmapped `baseline`, `preserve`, or direct atomic placeholders into a reusable template package. Route that source through [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) first. + +**Composite slot boundary**: A normal slot group has exactly one compatible direct carrier. Only a genuinely composite region may declare `data-pptx-placeholder="object"` with `data-pptx-placeholder-binding="proxy"`; the visible group stays Slide-local and export creates a hidden transparent binding proxy. Do not use proxy binding as the default template slot form. + +In `mirror`, preserve imported placeholder types, indices, bounds, and carrier +identity exactly when the importer supports them. Do not replace source +`subTitle`, `obj`, `media`, or `dt` roles with generic body content. In +`standard` / `fidelity`, assign the canonical authored types deliberately: +`title`, `subtitle`, `body`, `picture`, `chart`, `table`, `object`, `media`, +`date`, `footer`, and `slide-number`. An authored title normally has no index; +assign stable indices only when repeated roles need disambiguation inside the +new Layout. + +**Hard rule — explicit design-zone bounds**: Every slot carries `data-pptx-placeholder-bounds="x y width height"`. Mirror uses the source Layout placeholder frame. `standard` / `fidelity` author bounds from the intended safe area, column, panel inset, or media frame. Do not use character count, glyph width, current wrapping, or the tight sample-content box. An authored Layout may intentionally have zero slots. ### 3. Placeholder Markers -> Mirror may retain literal example text instead of `{{...}}` authoring markers, but imported semantic content slots still receive native `data-pptx-placeholder` metadata. The rest of this section defines the preferred authoring vocabulary for standard and fidelity modes. +> Mirror retains literal source example text and source placeholder metadata. It does not insert `{{...}}` markers. The rest of this section defines the preferred authoring vocabulary for standard and fidelity modes. Use clear placeholder markers for replaceable content: ```xml - - - {{TITLE}} - + + + + {{TITLE}} + + - - {{CONTENT_AREA}} - + + + {{CONTENT_AREA}} + + ``` ### 4. Placeholder Reference (canonical convention, overridable per template) @@ -334,7 +389,7 @@ Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` fo Variants reuse their parent type's placeholder set by default: every `03*_content*.svg` shares the content placeholder list above, unless the spec frontmatter declares an override for that specific stem. -When rebuilding from imported PPTX references, placeholder insertion takes priority over visual mimicry. If the original layout leaves insufficient room for canonical placeholders, adjust the layout instead of inventing one-off placeholder families — or, if the deviation is intentional and meaningful, declare it in frontmatter. +For `standard` / `fidelity`, canonical placeholder insertion takes priority over visual mimicry; adjust the newly designed layout or declare an intentional vocabulary override. Mirror preserves the source placeholders and literal text instead of inserting canonical authoring markers. --- @@ -342,24 +397,37 @@ When rebuilding from imported PPTX references, placeholder insertion takes prior ### File Save Location -Library scope keeps the existing self-contained package. Standard mode (default): +Both scopes use one complete workspace shape. Only the workspace root differs: + +| Scope | `` | +|---|---| +| `library` | `skills/ppt-master/templates///` | +| `project` | `/` | + +Standard mode (default): ``` -templates/// -├── design_spec.md # Design specification (required) -├── 01_cover.svg -├── 02_chapter.svg -├── 02_toc.svg # Optional -├── 03_content.svg -├── 04_ending.svg -├── icons/ # Extracted vector assets (if any) -└── *.png / *.jpg # Bitmap assets (if any) +/ +├── templates/ +│ ├── design_spec.md +│ ├── 01_cover.svg +│ ├── 02_chapter.svg +│ ├── 02_toc.svg # Optional +│ ├── 03_content.svg +│ ├── 04_ending.svg +│ └── icons/ # Package/validation copy, when used +├── images/ # Optional; omit when unused +│ └── *.png / *.jpg # SVG href is ../images/ +├── icons/ # Optional; omit when unused +│ └── *.svg # Runtime copy, when used +└── exports/ # Optional; created only for on-demand review + └── _template_preview.pptx ``` -Fidelity mode adds variants and extension pages in the same package, e.g.: +Fidelity mode changes only the roster under `templates/`, e.g.: ``` -templates/// +/templates/ ├── design_spec.md ├── 01_cover.svg ├── 02a_chapter_full.svg @@ -369,14 +437,13 @@ templates/// ├── 03b_content_data_card.svg ├── 03c_content_quote.svg ├── 04_ending.svg -├── 05_section_break.svg -└── *.png / *.jpg +└── 05_section_break.svg ``` Mirror mode emits one SVG per source slide, named by source order: ``` -templates/// +/templates/ ├── design_spec.md ├── 001_cover.svg ├── 002_toc.svg @@ -386,41 +453,24 @@ templates/// ├── 006_content.svg ├── ... ├── 049_content.svg -├── 050_ending.svg -└── *.png / *.jpg +└── 050_ending.svg ``` -Filenames preserve the source slide order via the 3-digit prefix; `` is derived from `manifest.json` `pageTypeCandidates`. Literal source text may remain, but every page still carries explicit native structure metadata. +Filenames preserve the source slide order via the 3-digit prefix; `` is derived from `manifest.json` `pageTypeCandidates`. Literal source text and the source native structure are restored; the lightweight projection is not copied into the output. -Project scope writes directly into an initialized project's existing roots. The roster names still follow the selected replication mode, but the package is not nested under ``: - -``` -/ -├── templates/ -│ ├── design_spec.md -│ ├── 01_cover.svg -│ ├── 02_chapter.svg -│ ├── 03_content.svg -│ ├── 04_ending.svg -│ ├── ... # Fidelity variants or mirror pages -│ └── icons/ # Package/validation copy -├── images/ -│ └── *.png / *.jpg # Bitmap assets; template SVG href is ../images/ -└── icons/ - └── *.svg # Identical runtime copy of extracted icons -``` - -**Hard rule — project routing**: Keep `design_spec.md`, template SVGs, and non-bitmap package assets in `templates/`; place every bitmap in `images/`; duplicate each extracted icon into `templates/icons/` and runtime `icons/`. Do not write bitmaps into the project template root and do not create `/templates//`. This output belongs only to the target project; use library scope for a self-contained cross-project package. +**Hard rule — common routing**: Keep `design_spec.md`, template SVGs, and non-bitmap template-source assets in `templates/`; place every bitmap in `images/`; duplicate each extracted icon into `templates/icons/` and runtime `icons/`. Write a review deck to `exports/` only when explicitly requested. Create optional directories only when they contain real files; never add placeholders for empty directories. Do not branch asset placement by output scope. ### Template Preview -After each template is generated, provide a brief summary table listing each template's status. +When the user requests a PowerPoint review file, run `template_preview_pptx.py ` after SVG validation. The command creates `exports/` on demand and verifies one slide per SVG prototype plus the expected Master/Layout counts. The first export refuses a collision; an intentional post-fix replacement uses `--force`. The review PPTX is derived evidence and never a template-application input. + +When a review deck was generated, include its path in the completion summary. Otherwise omit `exports/` from the workspace inventory. If the template is based on PPTX import output, briefly note: - which extracted assets were reused directly -- which complex original decorations were intentionally simplified -- whether any page-type mapping required judgment beyond the import heuristic -- how the source Master/Layout graph was consolidated into the rebuilt Master and semantic Layout roster +- for `standard` / `fidelity`, which visual references influenced the newly authored roster +- for `mirror`, whether any source feature could not be restored and the exact affected source object/page +- whether any page-type filename mapping required judgment beyond the import heuristic --- @@ -428,13 +478,13 @@ If the template is based on PPTX import output, briefly note: If suitable template resources already exist, use them directly instead of generating new ones: -1. **Copy template**: copy the spec + explicitly layered template SVGs into the project's `templates/`, and any bundled bitmaps into the project's `images/` (the runtime image pool, referenced as `../images/`). +1. **Copy template workspace**: copy or stage `templates/` plus any existing `images/` and `icons/`; exclude `exports/` from template application. 2. **Adjust colors**: Modify colors per the project design spec 3. **Customize**: Make project-specific adjustments -This section describes downstream reuse of an existing library package. The `Template_Designer` role may instead create the same contract directly in project scope when `create-template` selected that output. +This section describes downstream reuse of an existing workspace. Library and project scopes carry the same portable template contract. -**Example library structure** (query the appropriate kind's index — `templates/layouts/layouts_index.json` for structure-only templates, `templates/decks/decks_index.json` for full-PPT replicas, `templates/brands/brands_index.json` for identity-only presets): +**Example library structure** (query the appropriate kind's index — `templates/layouts/layouts_index.json` for structure-only templates, `templates/decks/decks_index.json` for complete identity + structure templates, `templates/brands/brands_index.json` for identity-only presets): ``` templates/ @@ -445,8 +495,8 @@ templates/ │ ├── academic_defense/ # Academic-defense structure (no identity) │ └── pixel_retro/ # Pixel retro / cyberpunk structure (no identity) └── decks/ - ├── 招商银行/ # China Merchants Bank full PPT replica - └── 中国电建/ # PowerChina full PPT replica + ├── / # Example banking deck + └── / # Example engineering deck ``` --- @@ -457,13 +507,16 @@ templates/ ## Template_Designer Phase Complete - [x] Read `references/template-designer.md` -- [x] Output scope confirmed: `library` | `project`; project-scope preflight passed before final writes +- [x] Output scope confirmed: `library` | `project`; the common workspace preflight passed before final writes - [x] Replication mode confirmed: `standard` | `fidelity` | `mirror` -- [x] Every page listed in `design_spec.md §V Page Roster` saved to the selected template target (library package directory or project `templates/` root) +- [x] Every page listed in `design_spec.md §V Page Roster` saved to `/templates/` - [x] Naming convention applied (standard / fidelity: letter-suffix variants; mirror: `_.svg`) - [x] Templates follow design spec (colors, fonts, layout) -- [x] Placeholder markers are clear and standardized; mirror may keep literal text but still maps imported semantic slots -- [x] Every SVG is a complete preview with explicit Master/Layout/Slide/placeholder metadata and `native_structure_mode: template` -- [x] Project scope, when selected: bitmaps routed to `images/`; extracted icons copied to both `templates/icons/` and runtime `icons/` -- [ ] **Next step**: Validate assets; register only library scope, otherwise hand the in-place project bundle to main Step 3 +- [x] `standard` / `fidelity` SVGs and Master/Layout contracts were newly authored; `mirror` SVGs restore the lossless source graph without semantic redesign +- [x] Placeholder markers are clear and standardized for `standard` / `fidelity`; mirror preserves literal source text plus source placeholder type/index/bounds +- [x] Every SVG is a complete preview with explicit root Master/Layout identity and `native_structure_mode: structured`; authored modes use canonical fixed layers/slots, while mirror preserves source ownership and mechanically expands fixed-layer groups into direct atoms +- [x] Authored `standard` / `fidelity` Layout keys are non-duplicative; mirror keeps distinct source Layout identities even when their current visible contracts are equivalent +- [x] Model context used lightweight projections; lossless imports remained authoritative for mirror restoration, while `standard` / `fidelity` used compact canonical authored metadata +- [x] Both scopes route bitmaps to `images/` and copy extracted icons to both `templates/icons/` and runtime `icons/` +- [ ] **Next step**: Validate assets, optionally export a review PPTX, then register only library scope ``` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/_index.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/_index.md index 80856d63..c3e45187 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/_index.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/_index.md @@ -92,7 +92,7 @@ Each style has its own file with: shape & decoration, typography character, colo ## 3. Escape hatch — `custom` -When no preset captures the intended aesthetic, set `- visual_style: custom` in `spec_lock.md` and add a `- visual_style_behavior:` line: one paragraph naming shape language, decoration density, whitespace, typographic character, and texture — **no HEX, no color names as values**. `custom` is a tail-case, not a default; reach for a preset first. +When no preset captures the intended aesthetic, set `- visual_style: custom` in `spec_lock.md` and add a `- visual_style_behavior:` line: one paragraph naming shape language, composition geometry (page-scale moves), decoration density, whitespace, typographic character, and texture — **no HEX, no color names as values**. `custom` is a tail-case, not a default; reach for a preset first. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/blueprint.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/blueprint.md index 5e80ea4a..f4f7cb8b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/blueprint.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/blueprint.md @@ -7,6 +7,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj ## 1. Shape & decoration - Shape language: thin single-weight line frames (no heavy fills); components drawn as outlined geometry; optional isometric / 3D-axonometric projection for structures. Slight or zero corner rounding. +- Composition geometry: let the schematic be the layout — a full-page annotated drawing as the skeleton with content hung on leader lines; a circular detail blow-up linked to its source; dimension-line brackets framing a hero figure; an isometric exploded stack for structure or sequence; a drawing-sheet title block anchoring one corner. - Decoration: the engineering-drawing vocabulary — dimension lines, leader arrows, component codes, coordinate labels, a faint gridline backdrop under everything. Annotation *is* the decoration. - Whitespace: the grid breathes through; let line work float on the dark field with measured spacing. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/brutalist.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/brutalist.md index 44ed7c35..369705d9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/brutalist.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/brutalist.md @@ -7,6 +7,7 @@ Brutalist editorial newspaper. Wall-to-wall small type, irregular column widths, ## 1. Shape & decoration - Shape language: hard rectangles and ruled boxes; thick black borders / cell frames; visible column dividers. Corner radius `rx="0"` — never rounded. +- Composition geometry: a masthead numeral or headline set so large it crosses column rules; one grid cell inverted to solid ink (light type on dark) as the focal cell; full-bleed rule bars slicing sections; a single rotated stamp-box breaking the grid at one deliberate point. - Decoration: the grid itself is the decoration — masthead bars, rule lines, boxed pull-quotes, halftone fills. No gradients, no soft cards, no shadows. - Whitespace: tight and deliberate — narrow margins, dense columns, a newspaper's packed rhythm. Density is the point; one or two breathing zones per page keep it readable, not airy. - Irregular multi-column layout (mixed column widths) over a uniform grid; asymmetry is intentional. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/chalkboard.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/chalkboard.md index dba8deee..2c14fa6f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/chalkboard.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/chalkboard.md @@ -7,6 +7,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder ## 1. Shape & decoration - Shape language: chalk-stroke line work with slightly diffused, dry-medium edges; sketched boxes, brackets, arrows in chalk. Confident but never mechanical — the sketched boxes and arrows are `` with non-aligned points; a primitive `` / `` snaps the chalk back to mechanical. +- Composition geometry: one big chalk ring around the term that matters; a radial mind-map spreading from a central concept; a hand-drawn arc timeline sweeping the board; an oversized chalk bracket grouping half the page; a boxed corner note holding the takeaway. - Decoration: underlines and emphasis marks; a few sprinkled chalk stars / dots. Blackboard pedagogy — organized sections, a clear central focus. - Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/dark-tech.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/dark-tech.md index 09f7a677..29567d11 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/dark-tech.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/dark-tech.md @@ -7,6 +7,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat ## 1. Shape & decoration - Shape language: crisp geometry; thin glowing rules; hexagon / circuit / grid motifs used sparingly. Slight rounding (`rx` 4-8) or sharp. +- Composition geometry: a glowing diagonal circuit trace splitting the field; concentric orbit rings staging a central metric; a hexagon node cluster mapping components; an oversized low-opacity numeral or glyph floating behind the content layer; thin bracket frames zoning the page. - Decoration: glow accents, fine grid backgrounds, monospace labels, node / connector lines. Restrained — precision over clutter. - Whitespace: dark negative space reads as depth; let elements float on it. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/data-journalism.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/data-journalism.md index 7d883be1..18800415 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/data-journalism.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/data-journalism.md @@ -7,6 +7,7 @@ Bloomberg / Economist news-infographic — publication-grade information density ## 1. Shape & decoration - Shape language: a dense multi-column grid carrying many small charts and data tables inline; editorial sidebars and pull-stats; hairline dividers; hero numbers; a running source / footnote line. Information density is the look — kept legible by a rigorous grid. +- Composition geometry: the chart as the page's spine — layout wraps the visualization instead of boxing it; a hero number at full column scale; a full-width spanner rule dropping into a stat band; a sidebar cut into the grid; small-multiple strips instead of one boxed chart. - Decoration: minimal beyond the data — a single accent rule, sparing annotation. Charts and numbers are the visual interest, not ornament. - Whitespace: tight but structured; the grid earns density without clutter. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/editorial.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/editorial.md index 2b0bf263..c3edb5f9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/editorial.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/editorial.md @@ -7,6 +7,7 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str ## 1. Shape & decoration - Shape language: rectilinear; thin rules and column dividers instead of cards. Minimal rounding (`rx` 0-4). +- Composition geometry: an oversized drop cap or numeral anchoring the page; a pull quote breaking across two columns; a full-height vertical rule the content hangs from; an asymmetric 2:1 column split instead of even columns; one figure deliberately crossing a column edge. - Decoration: hairline rules, kickers / eyebrows, pull quotes, drop-style emphasis — typographic, not graphic. Sparing. - Whitespace: structured by columns and baseline rhythm; comfortable but information-rich. - Multi-column text flow where content suits. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md index 40d94192..0d035548 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md @@ -7,6 +7,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float ## 1. Shape & decoration - Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20). +- Composition geometry: one hero glass panel set off-axis over a radial bloom; overlapping translucent discs building the focal cluster; a large glass ring encircling the key metric; panels stepped in depth to imply sequence; a diagonal light beam crossing the dark field. - Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. Realize the radial bloom / glow halo as a `` / `` with a `` fill, never a `rect rx=w/2` standing in for it. - Whitespace: dark negative space reads as depth; let panels float on it with room to breathe. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-notes.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-notes.md index 7f643569..8b3a2b19 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-notes.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-notes.md @@ -7,6 +7,7 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work, ## 1. Shape & decoration - Shape language: hand-drawn line work with slight, intentional wobble — boxes, arrows, dividers and brackets sketched as if on a thoughtful whiteboard; never mechanically straight — realize it as `` / `` with off-grid points, not `` / `` primitives. Line defines structure; no filled cards. +- Composition geometry: a circled central concept with branch arrows carrying the page; a hand-drawn Venn or overlap sketch as the argument; strike-through-and-replace geometry for before/after; one oversized bracket grouping the evidence under the claim. - Decoration: minimal — a few doodle marks (stars, dashes, dots, underlines) for emphasis. Restraint is the look; clutter breaks the "considered" feel. - Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-wash.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-wash.md index 3529540d..d0bfa9e5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-wash.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/ink-wash.md @@ -7,6 +7,7 @@ New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restraine ## 1. Shape & decoration - Shape language: minimal brush-stroke marks and hairline dividers; the occasional ink-dark block; a single seal-stamp (印章) square as a focal accent. No cards, no boxes — emptiness is the structure. The brush-stroke and ink-bleed marks are irregular `` shapes with uneven control points — never an `` / `` standing in for a wash, which reads as fake ink. (The seal-stamp square is the one deliberate hard edge.) +- Composition geometry: one broad brush sweep as the page's diagonal spine; an open ensō-like ring framing the core phrase; a vertical scroll band ordering a sequence; a low wash horizon with content floating in the emptiness above; the seal square placed to counterweight an asymmetric field. - Decoration: almost none; what little appears reads as brush and seal. Asymmetric, scroll-like composition with deliberate off-balance. - Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/memphis.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/memphis.md index 6711cbab..3a66789e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/memphis.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/memphis.md @@ -7,6 +7,7 @@ Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s- ## 1. Shape & decoration - Shape language: bold geometric primitives — circles, triangles, zigzags, squiggles, blobs — with thick dark outlines (2-4px). Mixed corner radii allowed; playful inconsistency is on-brand. +- Composition geometry: a giant primitive bleeding off one edge as the backdrop; a diagonal two-color field split; a zigzag or squiggle band as the section divider; props scattered at angles around one dominant rotated frame or badge at the focal point. - Decoration: scattered geometric confetti, color-block backings, pattern fills (dots / stripes), oversized punctuation. Generous decoration — but composed, not chaotic. - Whitespace: energetic asymmetry; props float at angles. Still leave the focal content room to read against the noise. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/paper-cut.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/paper-cut.md index fb5a45cd..cdf51582 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/paper-cut.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/paper-cut.md @@ -7,6 +7,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado ## 1. Shape & decoration - Shape language: forms defined by crisp, slightly-irregular cut edges (no outlines); simplified, stylized shapes that read as cut paper rather than illustration. Those cut edges are irregular `` / `` outlines, not a clean `` / ``, which reads as a digital box rather than torn paper. +- Composition geometry: stacked wave sheets building the page bottom-up; a die-cut window revealing the layer beneath; one large cut disc (sun / moon) anchoring the composition; tabbed sheet edges ordering steps; a foreground frame layer with the content on the sheet behind it. - Decoration: layering itself is the device — each element is a "sheet" stacked over the one beneath; small cut-out accents on the top layer. - Whitespace: cozy, composed — the backing sheet shows through as breathing room. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/photo-editorial.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/photo-editorial.md index e922ac56..435cacce 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/photo-editorial.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/photo-editorial.md @@ -7,6 +7,7 @@ Photo-led editorial — large full-bleed photography dominates the page, text po ## 1. Shape & decoration - Shape language: large full-bleed / edge-to-edge image fields are the page's spine; text sits in restrained columns, caption blocks, kickers, or overlay headlines. Minimal chrome — the photograph carries the page. +- Composition geometry: an L-shaped text zone carved out of the full bleed; a headline straddling the photo edge; a diptych / triptych panel split; one floating caption card breaking the image boundary — moves that serve the photograph, never bury it. - Decoration: thin rules, section numbering, small figure notes; nothing competes with the image. - Whitespace: generous around text; the photo fills, the type breathes beside it. Asymmetric magazine composition. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/pixel-art.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/pixel-art.md index 17dd152d..68b97099 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/pixel-art.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/pixel-art.md @@ -7,6 +7,7 @@ ## 1. Shape & decoration - Shape language: everything aligns to a visible pixel grid — blocky shapes, stepped edges, sharp transitions, no smooth curves; optional 1-pixel darker outlines for definition. +- Composition geometry: a stepped pixel staircase as the divider; an oversized sprite anchoring the page; a HUD frame with corner brackets zoning content; a tile ground-band along the bottom edge; a pixel progress bar as the sequence device. - Decoration: classic game framing — HUD bars, tile floors, sprite icons, chunky pixel borders. References NES / SNES / arcade composition. - Whitespace: grid-disciplined; let blocks sit on clean tiled ground rather than crowd. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/sketch-notes.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/sketch-notes.md index d916d97e..3ea85a5c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/sketch-notes.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/sketch-notes.md @@ -7,6 +7,7 @@ Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gen ## 1. Shape & decoration - Shape language: rounded shapes drawn with a slight wobble; pastel block fills that slightly overshoot their outlines (hand-painted feel); simple cartoon icons. Draw that wobble as a `` with non-aligned points — a `` is not wobble. +- Composition geometry: a wavy-arrow journey path structuring the whole page; a radial mind-map around a central doodle; a hand-drawn banner ribbon carrying the title; numbered circles hopping along a dotted route; one cloud frame around the idea that matters. - Decoration: small doodles — stars, sparkles, dots, underlines — sprinkled sparingly for warmth; wavy hand-drawn arrows connecting ideas with short inline labels. - Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md index 9e7a88a5..16ea7496 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md @@ -7,6 +7,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p ## 1. Shape & decoration - Shape language: rounded rectangles (`rx` 12-16), pill tags, soft containers. Consistent radius deck-wide. +- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or arc path replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Cards are the container language, not the composition — vary the stage they sit on. - Decoration: cards as the primary container; icon accents; numbered circles; gentle dividers. Moderate, in service of clarity. - Whitespace: comfortable padding inside cards; even gutters; balanced rather than austere. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md index dff8171f..d4ac2527 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md @@ -7,6 +7,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac ## 1. Shape & decoration - Shape language: sharp rectangles, true circles, single-weight rules. Corner radius `rx="0"` by default; if rounding at all, ≤4. +- Composition geometry: one oversized geometric plane — a full-height color column, a giant disc, a heavy bar — zoning the page; an asymmetric split with content flush to one axis; a hero numeral at architectural scale; a single diagonal rule or type line as the deliberate grid break. Few, large, exact — geometry at poster scale is Swiss, clutter is not. - Decoration: none. No gradient fills, no decorative blocks, no badges — structure carries the page. - Whitespace: vast and deliberate; negative space carries as much weight as content. Wide margins, generous gutters. - Layout snaps to a visible or implied modular grid; rigorous column / row alignment. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/vintage-poster.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/vintage-poster.md index 98ca91bb..bd09f406 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/vintage-poster.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/vintage-poster.md @@ -7,6 +7,7 @@ Mid-century print poster (1950s–1970s) — bold rounded-geometric shapes, limi ## 1. Shape & decoration - Shape language: bold geometric shapes with rounded organic edges, often slightly off-axis for retro tension; overlapping flat blocks; thick hand-aware lines; stylized, reduced iconography (a stylized sun, an angular mountain). +- Composition geometry: a giant sun disc or arch as the backdrop anchor; ray wedges radiating from the focal point; off-axis overlapping blocks staging the content; a horizon band splitting the page into field and sky zones; one badge or rosette seal at the emphasis point. - Decoration: the print artifacts — halftone dot overlays, slight ink misregistration — carry the character; imagery stays reduced and graphic, never photoreal. - Whitespace: confident poster composition — a few large blocks, deliberate negative space. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/zine.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/zine.md index 53e4db25..c5e4874f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/zine.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/zine.md @@ -7,6 +7,7 @@ Risograph zine / DIY poster — misregistered color layers, halftone dots, a tig ## 1. Shape & decoration - Shape language: cut-and-paste blocks, offset color shapes, rough frames; outlines in a near-black ink tone. Corner radius low or zero — print-flat, not soft-digital. +- Composition geometry: rotated pasted-on blocks (slight skew) building the collage stage; a torn-strip band as the section divider; an oversized halftone shape bleeding behind the content; a photocopy frame or taped corner anchoring the focal block; column scraps at mixed widths. - Decoration: the riso print artifacts — 1-3px color-layer misregistration, halftone-dot `` texture, overlapping color blocks that imply a third color where they cross. Texture is the decoration. - Whitespace: poster-like — bold focal blocks with raw margins; deliberate roughness over clean alignment. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index 663ecfb7..2690723d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md @@ -45,7 +45,7 @@ python3 scripts/update_repo.py |------|-----------------|---------------| | Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) | | Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) | -| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) | +| SVG pipeline | `preset_shape_svg.py`, `svg_authoring_view.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native preset authoring](../references/native-shape-authoring.md) | | PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) | | PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) | | Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) | @@ -81,8 +81,24 @@ Template source import: python3 scripts/pptx_template_import.py python3 scripts/pptx_template_import.py --manifest-only python3 scripts/pptx_template_import.py --inheritance-mode both +python3 scripts/svg_authoring_view.py -o +python3 scripts/template_preview_pptx.py +python3 scripts/template_preview_pptx.py --visual-only ``` +`svg_authoring_view.py` creates a lightweight, non-destructive inspection copy +of PPTX-imported SVGs. It removes embedded `txbody` payloads, +duplicate hidden geometry carriers, and import-identity attributes from the +copy while retaining visible fallback geometry, text, images, stable element +ids, root Master/Layout markers, and supported compact native-shape intent. +Relative local image references are rewritten so the projected copy still +renders from its new location. The full imported SVG remains unchanged and is +the evidence source for mirror restoration; the projection is inspection-only. +The exporter never reads the import workspace or the projection. A projected +view is not a final template or release export source. + +`template_preview_pptx.py` reads a template workspace, exports every `templates/*.svg` prototype as one structured review slide, and verifies the resulting Master/Layout package. This is an on-demand review action: its default output is `exports/_template_preview.pptx`, and that directory need not exist before the command runs. It refuses an existing output unless an intentional re-export passes `--force`. `--visual-only` is an explicit migration aid for legacy SVG rosters: it creates a slide-local visual review deck without validating or claiming a reusable Master/Layout contract. New structured templates use the default mode when a review deck is requested. + Template fill (direct PPTX, no SVG conversion): ```bash @@ -107,6 +123,16 @@ python3 scripts/native_enhance_pptx.py validate python3 scripts/native_enhance_pptx.py apply ``` +Native preset shape authoring (one registry-backed fragment on stdout): + +```bash +python3 scripts/preset_shape_svg.py list --search arrow +python3 scripts/preset_shape_svg.py describe rightArrow +python3 scripts/preset_shape_svg.py render rightArrow --id process-arrow --frame 120 180 240 96 --fill '#2563EB' +``` + +The helper never writes a page or project file. Select one exact semantic stock-shape match, inspect the emitted fragment, and insert it into the hand-authored SVG with the normal patch workflow. Keep ordinary rectangles, ellipses, freeform geometry, charts, icons, and ambiguous silhouettes as regular SVG. See [`references/native-shape-authoring.md`](../references/native-shape-authoring.md) for the selection and metadata contract. + Post-processing and export: ```bash @@ -122,13 +148,21 @@ python3 scripts/svg_to_pptx.py For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, transitions, `template-fill-pptx`, and `native-enhance-pptx` remain separately owned capabilities. -When `spec_lock.md` has no `pptx_structure` section, native `svg_to_pptx.py` falls back to `baseline`: the generated deck keeps a standard Master/Layout relationship and promotes only exact, z-order-safe shared backgrounds/chrome. Root `data-pptx-page-role` selects `Cover`, `Agenda`, `Section`, `Closing`, or `Content`; filenames are a fallback for marker-free legacy SVGs. A family-wide exact background and an exact leading structurally marked chrome prefix may move into that Layout; ids are consulted only when the structural role is absent. Actual titles, body content, pictures, charts, tables, page numbers, and page-specific shapes remain on Slide. No visual-similarity or placeholder inference is performed. Baseline also prunes unused layouts and maps locked typography/colors into the PowerPoint theme. Use `--pptx-structure flat` for slide-local diagnostics. +Native `svg_to_pptx.py` release export uses the project's `structured` lock. Every project supplies a complete Master roster and page-to-Master/Layout mapping before SVG generation. Every SVG root repeats that identity; fixed Master/Layout visuals are direct atomic children, and reusable slots are top-level groups with positive design-zone bounds plus one compatible carrier. Composite `object` regions use explicit proxy binding, and zero-slot Layouts are valid. -Deck/layout template routes use explicit template structure. Each complete SVG names its output Layout, repeats inherited Master/Layout preview layers, and marks supported content placeholders. Strict keeps the selected Layout contract; adaptive may create a new Layout while retaining the Master. The exporter validates cross-slide equality, creates one reusable Layout per key, removes repeated inherited copies, and keeps actual content Slide-local. The complete contract lives in [`references/shared-standards.md`](../references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export). +The exporter compiles only this declared structure, maps locked typography/colors into PowerPoint defaults, creates the named Master/Layout parts, and reads the package back before publication. It never clusters pages, promotes repeated chrome heuristically, or invents placeholders. Use `--pptx-structure flat` only for diagnostics. -Current `create-template` output always rebuilds explicit SVG structure and does not package `native_structure.json` or `source_template.pptx`. `preserve` remains available only for existing projects that already carry the legacy pair. +Template `page_layouts` records input provenance. Strict preserves its Master/Layout/slot contract; adaptive retains its Master and may use a new Layout key only when fixed Layout atoms or slot topology/bounds change. `standard` / `fidelity` author new SVGs and a new Master/Layout/slot contract. `mirror` restores the source identities and parentage without semantic synthesis, while mechanically expanding fixed-layer group wrappers into the direct atoms required by the structured contract. -`pptx_to_svg.py` annotates supported unmerged tables and conservative classic-chart caches with `data-pptx-native` metadata. Source table-style inheritance, supported solid cell fills/basic text formatting, chart title/legend/axis titles, and plot-level data-label flags for area/bar/column/line charts are retained when the current schema can represent them. Tables with direct borders, non-solid fills, or mixed rich-text formatting remain fallback-only, as do charts with unsupported label scopes/types, custom axis semantics, trendlines/error bars, or subtype options. Unsupported tables keep their rendered SVG table; unsupported charts keep a baked preview or explicit placeholder. Both carry `data-pptx-native-status`, which `svg_quality_checker.py` and `svg_to_pptx.py --native-objects` report as a warning. +Legacy `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, and missing root Master identity must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before release export. + +`pptx_to_svg.py` annotates verified text-grid tables and conservative chart data with `data-pptx-native` metadata. Table import covers exact physical row/grid topology, canonical rectangular merges, safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed run-rich paragraph schema. Each rich run requires `text` and may use only `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`. A merge must use the exact `rowSpan` / `gridSpan` / `hMerge` / `vMerge` physical topology with empty merge slaves. Presentation-only source run XML normalizes, while relationship-bearing text, extensions, line breaks, fields, tabs, bullets, broken text topology, unsafe border XML, non-solid fills, and other merge encodings remain fallback-only. For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the normalized SVG fallback resolves `wholeTbl`, `firstRow`, horizontal banding, theme colors/fonts, and direct cell/run overrides; other built-in/custom style families are not implied. + +Supported parsed column/bar/line/area, pie/doughnut, scatter, and bubble charts without a baked preview receive a deterministic readable fallback marked `data-pptx-visual-status="normalized"`. The importer additionally activates verified column/line/area combo charts, canonical OHLC stock charts, area charts with numeric date axes, verified scatter/bubble charts whose two value axes fit the closed `axes.x` / `axes.y` contract, radar charts, safe `of_pie` `serLines`, axis/title/legend normalization, and validated bar/column gap/overlap cases. Combo plots may retain independent primary/secondary category caches and workbook ranges. Both the category/value and XY contracts retain kind/position/visibility/label position/number format/min/max/major unit/reverse/major gridlines for native read-back. Scatter import derives effective `scatter_style` from uniform per-series line/marker/smooth state. The normalized XY fallback consumes only the two major-gridline flags; the C4/C5 additions do not expand the normalized renderer. `gapWidth` is accepted only as an integer in `0..500` and `overlap` only as an integer in `-100..100`; both normalize in native output, while malformed or out-of-range values fail closed. Safe common series paint forms and theme scheme colors are resolved; unknown series paint/style XML outside the explicit normalization boundaries still fails closed. Safe stock series style may pass the structural gate, but stock series, `hiLowLines`, and up-down bar local styling can still normalize under the editable-first contract. The editable native replacement remains allowed to normalize unmodeled no-fill/alpha/line/marker details and reports the route-level loss risk. Chart title/legend/axis titles and supported data-label flags are retained when the current schema can represent them. Fallback-only objects keep rendered SVG content or a baked chart preview and carry `data-pptx-native-status`, which validation and `--native-objects` export report as a warning. An active marker without a renderer keeps `data-pptx-visual-status="placeholder"` plus `data-pptx-route-status="reconstruction-only"`; default export keeps the placeholder and native opt-in may still reconstruct it. + +The ChartEx importer accepts exactly the validated treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel data models. Supported hierarchy/category/value/series/subtotal data round-trips to native output; source style, axes, labels, and binning may normalize. Numeric caches must be non-empty and finite with exact contiguous point topology. This is not arbitrary ChartEx import or presentation fidelity, and the ChartEx native writer still only promises valid payload palette entries rather than full source styling. + +Active imported table/chart markers carry `data-pptx-fallback-sha256`. Visible fallback edits, reachable SVG fragment-definition changes, marker-local reference-target changes, and marker transforms make the baseline stale: the mandatory quality checker warns, default export remains available, and `--native-objects` fails instead of discarding the SVG edit. Legacy markers without a baseline remain convertible with a checker/native-route warning. Exporter-canonical classic charts also recover canonical solid series/slice colors and exact one- or two-paragraph title styling; two paragraphs retain diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py index ea8d8c8e..26bbe44e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py @@ -270,6 +270,11 @@ def _wait_for_result( except OSError: pass + skip_error = _stage_skip_error(result_file.parent) + if skip_error: + logger.error('%s', skip_error) + return 2 + returncode = proc.poll() if returncode is not None: logger.error('confirm UI exited before a fresh result was written') @@ -346,6 +351,44 @@ def _result_stage_number(stage: Optional[str]) -> int: return 0 +def _stage_skip(rec_stage_number: int, result_stage: Optional[str]) -> bool: + """Detect a staged recommendation running ahead of the confirmed progression. + + Stages confirm strictly in order (stage1 → stage2 → stage3): a + recommendation may only run one stage past the last confirmed result, so + e.g. a ``stage3`` file while only stage 1 is confirmed is a skip — the + page must not render it (an active template never exempts stage 2). + Legacy single-pass recommendations (no ``stage``) are not staged and are + exempt, as is any state after the final confirmation. + """ + if rec_stage_number <= 1 or result_stage == 'final': + return False + return rec_stage_number > _result_stage_number(result_stage) + 1 + + +def _stage_skip_error(confirm_dir: Path) -> Optional[str]: + """Return a directive error when recommendations.json skips a stage.""" + try: + rec_data = _read_json_object(confirm_dir / RECOMMENDATIONS_NAME) + except (OSError, json.JSONDecodeError, ValueError): + return None + rec_stage_number = _recommendation_stage(rec_data) + result_stage = _result_stage(confirm_dir / RESULT_NAME) + if not _stage_skip(rec_stage_number, result_stage): + return None + expected = _stage_name(_result_stage_number(result_stage) + 1) + reattach = ( + '--daemon --wait' if expected == 'stage1' + else '--wait-only --wait-stage stage2' + ) + return ( + f'stage skip detected: recommendations.json is {_stage_name(rec_stage_number)} but the last ' + f'confirmed result is {result_stage or "absent"} — the page will not render a skipped stage. ' + f'Stages confirm in order and an active template does not exempt stage2 (SKILL.md Step 4). ' + f'Overwrite recommendations.json with the {expected} recommendations, then re-run with {reattach}.' + ) + + def _file_version(path: Path) -> Optional[float]: """Return a cheap file version for polling state, or None when absent.""" try: @@ -389,7 +432,10 @@ def _build_session_state( result_stage = _result_stage(result_file) result_stage_number = _result_stage_number(result_stage) + stage_skip = _stage_skip(rec_stage_number, result_stage) + # A skipped stage is never presented: the page keeps its "deriving…" state + # (waiting_agent) until the AI rewrites recommendations.json in order. if result_stage == 'final': expected_stage_number = None status = 'done' @@ -400,17 +446,20 @@ def _build_session_state( current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 3 else 'stage2' elif result_stage == 'stage1': expected_stage_number = 2 - status = 'ready_user' if rec_stage_number >= 2 else 'waiting_agent' - current_stage = _stage_name(rec_stage_number) if rec_stage_number >= 2 else 'stage1' + ready = rec_stage_number >= 2 and not stage_skip + status = 'ready_user' if ready else 'waiting_agent' + current_stage = _stage_name(rec_stage_number) if ready else 'stage1' else: - expected_stage_number = rec_stage_number or 1 - status = 'ready_user' if rec_stage_number else 'waiting_agent' - current_stage = rec_stage or 'stage1' + expected_stage_number = 1 if stage_skip else (rec_stage_number or 1) + ready = bool(rec_stage_number) and not stage_skip + status = 'ready_user' if ready else 'waiting_agent' + current_stage = rec_stage if ready else 'stage1' return { 'session_id': previous.get('session_id') or uuid.uuid4().hex, 'status': status, 'current_stage': current_stage, + 'stage_skip': stage_skip, 'expected_stage': _stage_name(expected_stage_number), 'expected_stage_number': expected_stage_number, 'recommendation_stage': rec_stage, @@ -541,6 +590,11 @@ def _wait_only_for_result( logger.info('confirmation stage=%s received: %s', target_stage, result_file) return 0 + skip_error = _stage_skip_error(result_file.parent) + if skip_error: + logger.error('%s', skip_error) + return 2 + lock = _read_lock(lock_file) pid = _lock_pid(lock) if not pid or not _process_alive(pid): diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md index 8a79e758..fe00677d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md @@ -81,6 +81,8 @@ The page runs as a **three-stage wizard in one browser session**. `recommendatio The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-derives** the design-system candidates from the user's actual anchors, overwrites `recommendations.json` with `"stage": "stage2"`, and re-attaches with `--wait-only --wait-stage stage2`. After the Stage-2 result, it **re-derives** image and execution recommendations from the confirmed anchors + design system, overwrites `recommendations.json` with `"stage": "stage3"`, and re-attaches with `--wait-only` for the final result. The page preserves earlier selections across transitions (single JS session). `GET /api/session` is the browser's waiting-state endpoint: it is derived from `recommendations.json`, `result.json`, and the active server port, then persisted to `session.json` so a recovered server can resume the same stage state. Only after `/api/session` reports that the next recommendation stage is ready does the page fetch `GET /api/recommendations`. `GET /api/recommendations` is served `no-store` so polls see overwrites; on later stages the server folds already-confirmed choices from `result.json` back into the payload so a refresh / reopen re-initializes from the user's actual choices even though those sections are no longer rendered. +**Stage progression guard.** Stages confirm strictly in order — a staged `recommendations.json` may only run **one** stage past the last confirmed result. A file that skips ahead (e.g. `"stage3"` while only stage 1 is confirmed — typically an attempt to collapse Stage 2 because an active template already fixes color / typography) is never rendered: `/api/session` keeps reporting `waiting_agent` with `stage_skip: true`, and `--wait` / `--wait-only` exit `2` with a directive log line naming the expected stage to rewrite. An active deck/layout template — `strict` adherence included — does not exempt Stage 2: the template skin becomes the recommended color / typography candidate, not a reason to skip the confirmation. Legacy single-pass files (no `stage`) are not staged and bypass the guard. + ### Input — `recommendations.json` (written by Strategist before launch) ```json diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md index c0c70a90..f1b5c204 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md @@ -192,8 +192,9 @@ python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md Behavior: - extracts slide text in reading order -- converts PowerPoint tables to Markdown tables -- transcribes native chart data (type + categories × series values) into a Markdown table, so chart numbers are not lost in conversion +- converts PowerPoint tables to Markdown tables; cell-internal line breaks become `
` so they cannot break the pipe-table row structure +- transcribes category charts as category × series tables and scatter/bubble charts as typed X/Y[/size] point tables +- preserves every readable chart dimension or series and emits `[Chart data warning: ]` for missing caches/count mismatches; `[Chart data unavailable: ]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table - transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning - exports embedded pictures to a sibling `_files/` directory - appends speaker notes when present @@ -245,11 +246,89 @@ python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode flat | `layered` | Only the layered `svg/` view and inheritance metadata | | `flat` | One self-contained slide SVG per page under `svg/` | -Supported unmerged tables and conservative classic-chart caches carry -`data-pptx-native` metadata beside their SVG fallback. Markers remain dormant -unless a later export uses `--native-objects`. Unsupported tables keep their -rendered SVG table; unsupported charts keep a baked preview or explicit -placeholder. Both carry `data-pptx-native-status`. +Supported text-grid tables and conservative classic-chart caches carry +`data-pptx-native` metadata beside their SVG fallback. Table import requires +exact physical row/grid topology and accepts canonical rectangular merges, +safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed +run-rich paragraph schema. +Each run requires `text` and may use only `bold`, `italic`, `underline`, +`strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`. +Presentation-only source run XML normalizes. Relationship-bearing text, +extensions, noncanonical/overlapping merges, nonblank merge slaves, unsafe +border XML, non-solid fills, structural line breaks/fields/tabs/bullets, and +broken text topology remain fallback-only. +Markers remain dormant +unless a later export uses `--native-objects`. That opt-in is editable-first: +it may normalize styling or omit marker-local details not represented by the +payload, and export reports that risk without disabling an otherwise supported +active marker. Unsupported tables keep their +rendered SVG table; unsupported charts keep a baked preview when one exists. +For the currently supported parsed classic families (column/bar/line/area, +pie/doughnut, scatter, and bubble), a chart without a baked preview receives a +deterministic readable fallback marked +`data-pptx-visual-status="normalized"`. Unknown style XML still fails closed; +common solid/no-fill/line/marker forms and scheme colors are normalized for the +SVG fallback and core payload colors, while native opt-in may still normalize +unmodeled alpha, line, marker, or no-fill details. Common General, decimal, +grouped, percent, and simple currency-prefix data-label formats render +deterministically; an unknown Excel format program keeps the active payload but +does not claim a normalized fallback. Active types outside the current renderer +continue to use an explicit placeholder marked +`data-pptx-visual-status="placeholder"` and +`data-pptx-route-status="reconstruction-only"`. Validation and export report +that route as a warning. Default export keeps the placeholder; when the same +group has a valid active native-chart payload, `--native-objects` may still +reconstruct the editable chart. Invalid or contradictory status declarations +remain errors. +Fallback-only native capability uses `data-pptx-native-status` and remains a +warning when the SVG fallback itself is complete. + +Active imported markers also carry `data-pptx-fallback-sha256`, computed over +their canonical fallback plus reachable document-level SVG fragment definitions. +A later visible edit, reachable definition change, local reference-target +change, or marker transform makes the native metadata stale. The mandatory +quality checker reports the mismatch; default export keeps the edited fallback, +while `--native-objects` fails before replacement so it cannot discard that edit. +`visibility:hidden` content, marker-local unused definitions, and explicitly +referenced document-level target roots (even when hidden) are included +conservatively; marker-local `display:none` subtrees are excluded, and external +file bytes are not read. +A legacy marker without the hash remains native-compatible and warns in the +checker/native route that stale detection is unavailable. + +For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the importer resolves +the normalized `wholeTbl`, `firstRow`, `band1H`/`band2H`, theme color/font, and +direct-format override subset. Other built-in/custom style families remain +outside this guarantee. + +The chart importer also accepts the verified column/line/area combo subset, +canonical four-series OHLC stock charts with shared numeric date caches, area +charts with numeric date axes, and verified scatter/bubble charts with a closed +pair of `axes.x` / `axes.y` value axes. Combo primary/secondary plots may retain +independent category caches and workbook ranges. Both the category/value and XY +contracts read back the supported kind/position/visibility, label-position, +number-format, min/max/major-unit, reverse, and major-gridline fields; the native +writer emits every field in those closed contracts. Scatter style is derived +from uniform effective series line/marker/smooth state. The normalized XY +fallback newly consumes only the two major-gridline flags, not the remaining +axis fields. The importer also accepts radar, safe `of_pie` `serLines`, the +closed axis/title/legend normalization cases, and bar/column `gapWidth` / +`overlap`. `gapWidth` must be one integer in `0..500` and `overlap` one integer +in `-100..100`; both normalize in native output, while malformed, duplicate, or +out-of-range values fail closed. These additions do not expand the normalized +renderer. +Safe stock series style may pass the structural gate, while stock series, +`hiLowLines`, and up-down bar local styling can still normalize under the +editable-first contract. +ChartEx import accepts exactly the validated treemap, sunburst, histogram, +pareto, box-whisker, waterfall, and funnel data models. Their supported +hierarchy/category/value/series/subtotal topology round-trips to native output. +Numeric caches must be non-empty and finite, with canonical non-negative counts +and indexes and exact contiguous point topology. Source style, axes, labels, +and binning details may normalize. This is not full `AxisSpec`, arbitrary +ChartEx import or presentation fidelity, arbitrary stock variants, other +date-axis chart families, or unlisted axis semantics. ChartEx native output +still consumes valid payload colors in its color-style part. Exporter-canonical charts recover canonical solid series/slice colors and exact one- or two-paragraph title styling; two paragraphs retain their `title` / diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md index b1ba1285..2e16210a 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md @@ -6,6 +6,37 @@ These tools cover post-processing, SVG validation, speaker notes, recorded narra The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported. +## `svg_authoring_view.py` + +Create a lightweight inspection/authoring projection of one PPTX-imported SVG +or a directory of imported SVGs: + +```bash +python3 scripts/svg_authoring_view.py -o +``` + +The operation is non-destructive and refuses existing output files unless +`--force` is explicit. It never writes back to the source SVG. The JSON report +on stdout records original/projected byte counts and removals by category. + +The projected copy: + +- removes embedded `txbody` metadata; +- removes hidden native geometry carriers while retaining and unwrapping their + visible preview geometry; +- removes source-object identity/style/hash attributes that are only useful to + an exact import round trip; +- keeps visible paths, text, images, stable ids, Master/Layout root markers, + and supported compact `data-pptx-object` / `data-pptx-prst` / + `data-pptx-frame` intent; and +- rewrites relative local asset references for the projection's new location. + +The complete imported SVG remains the evidence source for mirror restoration. +The exporter does not read the import workspace or the projection. The +projection is deliberately not a template generator, not a replacement for +the explicit Master/Layout restoration workflow, and not a supported release +input to `svg_to_pptx.py`. + ## Recommended Pipeline Run these steps in order: @@ -35,8 +66,7 @@ Convert project SVGs into PPTX. ```bash python3 scripts/svg_to_pptx.py python3 scripts/svg_to_pptx.py --native-objects -python3 scripts/svg_to_pptx.py --pptx-structure template # explicit SVG template metadata -python3 scripts/svg_to_pptx.py --pptx-structure preserve # imported source package contract +python3 scripts/svg_to_pptx.py --pptx-structure structured # current release structure python3 scripts/svg_to_pptx.py --pptx-structure flat # structure diagnostic # Template-import visual round-trip diagnostic only: python3 scripts/svg_to_pptx.py -s svg-flat @@ -58,7 +88,7 @@ Behavior: - `finalize_svg.py` always creates `svg_final/` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. - Explicit `-o/--output` changes the native PPTX destination and skips `backup/`. - Paragraph merging is enabled by default and trades some SVG line-layout fidelity for PowerPoint editability: - - Default: mergeable paragraph blocks (same x, dy clustered around one base line-height, optional larger gap for paragraph breaks) collapse into one editable text frame with multiple `` and precise `` / ``. Resizing the box reflows text inside it. + - Default: mergeable paragraph blocks (same x, dy clustered around one base line-height) collapse into one editable text frame. Equal effective font sizes may join as flowing prose; a font-size change, list marker, or accepted larger gap starts a new `` with precise `` / ``. Resizing the box reflows text inside it without erasing those paragraph boundaries. - With `--no-merge`: every dy-stacked `` becomes its own text frame — exact SVG line layout is preserved but a 12-line paragraph is 12 separate textboxes - Side effect: PowerPoint may wrap merged paragraphs to a different line count than the SVG source. Long body text (abstracts, multi-paragraph sections, reference lists) usually benefits from the default; pages with tight typographic alignment (covers, charts, tables) usually want `--no-merge` - Mergeable detection is conservative: only fires when the children form a clean paragraph block; mixed-layout `` falls through to the default per-line path @@ -71,16 +101,16 @@ Behavior: inputs and package-level processing. - For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide. - Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content. -- Omitting `--pptx-structure` reads `spec_lock.md` and falls back to `baseline`. Baseline assigns Layout families from root `data-pptx-page-role`, keeps content Slide-local, and promotes only exact family-wide backgrounds plus exact leading structurally marked chrome; filenames and ids are compatibility fallbacks for marker-free legacy SVGs. It never infers placeholders or visual similarity. Template mode builds reusable PowerPoint structure only from explicit SVG metadata and validates every `pptx_layouts` row plus cross-slide equality. Both strict and adaptive template adherence use this mode; adaptive may introduce a new Layout key under the same Master. -- Template/preserve placeholder semantics distinguish title, subtitle, body, picture, chart, table, generic object, media, date, footer, and slide number. Reconstructed titles are normally type-matched without an index; explicit imported title indices and all other source indices are retained. Imported `subTitle`, `obj`, `media`, and `dt` identities remain distinct through `manifest.json`, `native_structure.json`, Layout XML, and Slide XML. -- Baseline/template native export reads `spec_lock.md` typography into the PowerPoint theme: `title_family` becomes the major font and `body_family` / `font_family` becomes the minor font. Matching SVG text emits `+mj-*` / `+mn-*` tokens, while unrelated emphasis/code/brand families stay concrete. Preserve mode keeps the imported source theme; flat mode keeps fixed-font diagnostic output. -- Template mode also requires numeric `typography.title` and `typography.body` rows. It writes the title size to every Master `p:titleStyle` level and the body size to every `p:bodyStyle` / `p:otherStyle` level. Each generated Layout text placeholder also stores the prototype's first direct run size in `a:lstStyle/a:lvl1pPr/a:defRPr@sz` while retaining its prompt run size. Slide direct-run sizes remain unchanged. Missing title/body rows fail template export; baseline, preserve, and flat modes do not apply these updates. -- Baseline/template native export also maps canonical `spec_lock.md` color roles into the PowerPoint color scheme and emits context-safe `schemeClr` tokens for exact matches in SVG fills/text/strokes, gradients/patterns/bullets, native tables, and native-chart accent series. Local colors, inverse white/black, and effects stay concrete. Preserve mode keeps the imported source color scheme; flat mode keeps fixed-color diagnostic output. -- Preserve mode is legacy strict-only compatibility for existing projects that already carry `native_structure.json` + `source_template.pptx`. Current template creation does not emit the pair. +- Omitting `--pptx-structure` reads `spec_lock.md`; release export requires `mode: structured`. The lock must include a complete `pptx_masters` roster and one `pptx_layouts` row per page. `flat` is diagnostic-only. +- Every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct atomic children; layer `` elements are invalid. +- Each normal slot is a direct root `` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid. +- Structured export maps locked typography/colors into PowerPoint Master/Layout/theme defaults, creates one reusable Layout per declared key, and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts. +- Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive retains its Master and may use a new Layout identity only when fixed Layout atoms or slot topology/bounds change. +- Legacy `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, and missing Master identity are rejected with a pointer to [`restore-pptx-structure`](../../workflows/restore-pptx-structure.md). Export never migrates or infers them. - Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename. - `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them. - Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file. -- Before publishing template-mode native output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication; other structure modes skip this gate. +- Before publishing structured-baseline or template-mode native output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication; legacy baseline, preserve, and flat skip this gate. - SVG clip paths are still restricted for authored SVGs, but nested crop wrappers generated by PPTX import are mapped back to native picture crop / geometry when possible. - Speaker notes are embedded automatically unless `--no-notes` is used - Recorded narration is opt-in: @@ -95,7 +125,7 @@ Behavior: - Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level - Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider --list-voices` - Page transitions are controlled by `-t/--transition`; per-element entrance animations are controlled by `-a/--animation` -- Per-element animation applies to top-level SVG `` groups in z-order; aim for 3–8 content groups per slide. Existing layer/slide-number placeholder semantics are read before minimal structural roles; exact id tokens remain a fallback only when all explicit markers are absent +- Per-element animation applies to ordinary top-level SVG `` groups in z-order; aim for 3–8 Slide-local content groups per slide. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent - An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker - Start mode is set by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), `with-previous` (all together on slide entry) - `on-click` is for live presentations only; recorded narration rejects it because the tool does not generate object-level click timings @@ -146,12 +176,19 @@ python3 scripts/svg_quality_checker.py examples/project python3 scripts/svg_quality_checker.py examples/project --format ppt169 python3 scripts/svg_quality_checker.py --all examples python3 scripts/svg_quality_checker.py examples/project --export +python3 scripts/svg_quality_checker.py path/to/template/templates --template-mode ``` Checks include: - `viewBox` - banned elements - line-break structure +- explicit Master/Layout/slot structure for reusable templates +- duplicate empty Layout contracts under different keys + +Template mode accepts compact canonical preset shapes marked with +`data-pptx-authoring="preset"`. It validates the explicit structured SVG +contract; it does not implement a separate source-payload opt-in marker. ## `svg_position_calculator.py` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/__init__.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/__init__.py new file mode 100644 index 00000000..5e724838 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/__init__.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +PPT Master - DrawingML Preset Shapes + +Shared full-catalog loader, registry, and formula evaluator for PPTX shapes. + +Usage: + Import get_preset_registry from pptx_shapes. + +Examples: + from pptx_shapes import get_preset_registry + arrow = get_preset_registry().evaluate("rightArrow", 320, 160) + +Dependencies: + None (only uses standard library) +""" + +from .errors import ( + FormulaEvaluationError, + FormulaSyntaxError, + PresetShapeDataError, + PresetShapeError, + UnknownGuideError, + UnknownPresetShapeError, +) +from .formula import ( + FULL_CIRCLE, + OOXML_COORDINATE_MAX, + OOXML_COORDINATE_MIN, + OOXML_DEGREE, + OOXML_LINE_WIDTH_MAX, + SUPPORTED_OPERATORS, + FormulaEvaluator, + build_builtin_guides, + evaluate_formula, + validate_ooxml_line_width, + validate_ooxml_xfrm, +) +from .loader import ( + BUNDLED_DEFINITIONS_PATH, + BUNDLED_DEFINITIONS_SHA256, + BUNDLED_SHAPE_TYPES_PATH, + BUNDLED_SHAPE_TYPES_SHA256, + DRAWINGML_NS, + EXPECTED_SHAPE_COUNT, + load_preset_shape_definitions, + load_shape_type_values, +) +from .models import ( + EvaluatedPresetGeometry, + PresetShapeDefinition, +) +from .registry import ( + CONNECTOR_PRESET_TYPES, + PresetShapeRegistry, + get_preset_registry, +) +from .semantic_hash import ( + NATIVE_FALLBACK_SHA256_ATTR, + resolve_preset_preview_hash, + svg_native_fallback_fingerprint, + svg_native_fallback_markup_fingerprint, + svg_preset_preview_fingerprint, + svg_text_fingerprint, +) +from .xml_safety import RELATIONSHIPS_NS, has_relationship_attributes + + +__all__ = [ + "BUNDLED_DEFINITIONS_PATH", + "BUNDLED_DEFINITIONS_SHA256", + "BUNDLED_SHAPE_TYPES_PATH", + "BUNDLED_SHAPE_TYPES_SHA256", + "CONNECTOR_PRESET_TYPES", + "DRAWINGML_NS", + "EXPECTED_SHAPE_COUNT", + "FULL_CIRCLE", + "OOXML_COORDINATE_MAX", + "OOXML_COORDINATE_MIN", + "OOXML_DEGREE", + "OOXML_LINE_WIDTH_MAX", + "NATIVE_FALLBACK_SHA256_ATTR", + "SUPPORTED_OPERATORS", + "EvaluatedPresetGeometry", + "FormulaEvaluationError", + "FormulaEvaluator", + "FormulaSyntaxError", + "PresetShapeDataError", + "PresetShapeDefinition", + "PresetShapeError", + "PresetShapeRegistry", + "RELATIONSHIPS_NS", + "UnknownGuideError", + "UnknownPresetShapeError", + "build_builtin_guides", + "evaluate_formula", + "get_preset_registry", + "has_relationship_attributes", + "load_preset_shape_definitions", + "load_shape_type_values", + "resolve_preset_preview_hash", + "svg_native_fallback_fingerprint", + "svg_native_fallback_markup_fingerprint", + "svg_text_fingerprint", + "svg_preset_preview_fingerprint", + "validate_ooxml_line_width", + "validate_ooxml_xfrm", +] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-APACHE-2.0.txt b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-APACHE-2.0.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-APACHE-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-OPEN-XML-SDK-MIT.txt b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-OPEN-XML-SDK-MIT.txt new file mode 100644 index 00000000..984713a4 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/LICENSE-OPEN-XML-SDK-MIT.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/NOTICE.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/NOTICE.md new file mode 100644 index 00000000..0f142482 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/NOTICE.md @@ -0,0 +1,54 @@ +# DrawingML preset geometry data provenance + +## Apache POI notice + +Apache POI + +Copyright 2003-2025 The Apache Software Foundation + +This product includes software developed at + +The Apache Software Foundation (). + +## Vendored data + +`presetShapeDefinitions.xml` is vendored from Apache POI 5.4.1: + +- Release tag: `REL_5_4_1` +- Source commit: `4554f204cbbf00ecbcaed134fe57e43a1779a612` +- Source path: `poi/src/main/resources/org/apache/poi/sl/draw/geom/presetShapeDefinitions.xml` +- Source revision: +- Original release-file SHA-256: + `a7dad593d27bd70536b41da9b761fa16409536cc0c25ef2b6c7a61c5d9b3e738` +- Vendored SHA-256: `4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780` +- Modification: line endings normalized from the release JAR's mixed CRLF/LF to LF; XML content is otherwise unchanged. +- License: Apache License 2.0; see `LICENSE-APACHE-2.0.txt`. + +The independent completeness list in `shape_type_values.txt` is derived from +the Open XML SDK schema metadata for `a:ST_ShapeType` / `ShapeTypeValues`: + +- Repository: `dotnet/Open-XML-SDK` +- Source commit: `00967dc871f06776ae969762c6703d062308a6c9` +- Source path: `data/schemas/schemas_openxmlformats_org_drawingml_2006_main.json` +- Source revision: + +- Source-file SHA-256: + `e760b534c96ee02745d2a8084f1be362826da4470a85d0b0102a67c3b1678ab7` +- Vendored enum-list SHA-256: + `f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587` +- License: MIT (`dotnet/Open-XML-SDK`); see + `LICENSE-OPEN-XML-SDK-MIT.txt`. + +At the locked revisions, both sources contain exactly 187 unique preset names, +and their sets are identical. + +## Evaluator compatibility notes + +- The circular-arrow, left-circular-arrow, and left-right-circular-arrow + definitions each contain `+- xH 0 dxB 0`. Apache POI evaluates the first + three operands and ignores the inert final zero. The PPT Master evaluator + accepts only this trailing-zero compatibility form; other arity mismatches + remain errors. +- A small number of definitions rebind an intermediate guide name later in + the ordered `gdLst`. Evaluation therefore preserves document order and the + later value replaces the earlier value, matching Apache POI behavior. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/presetShapeDefinitions.xml b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/presetShapeDefinitions.xml new file mode 100644 index 00000000..b425872e --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/presetShapeDefinitions.xml @@ -0,0 +1,19898 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/shape_type_values.txt b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/shape_type_values.txt new file mode 100644 index 00000000..cba0a418 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/data/shape_type_values.txt @@ -0,0 +1,187 @@ +line +lineInv +triangle +rtTriangle +rect +diamond +parallelogram +trapezoid +nonIsoscelesTrapezoid +pentagon +hexagon +heptagon +octagon +decagon +dodecagon +star4 +star5 +star6 +star7 +star8 +star10 +star12 +star16 +star24 +star32 +roundRect +round1Rect +round2SameRect +round2DiagRect +snipRoundRect +snip1Rect +snip2SameRect +snip2DiagRect +plaque +ellipse +teardrop +homePlate +chevron +pieWedge +pie +blockArc +donut +noSmoking +rightArrow +leftArrow +upArrow +downArrow +stripedRightArrow +notchedRightArrow +bentUpArrow +leftRightArrow +upDownArrow +leftUpArrow +leftRightUpArrow +quadArrow +leftArrowCallout +rightArrowCallout +upArrowCallout +downArrowCallout +leftRightArrowCallout +upDownArrowCallout +quadArrowCallout +bentArrow +uturnArrow +circularArrow +leftCircularArrow +leftRightCircularArrow +curvedRightArrow +curvedLeftArrow +curvedUpArrow +curvedDownArrow +swooshArrow +cube +can +lightningBolt +heart +sun +moon +smileyFace +irregularSeal1 +irregularSeal2 +foldedCorner +bevel +frame +halfFrame +corner +diagStripe +chord +arc +leftBracket +rightBracket +leftBrace +rightBrace +bracketPair +bracePair +straightConnector1 +bentConnector2 +bentConnector3 +bentConnector4 +bentConnector5 +curvedConnector2 +curvedConnector3 +curvedConnector4 +curvedConnector5 +callout1 +callout2 +callout3 +accentCallout1 +accentCallout2 +accentCallout3 +borderCallout1 +borderCallout2 +borderCallout3 +accentBorderCallout1 +accentBorderCallout2 +accentBorderCallout3 +wedgeRectCallout +wedgeRoundRectCallout +wedgeEllipseCallout +cloudCallout +cloud +ribbon +ribbon2 +ellipseRibbon +ellipseRibbon2 +leftRightRibbon +verticalScroll +horizontalScroll +wave +doubleWave +plus +flowChartProcess +flowChartDecision +flowChartInputOutput +flowChartPredefinedProcess +flowChartInternalStorage +flowChartDocument +flowChartMultidocument +flowChartTerminator +flowChartPreparation +flowChartManualInput +flowChartManualOperation +flowChartConnector +flowChartPunchedCard +flowChartPunchedTape +flowChartSummingJunction +flowChartOr +flowChartCollate +flowChartSort +flowChartExtract +flowChartMerge +flowChartOfflineStorage +flowChartOnlineStorage +flowChartMagneticTape +flowChartMagneticDisk +flowChartMagneticDrum +flowChartDisplay +flowChartDelay +flowChartAlternateProcess +flowChartOffpageConnector +actionButtonBlank +actionButtonHome +actionButtonHelp +actionButtonInformation +actionButtonForwardNext +actionButtonBackPrevious +actionButtonEnd +actionButtonBeginning +actionButtonReturn +actionButtonDocument +actionButtonSound +actionButtonMovie +gear6 +gear9 +funnel +mathPlus +mathMinus +mathMultiply +mathDivide +mathEqual +mathNotEqual +cornerTabs +squareTabs +plaqueTabs +chartX +chartStar +chartPlus diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/errors.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/errors.py new file mode 100644 index 00000000..e0d3199a --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/errors.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +PPT Master - PPTX Shape Errors + +Shared exception types for the DrawingML preset-geometry package. + +Usage: + Import exception classes from pptx_shapes.errors. + +Examples: + from pptx_shapes.errors import FormulaEvaluationError + +Dependencies: + None (only uses standard library) +""" + + +class PresetShapeError(ValueError): + """Base error for invalid preset-shape data or evaluation input.""" + + +class PresetShapeDataError(PresetShapeError): + """Raised when the bundled preset catalog is incomplete or malformed.""" + + +class FormulaSyntaxError(PresetShapeError): + """Raised when a DrawingML guide formula has invalid syntax.""" + + +class FormulaEvaluationError(PresetShapeError): + """Raised when a syntactically valid formula cannot be evaluated.""" + + +class UnknownGuideError(PresetShapeError): + """Raised when a formula references an unknown guide token.""" + + +class UnknownPresetShapeError(KeyError): + """Raised when a preset name is absent from the locked shape catalog.""" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/formula.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/formula.py new file mode 100644 index 00000000..3c07a23f --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/formula.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +""" +PPT Master - DrawingML Formula Evaluator + +Evaluate DrawingML geometry guide formulas and standard built-in guides. + +Usage: + Import FormulaEvaluator or evaluate_formula from pptx_shapes.formula. + +Examples: + evaluator = FormulaEvaluator(width=200, height=100) + evaluator.evaluate("*/ w 1 2") + +Dependencies: + None (only uses standard library) +""" + +from __future__ import annotations + +import math +import re +from types import MappingProxyType +from typing import Mapping + +from .errors import ( + FormulaEvaluationError, + FormulaSyntaxError, + UnknownGuideError, +) + + +OOXML_DEGREE = 60000.0 +FULL_CIRCLE = 360.0 * OOXML_DEGREE +OOXML_COORDINATE_MIN = -27273042329600 +OOXML_COORDINATE_MAX = 27273042316900 +OOXML_LINE_WIDTH_MAX = 20116800 + +_OPERATOR_ARITY = { + "val": 1, + "*/": 3, + "+-": 3, + "+/": 3, + "?:": 3, + "abs": 1, + "at2": 2, + "cat2": 3, + "cos": 2, + "max": 2, + "min": 2, + "mod": 3, + "pin": 3, + "sat2": 3, + "sin": 2, + "sqrt": 1, + "tan": 2, +} +SUPPORTED_OPERATORS = frozenset(_OPERATOR_ARITY) + +_DIMENSION_GUIDE_RE = re.compile(r"^(wd|hd|ssd)([1-9][0-9]*)$") +_ANGLE_GUIDE_RE = re.compile(r"^(?:(\d+))?cd([1-9][0-9]*)$") + + +def validate_ooxml_xfrm( + off_x: int, + off_y: int, + ext_cx: int, + ext_cy: int, +) -> None: + """Validate DrawingML shape offsets and extents in EMU.""" + for name, value in (("x", off_x), ("y", off_y)): + if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: + raise ValueError( + f"DrawingML xfrm offset {name}={value} is outside the " + "OOXML coordinate range" + ) + for name, value in (("cx", ext_cx), ("cy", ext_cy)): + if value < 0 or value > OOXML_COORDINATE_MAX: + raise ValueError( + f"DrawingML xfrm extent {name}={value} is outside the " + "OOXML positive-coordinate range" + ) + + +def validate_ooxml_line_width(width: int) -> None: + """Validate one DrawingML ``ST_LineWidth`` value in EMU.""" + if width < 0 or width > OOXML_LINE_WIDTH_MAX: + raise ValueError( + f"DrawingML line width {width} is outside the OOXML line-width range" + ) + + +def build_builtin_guides( + width: float, + height: float, + *, + left: float = 0.0, + top: float = 0.0, +) -> Mapping[str, float]: + """Build the standard DrawingML geometry guide context for one frame.""" + + width = _finite_number(width, "width") + height = _finite_number(height, "height") + left = _finite_number(left, "left") + top = _finite_number(top, "top") + if width < 0 or height < 0: + raise FormulaEvaluationError("Shape width and height must be non-negative") + + right = left + width + bottom = top + height + short_side = min(width, height) + long_side = max(width, height) + values = { + "l": left, + "t": top, + "r": right, + "b": bottom, + "w": width, + "h": height, + "hc": left + width / 2.0, + "vc": top + height / 2.0, + "ss": short_side, + "ls": long_side, + "cd2": FULL_CIRCLE / 2.0, + "cd3": FULL_CIRCLE / 3.0, + "cd4": FULL_CIRCLE / 4.0, + "cd8": FULL_CIRCLE / 8.0, + "3cd4": FULL_CIRCLE * 3.0 / 4.0, + "3cd8": FULL_CIRCLE * 3.0 / 8.0, + "5cd8": FULL_CIRCLE * 5.0 / 8.0, + "7cd8": FULL_CIRCLE * 7.0 / 8.0, + } + for divisor in (2, 3, 4, 5, 6, 8, 10, 12, 16, 32): + values[f"wd{divisor}"] = width / divisor + values[f"hd{divisor}"] = height / divisor + values[f"ssd{divisor}"] = short_side / divisor + return MappingProxyType(values) + + +class FormulaEvaluator: + """Evaluate guide formulas against a shape-local DrawingML context.""" + + def __init__( + self, + width: float, + height: float, + *, + left: float = 0.0, + top: float = 0.0, + values: Mapping[str, float] | None = None, + ) -> None: + self._builtins = dict( + build_builtin_guides(width, height, left=left, top=top) + ) + self._values: dict[str, float] = {} + if values: + for name, value in values.items(): + self.bind(name, value) + + @property + def values(self) -> Mapping[str, float]: + """Return a detached, read-only view of explicitly bound guides.""" + + return MappingProxyType(dict(self._values)) + + @property + def builtins(self) -> Mapping[str, float]: + """Return the resolved built-in guide values.""" + + return MappingProxyType(dict(self._builtins)) + + def bind(self, name: str, value: float) -> float: + """Bind one evaluated adjustment or guide and return its numeric value.""" + + if not name or name.isspace(): + raise FormulaEvaluationError("Guide name must not be empty") + number = _finite_number(value, f"guide {name!r}") + self._values[name] = number + return number + + def resolve(self, token: str) -> float: + """Resolve a literal, bound guide, or DrawingML built-in guide token.""" + + token = token.strip() + if not token: + raise UnknownGuideError("Guide token must not be empty") + try: + return _finite_number(float(token), f"literal {token!r}") + except ValueError: + pass + + if token in self._values: + return self._values[token] + if token in self._builtins: + return self._builtins[token] + + dynamic = self._resolve_dynamic_builtin(token) + if dynamic is not None: + self._builtins[token] = dynamic + return dynamic + raise UnknownGuideError(f"Unknown DrawingML guide token: {token!r}") + + def evaluate(self, formula: str) -> float: + """Evaluate one complete DrawingML geometry formula.""" + + parts = formula.split() + if not parts: + raise FormulaSyntaxError("DrawingML formula must not be empty") + operator = parts[0] + expected = _OPERATOR_ARITY.get(operator) + if expected is None: + raise FormulaSyntaxError( + f"Unsupported DrawingML formula operator: {operator!r}" + ) + if len(parts) < expected + 1: + raise FormulaSyntaxError( + f"Operator {operator!r} expects {expected} operands, " + f"received {len(parts) - 1}: {formula!r}" + ) + trailing = parts[expected + 1 :] + if trailing and not ( + operator == "+-" and all(token == "0" for token in trailing) + ): + raise FormulaSyntaxError( + f"Operator {operator!r} expects {expected} operands, " + f"received {len(parts) - 1}: {formula!r}" + ) + # POI 5.4.1 carries three circular-arrow formulas with one inert + # trailing zero ("+- xH 0 dxB 0"). Apache POI ignores surplus tokens; + # accept only this harmless form while keeping all other arity errors. + operands = tuple(self.resolve(token) for token in parts[1 : expected + 1]) + try: + result = _apply_operator(operator, operands) + except (OverflowError, ValueError) as exc: + raise FormulaEvaluationError( + f"Cannot evaluate DrawingML formula {formula!r}: {exc}" + ) from exc + return _finite_number(result, f"result of {formula!r}") + + def evaluate_value(self, value: str | int | float) -> float: + """Evaluate a formula string, guide token, or numeric adjustment value.""" + + if isinstance(value, str): + parts = value.split() + if not parts: + raise FormulaSyntaxError("DrawingML value must not be empty") + if parts[0] in SUPPORTED_OPERATORS: + return self.evaluate(value) + if len(parts) == 1: + return self.resolve(parts[0]) + raise FormulaSyntaxError(f"Invalid DrawingML value: {value!r}") + return _finite_number(value, "guide value") + + def _resolve_dynamic_builtin(self, token: str) -> float | None: + dimension_match = _DIMENSION_GUIDE_RE.fullmatch(token) + if dimension_match: + family, divisor_text = dimension_match.groups() + divisor = int(divisor_text) + base_name = {"wd": "w", "hd": "h", "ssd": "ss"}[family] + return self._builtins[base_name] / divisor + + angle_match = _ANGLE_GUIDE_RE.fullmatch(token) + if angle_match: + numerator_text, divisor_text = angle_match.groups() + numerator = int(numerator_text or "1") + divisor = int(divisor_text) + return FULL_CIRCLE * numerator / divisor + return None + + +def evaluate_formula( + formula: str, + *, + width: float, + height: float, + left: float = 0.0, + top: float = 0.0, + values: Mapping[str, float] | None = None, +) -> float: + """Evaluate one formula without manually constructing an evaluator.""" + + evaluator = FormulaEvaluator( + width, + height, + left=left, + top=top, + values=values, + ) + return evaluator.evaluate(formula) + + +def _apply_operator(operator: str, values: tuple[float, ...]) -> float: + if operator == "val": + return values[0] + if operator == "*/": + return 0.0 if values[2] == 0 else values[0] * values[1] / values[2] + if operator == "+-": + return values[0] + values[1] - values[2] + if operator == "+/": + return 0.0 if values[2] == 0 else (values[0] + values[1]) / values[2] + if operator == "?:": + return values[1] if values[0] > 0 else values[2] + if operator == "abs": + return abs(values[0]) + if operator == "at2": + return math.degrees(math.atan2(values[1], values[0])) * OOXML_DEGREE + if operator == "cat2": + return values[0] * math.cos(math.atan2(values[2], values[1])) + if operator == "cos": + return values[0] * math.cos(math.radians(values[1] / OOXML_DEGREE)) + if operator == "max": + return max(values[0], values[1]) + if operator == "min": + return min(values[0], values[1]) + if operator == "mod": + return math.sqrt(sum(value * value for value in values)) + if operator == "pin": + return max(values[0], min(values[1], values[2])) + if operator == "sat2": + return values[0] * math.sin(math.atan2(values[2], values[1])) + if operator == "sin": + return values[0] * math.sin(math.radians(values[1] / OOXML_DEGREE)) + if operator == "sqrt": + return math.sqrt(values[0]) + if operator == "tan": + return values[0] * math.tan(math.radians(values[1] / OOXML_DEGREE)) + raise FormulaSyntaxError(f"Unsupported DrawingML formula operator: {operator!r}") + + +def _finite_number(value: float, label: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise FormulaEvaluationError(f"{label} must be numeric") from exc + if not math.isfinite(number): + raise FormulaEvaluationError(f"{label} must be finite") + return number diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/loader.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/loader.py new file mode 100644 index 00000000..8089a2a2 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/loader.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +PPT Master - Preset Shape Data Loader + +Load and validate the bundled DrawingML preset geometry catalog. + +Usage: + Import load_preset_shape_definitions from pptx_shapes.loader. + +Examples: + definitions = load_preset_shape_definitions() + +Dependencies: + None (only uses standard library) +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from xml.etree import ElementTree as ET + +from .errors import PresetShapeDataError +from .models import ( + AdjustHandleDefinition, + ConnectionSiteDefinition, + GuideDefinition, + PathCommandDefinition, + PointExpression, + PresetShapeDefinition, + ShapePathDefinition, + TextRectangleDefinition, +) + + +DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" +EXPECTED_SHAPE_COUNT = 187 +BUNDLED_DEFINITIONS_SHA256 = ( + "4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780" +) +BUNDLED_SHAPE_TYPES_SHA256 = ( + "f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587" +) + +_PACKAGE_DIR = Path(__file__).resolve().parent +BUNDLED_DEFINITIONS_PATH = _PACKAGE_DIR / "data" / "presetShapeDefinitions.xml" +BUNDLED_SHAPE_TYPES_PATH = _PACKAGE_DIR / "data" / "shape_type_values.txt" +_EXPECTED_ROOT_TAG = "presetShapeDefinitons" +_PATH_COMMAND_ARITY = { + "moveTo": 1, + "lnTo": 1, + "quadBezTo": 2, + "cubicBezTo": 3, + "arcTo": 0, + "close": 0, +} + + +def load_shape_type_values(path: Path | None = None) -> tuple[str, ...]: + """Load the independent Open XML ``ShapeTypeValues`` coverage list.""" + + source = path or BUNDLED_SHAPE_TYPES_PATH + try: + raw = source.read_bytes() + names = tuple( + line.strip() + for line in raw.decode("utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + except (OSError, UnicodeDecodeError) as exc: + raise PresetShapeDataError( + f"Cannot read preset shape type catalog {source}: {exc}" + ) from exc + if path is None: + actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest() + if actual != BUNDLED_SHAPE_TYPES_SHA256: + raise PresetShapeDataError( + f"ShapeTypeValues checksum mismatch for {source}: " + f"expected {BUNDLED_SHAPE_TYPES_SHA256}, found {actual}" + ) + if len(names) != len(set(names)): + raise PresetShapeDataError(f"Duplicate names in shape type catalog: {source}") + if len(names) != EXPECTED_SHAPE_COUNT: + raise PresetShapeDataError( + f"Expected {EXPECTED_SHAPE_COUNT} ShapeTypeValues, found {len(names)}" + ) + return names + + +def load_preset_shape_definitions( + path: Path | None = None, + *, + expected_sha256: str | None = None, + expected_names: tuple[str, ...] | None = None, +) -> tuple[PresetShapeDefinition, ...]: + """Load one preset XML catalog and enforce uniqueness and coverage. + + The bundled catalog is hash-locked automatically. External catalogs are + validated structurally and can opt into a caller-provided hash. + """ + + source = path or BUNDLED_DEFINITIONS_PATH + locked_hash = ( + BUNDLED_DEFINITIONS_SHA256 + if path is None and expected_sha256 is None + else expected_sha256 + ) + raw = _read_verified_bytes(source, locked_hash) + try: + root = ET.fromstring(raw) + except ET.ParseError as exc: + raise PresetShapeDataError(f"Invalid preset geometry XML {source}: {exc}") from exc + if _local_name(root.tag) != _EXPECTED_ROOT_TAG: + raise PresetShapeDataError( + f"Unexpected preset geometry root {_local_name(root.tag)!r} in {source}" + ) + + definitions = tuple(_parse_shape(element) for element in root) + names = tuple(definition.name for definition in definitions) + if len(names) != len(set(names)): + raise PresetShapeDataError(f"Duplicate preset geometry names in {source}") + + catalog_names = expected_names + if catalog_names is None and path is None: + catalog_names = load_shape_type_values() + if catalog_names is not None: + missing = sorted(set(catalog_names) - set(names)) + extra = sorted(set(names) - set(catalog_names)) + if missing or extra: + raise PresetShapeDataError( + "Preset geometry coverage differs from ShapeTypeValues: " + f"missing={missing}, extra={extra}" + ) + return definitions + + +def _read_verified_bytes(path: Path, expected_sha256: str | None) -> bytes: + try: + raw = path.read_bytes() + except OSError as exc: + raise PresetShapeDataError(f"Cannot read preset geometry data {path}: {exc}") from exc + if expected_sha256: + actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest() + if actual != expected_sha256: + raise PresetShapeDataError( + f"Preset geometry checksum mismatch for {path}: " + f"expected {expected_sha256}, found {actual}" + ) + return raw + + +def _normalized_lf_bytes(raw: bytes) -> bytes: + """Normalize checkout line endings before verifying text-resource hashes.""" + + return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + + +def _parse_shape(element: ET.Element) -> PresetShapeDefinition: + name = _local_name(element.tag) + if not name: + raise PresetShapeDataError("Preset shape name must not be empty") + adjustments = _parse_guides(_find_child(element, "avLst")) + guides = _parse_guides(_find_child(element, "gdLst")) + handles = _parse_handles(_find_child(element, "ahLst")) + connections = _parse_connections(_find_child(element, "cxnLst")) + text_rectangle = _parse_text_rectangle(_find_child(element, "rect")) + paths = _parse_paths(_find_child(element, "pathLst"), name) + return PresetShapeDefinition( + name=name, + adjustments=adjustments, + guides=guides, + handles=handles, + connections=connections, + text_rectangle=text_rectangle, + paths=paths, + ) + + +def _parse_guides(container: ET.Element | None) -> tuple[GuideDefinition, ...]: + if container is None: + return () + guides = [] + for element in container: + if _local_name(element.tag) != "gd": + raise PresetShapeDataError( + f"Unexpected {_local_name(element.tag)!r} in guide list" + ) + name = _required_attribute(element, "name") + formula = _required_attribute(element, "fmla") + guides.append(GuideDefinition(name=name, formula=formula)) + # Some normative preset definitions intentionally rebind an intermediate + # name later in the ordered guide list. Preserve that sequential behavior. + return tuple(guides) + + +def _parse_handles( + container: ET.Element | None, +) -> tuple[AdjustHandleDefinition, ...]: + if container is None: + return () + handles = [] + for element in container: + kind = _local_name(element.tag) + if kind not in {"ahXY", "ahPolar"}: + raise PresetShapeDataError(f"Unexpected adjustment handle: {kind!r}") + position = _parse_position(element) + handles.append( + AdjustHandleDefinition( + kind="xy" if kind == "ahXY" else "polar", + position=position, + x_reference=element.attrib.get("gdRefX"), + minimum_x=element.attrib.get("minX"), + maximum_x=element.attrib.get("maxX"), + y_reference=element.attrib.get("gdRefY"), + minimum_y=element.attrib.get("minY"), + maximum_y=element.attrib.get("maxY"), + angle_reference=element.attrib.get("gdRefAng"), + minimum_angle=element.attrib.get("minAng"), + maximum_angle=element.attrib.get("maxAng"), + radius_reference=element.attrib.get("gdRefR"), + minimum_radius=element.attrib.get("minR"), + maximum_radius=element.attrib.get("maxR"), + ) + ) + return tuple(handles) + + +def _parse_connections( + container: ET.Element | None, +) -> tuple[ConnectionSiteDefinition, ...]: + if container is None: + return () + connections = [] + for element in container: + if _local_name(element.tag) != "cxn": + raise PresetShapeDataError( + f"Unexpected connection-site element: {_local_name(element.tag)!r}" + ) + connections.append( + ConnectionSiteDefinition( + angle=_required_attribute(element, "ang"), + position=_parse_position(element), + ) + ) + return tuple(connections) + + +def _parse_text_rectangle( + element: ET.Element | None, +) -> TextRectangleDefinition | None: + if element is None: + return None + return TextRectangleDefinition( + left=_required_attribute(element, "l"), + top=_required_attribute(element, "t"), + right=_required_attribute(element, "r"), + bottom=_required_attribute(element, "b"), + ) + + +def _parse_paths( + container: ET.Element | None, + shape_name: str, +) -> tuple[ShapePathDefinition, ...]: + if container is None: + raise PresetShapeDataError(f"Preset {shape_name!r} has no path list") + paths = [] + for element in container: + if _local_name(element.tag) != "path": + raise PresetShapeDataError( + f"Unexpected path-list element: {_local_name(element.tag)!r}" + ) + paths.append( + ShapePathDefinition( + coordinate_width=element.attrib.get("w"), + coordinate_height=element.attrib.get("h"), + fill=element.attrib.get("fill", "norm"), + stroke=_parse_boolean(element.attrib.get("stroke"), default=True), + extrusion_ok=_parse_boolean( + element.attrib.get("extrusionOk"), + default=True, + ), + commands=tuple(_parse_path_command(command) for command in element), + ) + ) + if not paths: + raise PresetShapeDataError(f"Preset {shape_name!r} has an empty path list") + return tuple(paths) + + +def _parse_path_command(element: ET.Element) -> PathCommandDefinition: + name = _local_name(element.tag) + expected_points = _PATH_COMMAND_ARITY.get(name) + if expected_points is None: + raise PresetShapeDataError(f"Unsupported preset path command: {name!r}") + if name == "arcTo": + parameters = tuple( + _required_attribute(element, attribute) + for attribute in ("wR", "hR", "stAng", "swAng") + ) + return PathCommandDefinition(name=name, parameters=parameters) + points = tuple( + child for child in element if _local_name(child.tag) == "pt" + ) + if len(points) != expected_points: + raise PresetShapeDataError( + f"Path command {name!r} expects {expected_points} points, " + f"found {len(points)}" + ) + parameters = tuple( + coordinate + for point in points + for coordinate in ( + _required_attribute(point, "x"), + _required_attribute(point, "y"), + ) + ) + return PathCommandDefinition(name=name, parameters=parameters) + + +def _parse_position(parent: ET.Element) -> PointExpression: + positions = [ + child for child in parent if _local_name(child.tag) == "pos" + ] + if len(positions) != 1: + raise PresetShapeDataError( + f"{_local_name(parent.tag)!r} must contain exactly one position" + ) + return PointExpression( + x=_required_attribute(positions[0], "x"), + y=_required_attribute(positions[0], "y"), + ) + + +def _find_child(parent: ET.Element, local_name: str) -> ET.Element | None: + matches = [child for child in parent if _local_name(child.tag) == local_name] + if len(matches) > 1: + raise PresetShapeDataError( + f"Preset contains duplicate {local_name!r} elements" + ) + return matches[0] if matches else None + + +def _required_attribute(element: ET.Element, name: str) -> str: + value = element.attrib.get(name) + if value is None or not value.strip(): + raise PresetShapeDataError( + f"Element {_local_name(element.tag)!r} requires attribute {name!r}" + ) + return value.strip() + + +def _parse_boolean(value: str | None, *, default: bool) -> bool: + if value is None: + return default + if value in {"true", "1"}: + return True + if value in {"false", "0"}: + return False + raise PresetShapeDataError(f"Invalid DrawingML boolean value: {value!r}") + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/models.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/models.py new file mode 100644 index 00000000..0c601519 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/models.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +PPT Master - PPTX Shape Models + +Immutable value objects shared by the preset catalog loader and evaluator. + +Usage: + Import model classes from pptx_shapes.models. + +Examples: + from pptx_shapes.models import GuideDefinition + +Dependencies: + None (only uses standard library) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + + +@dataclass(frozen=True) +class GuideDefinition: + """One named DrawingML guide formula.""" + + name: str + formula: str + + +@dataclass(frozen=True) +class PointExpression: + """A point whose coordinates are numeric literals or guide names.""" + + x: str + y: str + + +@dataclass(frozen=True) +class AdjustHandleDefinition: + """An XY or polar adjustment handle from ``a:ahLst``.""" + + kind: str + position: PointExpression + x_reference: str | None = None + minimum_x: str | None = None + maximum_x: str | None = None + y_reference: str | None = None + minimum_y: str | None = None + maximum_y: str | None = None + angle_reference: str | None = None + minimum_angle: str | None = None + maximum_angle: str | None = None + radius_reference: str | None = None + minimum_radius: str | None = None + maximum_radius: str | None = None + + +@dataclass(frozen=True) +class ConnectionSiteDefinition: + """One preset connection site before guide evaluation.""" + + angle: str + position: PointExpression + + +@dataclass(frozen=True) +class TextRectangleDefinition: + """The preset's internal text rectangle expressions.""" + + left: str + top: str + right: str + bottom: str + + +@dataclass(frozen=True) +class PathCommandDefinition: + """A DrawingML path command with parameters in document order. + + Parameter order is ``x,y`` for move/line, point order for Bezier commands, + and ``wR,hR,stAng,swAng`` for arcs. ``close`` has no parameters. + """ + + name: str + parameters: tuple[str, ...] + + +@dataclass(frozen=True) +class ShapePathDefinition: + """One path in a preset geometry definition.""" + + coordinate_width: str | None + coordinate_height: str | None + fill: str + stroke: bool + extrusion_ok: bool + commands: tuple[PathCommandDefinition, ...] + + +@dataclass(frozen=True) +class PresetShapeDefinition: + """Complete immutable source definition for one preset shape.""" + + name: str + adjustments: tuple[GuideDefinition, ...] + guides: tuple[GuideDefinition, ...] + handles: tuple[AdjustHandleDefinition, ...] + connections: tuple[ConnectionSiteDefinition, ...] + text_rectangle: TextRectangleDefinition | None + paths: tuple[ShapePathDefinition, ...] + + +@dataclass(frozen=True) +class EvaluatedPoint: + """A point in the evaluated shape-local coordinate system.""" + + x: float + y: float + + +@dataclass(frozen=True) +class EvaluatedAdjustHandle: + """An adjustment handle with resolved position and constraint bounds.""" + + kind: str + position: EvaluatedPoint + x_reference: str | None = None + minimum_x: float | None = None + maximum_x: float | None = None + y_reference: str | None = None + minimum_y: float | None = None + maximum_y: float | None = None + angle_reference: str | None = None + minimum_angle: float | None = None + maximum_angle: float | None = None + radius_reference: str | None = None + minimum_radius: float | None = None + maximum_radius: float | None = None + + +@dataclass(frozen=True) +class EvaluatedConnectionSite: + """A connection site with resolved angle and position.""" + + angle: float + position: EvaluatedPoint + + +@dataclass(frozen=True) +class EvaluatedTextRectangle: + """A resolved internal text rectangle.""" + + left: float + top: float + right: float + bottom: float + + +@dataclass(frozen=True) +class EvaluatedPathCommand: + """A path command whose parameters have all been resolved to numbers.""" + + name: str + parameters: tuple[float, ...] + + +@dataclass(frozen=True) +class EvaluatedShapePath: + """One resolved path and its DrawingML paint behavior.""" + + coordinate_width: float + coordinate_height: float + fill: str + stroke: bool + extrusion_ok: bool + commands: tuple[EvaluatedPathCommand, ...] + + +@dataclass(frozen=True) +class EvaluatedPresetGeometry: + """All evaluated semantic geometry for one preset shape instance.""" + + name: str + width: float + height: float + left: float + top: float + adjustments: Mapping[str, float] + guides: Mapping[str, float] + handles: tuple[EvaluatedAdjustHandle, ...] + connections: tuple[EvaluatedConnectionSite, ...] + text_rectangle: EvaluatedTextRectangle | None + paths: tuple[EvaluatedShapePath, ...] + + +def immutable_mapping(values: Mapping[str, float]) -> Mapping[str, float]: + """Return a detached, read-only copy of a numeric mapping.""" + + return MappingProxyType(dict(values)) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/registry.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/registry.py new file mode 100644 index 00000000..adb8cc14 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/registry.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +PPT Master - Preset Shape Registry + +Expose the complete preset catalog and evaluate shape geometry instances. + +Usage: + Import get_preset_registry from pptx_shapes.registry. + +Examples: + geometry = get_preset_registry().evaluate("rightArrow", 320, 160) + +Dependencies: + None (only uses standard library) +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Mapping + +from .errors import ( + FormulaEvaluationError, + PresetShapeDataError, + UnknownPresetShapeError, +) +from .formula import FormulaEvaluator +from .loader import load_preset_shape_definitions, load_shape_type_values +from .models import ( + AdjustHandleDefinition, + EvaluatedAdjustHandle, + EvaluatedConnectionSite, + EvaluatedPathCommand, + EvaluatedPoint, + EvaluatedPresetGeometry, + EvaluatedShapePath, + EvaluatedTextRectangle, + PointExpression, + PresetShapeDefinition, + immutable_mapping, +) + + +AdjustmentValue = str | int | float +CONNECTOR_PRESET_TYPES = frozenset( + { + "line", + "lineInv", + "straightConnector1", + "bentConnector2", + "bentConnector3", + "bentConnector4", + "bentConnector5", + "curvedConnector2", + "curvedConnector3", + "curvedConnector4", + "curvedConnector5", + } +) + + +class PresetShapeRegistry: + """Read-only registry for all standard DrawingML preset geometries.""" + + def __init__( + self, + definitions: tuple[PresetShapeDefinition, ...], + expected_names: tuple[str, ...], + ) -> None: + self._definitions = { + definition.name: definition for definition in definitions + } + self._names = tuple(expected_names) + if len(self._definitions) != len(definitions): + raise PresetShapeDataError("Preset registry contains duplicate names") + missing = sorted(set(expected_names) - set(self._definitions)) + extra = sorted(set(self._definitions) - set(expected_names)) + if missing or extra: + raise PresetShapeDataError( + "Preset registry differs from ShapeTypeValues: " + f"missing={missing}, extra={extra}" + ) + + @classmethod + def bundled(cls) -> PresetShapeRegistry: + """Load the hash-locked catalog shipped with PPT Master.""" + + names = load_shape_type_values() + definitions = load_preset_shape_definitions(expected_names=names) + return cls(definitions, names) + + @property + def names(self) -> tuple[str, ...]: + """Return all names in official ``ShapeTypeValues`` order.""" + + return self._names + + def __len__(self) -> int: + return len(self._definitions) + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and name in self._definitions + + def contains(self, name: str) -> bool: + """Return whether ``name`` is a standard preset in this catalog.""" + + return name in self._definitions + + def get(self, name: str) -> PresetShapeDefinition: + """Return one immutable source definition or raise a precise error.""" + + try: + return self._definitions[name] + except KeyError as exc: + raise UnknownPresetShapeError( + f"Unknown DrawingML preset shape: {name!r}" + ) from exc + + def evaluate( + self, + name: str, + width: float, + height: float, + *, + adjustments: Mapping[str, AdjustmentValue] | None = None, + ) -> EvaluatedPresetGeometry: + """Evaluate every guide, handle, connection, text rect, and path.""" + + definition = self.get(name) + evaluator = FormulaEvaluator(width, height) + supplied = dict(adjustments or {}) + adjustment_names = {guide.name for guide in definition.adjustments} + unknown = sorted(set(supplied) - adjustment_names) + if unknown: + raise FormulaEvaluationError( + f"Preset {name!r} has no adjustments named {unknown}" + ) + + evaluated_adjustments: dict[str, float] = {} + for guide in definition.adjustments: + source = supplied.get(guide.name, guide.formula) + value = evaluator.evaluate_value(source) + evaluated_adjustments[guide.name] = evaluator.bind(guide.name, value) + + evaluated_guides: dict[str, float] = {} + for guide in definition.guides: + value = evaluator.evaluate(guide.formula) + evaluated_guides[guide.name] = evaluator.bind(guide.name, value) + + return EvaluatedPresetGeometry( + name=name, + width=evaluator.resolve("w"), + height=evaluator.resolve("h"), + left=evaluator.resolve("l"), + top=evaluator.resolve("t"), + adjustments=immutable_mapping(evaluated_adjustments), + guides=immutable_mapping(evaluated_guides), + handles=tuple( + _evaluate_handle(handle, evaluator) + for handle in definition.handles + ), + connections=tuple( + EvaluatedConnectionSite( + angle=evaluator.evaluate_value(connection.angle), + position=_evaluate_point(connection.position, evaluator), + ) + for connection in definition.connections + ), + text_rectangle=_evaluate_text_rectangle(definition, evaluator), + paths=tuple( + EvaluatedShapePath( + coordinate_width=_path_extent( + path.coordinate_width, + evaluator.resolve("w"), + evaluator, + ), + coordinate_height=_path_extent( + path.coordinate_height, + evaluator.resolve("h"), + evaluator, + ), + fill=path.fill, + stroke=path.stroke, + extrusion_ok=path.extrusion_ok, + commands=tuple( + EvaluatedPathCommand( + name=command.name, + parameters=tuple( + evaluator.evaluate_value(parameter) + for parameter in command.parameters + ), + ) + for command in path.commands + ), + ) + for path in definition.paths + ), + ) + + +@lru_cache(maxsize=1) +def get_preset_registry() -> PresetShapeRegistry: + """Return the process-wide, lazily loaded bundled registry.""" + + return PresetShapeRegistry.bundled() + + +def _evaluate_point( + point: PointExpression, + evaluator: FormulaEvaluator, +) -> EvaluatedPoint: + return EvaluatedPoint( + x=evaluator.evaluate_value(point.x), + y=evaluator.evaluate_value(point.y), + ) + + +def _evaluate_handle( + handle: AdjustHandleDefinition, + evaluator: FormulaEvaluator, +) -> EvaluatedAdjustHandle: + return EvaluatedAdjustHandle( + kind=handle.kind, + position=_evaluate_point(handle.position, evaluator), + x_reference=handle.x_reference, + minimum_x=_optional_value(handle.minimum_x, evaluator), + maximum_x=_optional_value(handle.maximum_x, evaluator), + y_reference=handle.y_reference, + minimum_y=_optional_value(handle.minimum_y, evaluator), + maximum_y=_optional_value(handle.maximum_y, evaluator), + angle_reference=handle.angle_reference, + minimum_angle=_optional_value(handle.minimum_angle, evaluator), + maximum_angle=_optional_value(handle.maximum_angle, evaluator), + radius_reference=handle.radius_reference, + minimum_radius=_optional_value(handle.minimum_radius, evaluator), + maximum_radius=_optional_value(handle.maximum_radius, evaluator), + ) + + +def _evaluate_text_rectangle( + definition: PresetShapeDefinition, + evaluator: FormulaEvaluator, +) -> EvaluatedTextRectangle | None: + rectangle = definition.text_rectangle + if rectangle is None: + return None + return EvaluatedTextRectangle( + left=evaluator.evaluate_value(rectangle.left), + top=evaluator.evaluate_value(rectangle.top), + right=evaluator.evaluate_value(rectangle.right), + bottom=evaluator.evaluate_value(rectangle.bottom), + ) + + +def _optional_value( + value: str | None, + evaluator: FormulaEvaluator, +) -> float | None: + return None if value is None else evaluator.evaluate_value(value) + + +def _path_extent( + value: str | None, + shape_extent: float, + evaluator: FormulaEvaluator, +) -> float: + if value is None: + return shape_extent + resolved = evaluator.evaluate_value(value) + return shape_extent if resolved == 0 else resolved diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/semantic_hash.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/semantic_hash.py new file mode 100644 index 00000000..88cea092 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/semantic_hash.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Shape Semantic Fingerprints + +Build stable hashes for visible SVG text, generated preset previews, and +native chart/table fallback subtrees. + +Usage: + Import the fingerprint helper for the relevant semantic carrier. + +Examples: + digest = svg_text_fingerprint(group_element) + +Dependencies: + None (only uses standard library) +""" + +from __future__ import annotations + +import hashlib +import json +import re +from xml.etree import ElementTree as ET + + +_ROOT_TEXT_STYLE_ATTRS = frozenset({ + "class", + "fill", + "fill-opacity", + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "opacity", + "style", + "text-anchor", + "text-decoration", + "word-spacing", +}) + +NATIVE_FALLBACK_SHA256_ATTR = "data-pptx-fallback-sha256" +_NATIVE_FALLBACK_IGNORED_TAGS = frozenset({"metadata", "title", "desc"}) +_NATIVE_FALLBACK_IGNORED_ATTRS = frozenset({ + "id", + "data-name", + "data-ph-type", +}) +_URL_ID_RE = re.compile( + r"url\(\s*(?P['\"]?)#(?P[^)'\"\s]+)(?P=quote)\s*\)", + re.IGNORECASE, +) + + +def svg_text_fingerprint(root: ET.Element) -> str: + """Hash text content, structure, positioning, and visible typography. + + Shape-level movement is intentionally excluded: the native ``a:xfrm`` + owns that change and the original ``p:txBody`` remains valid. Text/tspan + transforms and all their non-semantic attributes remain part of the hash. + """ + + payload = { + "root_style": sorted( + (name, value) + for name, value in root.attrib.items() + if name in _ROOT_TEXT_STYLE_ATTRS + ), + "text": [ + _element_payload(element) + for element in root.iter() + if _local_name(element.tag) == "text" + ], + } + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def svg_preset_preview_fingerprint(root: ET.Element) -> str: + """Hash the complete visible preview subtree and intermediate wrappers.""" + payload = _preview_subtree(root, is_root=True, active=False) + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def svg_native_fallback_fingerprint( + root: ET.Element, + *, + document_root: ET.Element | None = None, +) -> str: + """Hash one native chart/table marker's rendering-relevant SVG subtree. + + Native metadata, editor/runtime attributes, and stable element IDs are not + fallback artwork. Reachable document-level fragment definitions are hashed + when ``document_root`` is available. Transforms remain part of the digest + because complete explicit native bounds are absolute and therefore do not + consume marker transforms; changing one must make the replacement stale. + """ + id_tokens = _native_fallback_id_tokens(root) + dependencies = _native_fallback_external_dependencies( + root, + document_root, + id_tokens, + ) + payload = _native_fallback_subtree( + root, + id_tokens=id_tokens, + ) + if dependencies: + payload = { + "marker": payload, + "external_dependencies": [ + { + "token": id_tokens[element_id], + "node": _native_fallback_subtree( + target, + id_tokens=id_tokens, + force_include=True, + ), + } + for element_id, target in dependencies + ], + } + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def svg_native_fallback_markup_fingerprint( + markup: str, + *, + root_transform: str | None = None, + external_markup: str | None = None, +) -> str: + """Hash an SVG fallback fragment through the canonical marker function.""" + if external_markup: + document_root = ET.fromstring( + '' + f"{external_markup}{markup}" + "" + ) + wrapper = document_root[-1] + else: + document_root = None + wrapper = ET.fromstring( + '' + f"{markup}" + "" + ) + if root_transform: + wrapper.set("transform", root_transform) + return svg_native_fallback_fingerprint( + wrapper, + document_root=document_root, + ) + + +def resolve_preset_preview_hash(root: ET.Element) -> str | None: + """Resolve and cross-check a logical preset group's fingerprint contract. + + The hash is duplicated on the logical group and hidden native carrier so + stripping either copy cannot disable stale-preview detection. A visible + generated preview without either hash is invalid rather than legacy SVG. + """ + has_preview = any( + element.get("data-pptx-part") + in {"geometry-preview", "geometry-detail"} + for element in root.iter() + ) + carrier_hashes = { + value + for element in root.iter() + if element.get("data-pptx-part") == "geometry" + and (value := element.get("data-pptx-preview-sha256")) is not None + } + group_hash = root.get("data-pptx-preview-sha256") + if not has_preview and not carrier_hashes and group_hash is None: + return None + if len(carrier_hashes) > 1: + raise ValueError("Native geometry carriers have inconsistent preview hashes") + carrier_hash = next(iter(carrier_hashes), None) + if ( + group_hash is not None + and carrier_hash is not None + and group_hash != carrier_hash + ): + raise ValueError("Logical group and native carrier preview hashes differ") + expected = group_hash or carrier_hash + if expected is None: + raise ValueError("Generated preset preview is missing its fingerprint") + return expected + + +def _preview_subtree( + element: ET.Element, + *, + is_root: bool, + active: bool, +) -> dict | None: + part = element.get("data-pptx-part") + contains_preview = part in {"geometry-preview", "geometry-detail"} + child_active = active or contains_preview + children = [ + payload + for child in element + if ( + payload := _preview_subtree( + child, + is_root=False, + active=child_active, + ) + ) is not None + ] + if is_root: + return {"children": children} + if not child_active and not children: + return None + return { + "tag": _local_name(element.tag), + "attrs": sorted( + (name, value) + for name, value in element.attrib.items() + if name != "id" + and name != "data-pptx-preview-sha256" + and not name.startswith("data-pptx-runtime-") + ), + "children": children, + } + + +def _native_fallback_subtree( + element: ET.Element, + *, + id_tokens: dict[str, str], + force_include: bool = False, +) -> dict | None: + tag = _local_name(element.tag) + if not force_include and _native_fallback_element_hidden(element): + return None + + attrs = [] + for raw_name, raw_value in element.attrib.items(): + name = _local_name(raw_name) + if name in _NATIVE_FALLBACK_IGNORED_ATTRS: + continue + if name.startswith("data-pptx-"): + continue + attrs.append(( + raw_name, + _normalize_native_fallback_id_refs(name, raw_value, id_tokens), + )) + + children = [] + for child in element: + child_payload = _native_fallback_subtree( + child, + id_tokens=id_tokens, + ) + if child_payload is None: + continue + entry = {"node": child_payload} + if child.tail and ( + child.tail.strip() or tag in {"text", "tspan", "textPath"} + ): + entry["tail"] = child.tail + children.append(entry) + + text = element.text or "" + payload = { + "tag": tag, + "attrs": sorted(attrs), + "children": children, + } + if text and (text.strip() or tag in {"text", "tspan", "textPath", "style"}): + payload["text"] = text + return payload + + +def _native_fallback_id_tokens( + root: ET.Element, +) -> dict[str, str]: + tokens: dict[str, str] = {} + + def visit( + element: ET.Element, + *, + is_root: bool, + path: tuple[int, ...], + ) -> None: + element_id = None if is_root else element.get("id") + if element_id and element_id not in tokens: + tokens[element_id] = "native-node-" + "-".join(map(str, path)) + canonical_index = 0 + for child in element: + if _native_fallback_element_hidden(child): + continue + visit( + child, + is_root=False, + path=(*path, canonical_index), + ) + canonical_index += 1 + + visit(root, is_root=True, path=()) + return tokens + + +def _native_fallback_external_dependencies( + marker: ET.Element, + document_root: ET.Element | None, + id_tokens: dict[str, str], +) -> list[tuple[str, ET.Element]]: + """Resolve the marker's reachable document-level fragment references.""" + if document_root is None or document_root is marker: + return [] + + marker_nodes = set(marker.iter()) + targets: dict[str, ET.Element] = {} + for element in document_root.iter(): + element_id = element.get("id") + if element_id and element_id not in targets: + targets[element_id] = element + + dependencies: list[tuple[str, ET.Element]] = [] + + def add_reference(element_id: str) -> None: + if not element_id or element_id in id_tokens: + return + target = targets.get(element_id) + if target is None or target in marker_nodes: + return + id_tokens[element_id] = f"native-external-{len(dependencies) + 1}" + dependencies.append((element_id, target)) + for nested_id in _native_fallback_fragment_references( + target, + force_include_root=True, + ): + add_reference(nested_id) + + for element_id in _native_fallback_fragment_references(marker): + add_reference(element_id) + return dependencies + + +def _native_fallback_fragment_references( + root: ET.Element, + *, + force_include_root: bool = False, +) -> list[str]: + references: list[str] = [] + seen: set[str] = set() + + def add(element_id: str) -> None: + if element_id and element_id not in seen: + seen.add(element_id) + references.append(element_id) + + def visit(element: ET.Element, *, force_include: bool) -> None: + if not force_include and _native_fallback_element_hidden(element): + return + for raw_name, raw_value in sorted(element.attrib.items()): + name = _local_name(raw_name) + if name in _NATIVE_FALLBACK_IGNORED_ATTRS: + continue + if name.startswith("data-pptx-"): + continue + for match in _URL_ID_RE.finditer(raw_value): + add(match.group("id")) + if name == "href" and raw_value.startswith("#"): + add(raw_value[1:]) + for child in element: + visit(child, force_include=False) + + visit(root, force_include=force_include_root) + return references + + +def _native_fallback_element_hidden(element: ET.Element) -> bool: + if _local_name(element.tag) in _NATIVE_FALLBACK_IGNORED_TAGS: + return True + # ``display:none`` suppresses the entire descendant subtree. SVG + # ``visibility`` is different: a descendant may explicitly restore + # ``visibility:visible``. Keep visibility-hidden content in the digest + # conservatively so such visible descendants cannot evade stale detection. + return _native_fallback_style_value(element, "display") == "none" + + +def _normalize_native_fallback_id_refs( + name: str, + value: str, + id_tokens: dict[str, str], +) -> str: + def replace_url(match: re.Match[str]) -> str: + token = id_tokens.get(match.group("id")) + return f"url(#{token})" if token is not None else match.group(0) + + normalized = _URL_ID_RE.sub(replace_url, value) + if name in {"href", "xlink:href"} and normalized.startswith("#"): + token = id_tokens.get(normalized[1:]) + if token is not None: + return f"#{token}" + return normalized + + +def _native_fallback_style_value(element: ET.Element, name: str) -> str | None: + raw = element.get(name) + if raw is not None: + return raw.strip().lower() + style = element.get("style") or "" + for declaration in style.split(";"): + if ":" not in declaration: + continue + key, value = declaration.split(":", 1) + if key.strip().lower() == name: + return value.strip().lower() + return None + + +def _element_payload(element: ET.Element) -> dict: + return { + "tag": _local_name(element.tag), + "attrs": sorted( + (name, value) + for name, value in element.attrib.items() + if not name.startswith("data-pptx-") and name != "id" + ), + "text": element.text or "", + "children": [ + { + "node": _element_payload(child), + "tail": child.tail or "", + } + for child in element + if _local_name(child.tag) in {"text", "tspan"} + ], + } + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/xml_safety.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/xml_safety.py new file mode 100644 index 00000000..c0b717e6 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_shapes/xml_safety.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Safety checks for opaque DrawingML fragments copied between PPTX parts.""" + +from __future__ import annotations + +from xml.etree import ElementTree as ET + + +RELATIONSHIPS_NS = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +) +_RELATIONSHIP_ATTRIBUTE_PREFIX = f"{{{RELATIONSHIPS_NS}}}" + + +def has_relationship_attributes(root: ET.Element) -> bool: + """Return whether a subtree contains any part-local relationship QName.""" + return any( + attribute.startswith(_RELATIONSHIP_ATTRIBUTE_PREFIX) + for element in root.iter() + for attribute in element.attrib + ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py index b1db79cf..54f84144 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py @@ -26,6 +26,7 @@ from __future__ import annotations import argparse import sys from pathlib import Path +from xml.etree import ElementTree as ET # Allow running this script from anywhere sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -37,6 +38,23 @@ from pptx_to_svg.converter import ConvertOptions configure_utf8_stdio() +def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]: + """Return slide/object labels for generated placeholders.""" + artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", []) + diagnostics: list[tuple[int, str]] = [] + for artifact in artifacts: + try: + root = ET.fromstring(artifact.svg) + except ET.ParseError: + continue + for elem in root.iter(): + if elem.get("data-pptx-route-status") != "reconstruction-only": + continue + marker_id = elem.get("id") or elem.get("data-name") or "" + diagnostics.append((artifact.index, marker_id)) + return diagnostics + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Convert .pptx to per-slide SVG by reading OOXML directly.", @@ -110,6 +128,21 @@ def main() -> int: fonts = ", ".join(f"{k}={v}" for k, v in result.theme_fonts.items()) print(f"Theme fonts: {fonts}") print(f"Slides converted: {len(result.slides)}") + reconstruction_only = _reconstruction_only_graphics(result) + if reconstruction_only: + print( + "Warning: chart placeholder(s) without a baked preview are " + "reconstruction-only. Default export keeps the placeholder; " + "--native-objects may reconstruct entries with a valid active marker:", + file=sys.stderr, + ) + for slide_index, marker_id in reconstruction_only[:20]: + print(f" slide {slide_index}: {marker_id}", file=sys.stderr) + if len(reconstruction_only) > 20: + print( + f" ... and {len(reconstruction_only) - 20} more", + file=sys.stderr, + ) print(f"Output: {output_dir}") return 0 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/__init__.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/__init__.py index 446630d7..31ed220b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/__init__.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/__init__.py @@ -8,6 +8,13 @@ Public entry: convert_pptx_to_svg(). from __future__ import annotations -from .converter import convert_pptx_to_svg - __all__ = ["convert_pptx_to_svg"] + + +def __getattr__(name: str): + """Load the public converter lazily so shared submodules stay lightweight.""" + if name != "convert_pptx_to_svg": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from .converter import convert_pptx_to_svg + + return convert_pptx_to_svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py index 5f75add3..05646f7d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py @@ -19,7 +19,10 @@ from svg_to_pptx.native_objects.chart_data import ( validate_data_label_position, ) +from .chartex_to_svg import UnsupportedChartEx, extract_native_chartex_payload +from .color_resolver import ColorPalette, find_color_elem, resolve_color from .emu_units import NS, Xfrm, ooxml_bool +from .normalized_chart_svg import SeriesVisualStyle, render_normalized_chart_svg from .ooxml_loader import OoxmlPackage, PartRef @@ -39,6 +42,7 @@ class ChartResult: native_payload: dict[str, Any] | None = None native_status: str | None = None + normalized_svg: str | None = None class _UnsupportedChart(RuntimeError): @@ -54,14 +58,27 @@ def extract_native_chart_payload( xfrm: Xfrm, slide_part: PartRef, pkg: OoxmlPackage, + palette: ColorPalette | None = None, ) -> ChartResult: - """Return native chart metadata for a supported classic chart.""" + """Return native chart metadata for a supported classic or ChartEx chart.""" if graphic_data is None: return ChartResult(native_status="unsupported-chart-reference") uri = graphic_data.attrib.get("uri", "") if uri == CHARTEX_URI or graphic_data.find("cx:chart", C_NS) is not None: - return ChartResult(native_status="unsupported-chartex") + try: + payload = extract_native_chartex_payload( + graphic_data, + xfrm, + slide_part, + pkg, + palette, + ) + except UnsupportedChartEx as exc: + return ChartResult(native_status=exc.status) + except RuntimeError: + return ChartResult(native_status="unsupported-chartex-parse") + return ChartResult(native_payload=payload) if uri != CHART_URI: return ChartResult(native_status="unsupported-chart-uri") if xfrm.rot or xfrm.flip_h or xfrm.flip_v: @@ -82,7 +99,11 @@ def extract_native_chart_payload( return ChartResult(native_status="unsupported-chart-part") try: - payload = _payload_from_chart_xml(chart_part.xml, xfrm) + payload, visual_styles = _payload_from_chart_xml( + chart_part.xml, + xfrm, + palette=palette, + ) validate_chart_payload(payload) except _UnsupportedChart as exc: return ChartResult(native_status=exc.status) @@ -90,10 +111,22 @@ def extract_native_chart_payload( return ChartResult(native_status="unsupported-chart-schema") except (TypeError, ValueError, AttributeError): return ChartResult(native_status="unsupported-chart-parse") - return ChartResult(native_payload=payload) + # The visual fallback is deliberately best-effort and isolated from the + # active native payload. A renderer defect must never downgrade a chart + # that the native schema has already validated. + try: + normalized_svg = render_normalized_chart_svg(payload, visual_styles) + except (KeyError, TypeError, ValueError, OverflowError, ArithmeticError): + normalized_svg = None + return ChartResult(native_payload=payload, normalized_svg=normalized_svg) -def _payload_from_chart_xml(chart_root: ET.Element, xfrm: Xfrm) -> dict[str, Any]: +def _payload_from_chart_xml( + chart_root: ET.Element, + xfrm: Xfrm, + *, + palette: ColorPalette | None = None, +) -> tuple[dict[str, Any], list[SeriesVisualStyle]]: plot_area = chart_root.find(".//c:plotArea", C_NS) if plot_area is None: raise _UnsupportedChart("unsupported-chart-plot") @@ -105,36 +138,444 @@ def _payload_from_chart_xml(chart_root: ET.Element, xfrm: Xfrm) -> dict[str, Any ] if not chart_nodes: raise _UnsupportedChart("unsupported-chart-plot") + date_system = chart_root.find("c:date1904", C_NS) + if date_system is not None and ( + not set(date_system.attrib).issubset({"val"}) or list(date_system) + ): + raise _UnsupportedChart("unsupported-date-system") + uses_1904_dates = _strict_axis_bool( + date_system, + date_system is not None, + ) if len(chart_nodes) > 1: - raise _UnsupportedChart("unsupported-combo-chart") - if plot_area.find("c:dateAx", C_NS) is not None: - raise _UnsupportedChart("unsupported-date-axis") + if uses_1904_dates and any( + _category_cache_is_numeric(chart) for chart in chart_nodes + ): + raise _UnsupportedChart("unsupported-date-system") + payload, visual_styles = _combo_payload( + chart_root, + plot_area, + chart_nodes, + xfrm, + palette=palette, + ) + return payload, visual_styles chart = chart_nodes[0] chart_tag = _local_name(chart.tag) + has_date_axis = plot_area.find("c:dateAx", C_NS) is not None + if uses_1904_dates and has_date_axis: + raise _UnsupportedChart("unsupported-date-system") + if has_date_axis: + _validate_canonical_series_order( + [chart], + "unsupported-chart-series-order", + ) if chart_tag in {"area3DChart", "bar3DChart", "line3DChart", "pie3DChart", "surface3DChart"}: raise _UnsupportedChart("unsupported-3d-chart") + if has_date_axis and chart_tag not in {"areaChart", "stockChart"}: + raise _UnsupportedChart("unsupported-date-axis") if chart_tag == "barChart": payload = _category_payload(chart, _bar_chart_type(chart), xfrm) - elif chart_tag in {"areaChart", "lineChart", "ofPieChart", "pieChart", "doughnutChart"}: + elif chart_tag in { + "areaChart", + "doughnutChart", + "lineChart", + "ofPieChart", + "pieChart", + "radarChart", + }: chart_type = { "areaChart": "area", + "doughnutChart": "doughnut", "lineChart": "line", "ofPieChart": "of_pie", "pieChart": "pie", - "doughnutChart": "doughnut", + "radarChart": "radar", }[chart_tag] - payload = _category_payload(chart, chart_type, xfrm) + category_kind = "date" if has_date_axis else "text" + if chart_tag == "radarChart" and _category_cache_is_numeric(chart): + category_kind = "numeric" + payload = _category_payload( + chart, + chart_type, + xfrm, + category_kind=category_kind, + ) + if chart_tag == "radarChart": + if category_kind == "numeric": + payload["categories"] = [ + str(value) for value in payload["categories"] + ] + payload["radar_style"] = _effective_radar_style(chart) elif chart_tag == "scatterChart": payload = _xy_payload(chart, "scatter", xfrm) + payload["axes"] = _xy_axis_contract(plot_area, chart) elif chart_tag == "bubbleChart": payload = _xy_payload(chart, "bubble", xfrm) + payload["axes"] = _xy_axis_contract(plot_area, chart) + elif chart_tag == "stockChart": + payload, visual_styles = _stock_payload( + chart_root, + plot_area, + chart, + xfrm, + palette=palette, + ) + return payload, visual_styles else: raise _UnsupportedChart("unsupported-chart-type") - _validate_chart_semantics(payload, plot_area, chart) + if has_date_axis: + payload["axes"] = _single_axis_contract( + plot_area, + chart, + category_kind="date", + cross_between="midCat", + ) + expected_category_format = payload["axes"]["category"].get( + "number_format", + "m/d/yyyy", + ) + if _numeric_category_cache_format(chart) != expected_category_format: + raise _UnsupportedChart("unsupported-chart-category-format") + + visual_styles = _validate_chart_semantics( + payload, + plot_area, + chart, + palette=palette, + validate_axes=not ( + has_date_axis or chart_tag in {"bubbleChart", "scatterChart"} + ), + ) _apply_chart_metadata(payload, chart_root, plot_area, chart) - return payload + return payload, visual_styles + + +def _combo_payload( + chart_root: ET.Element, + plot_area: ET.Element, + chart_nodes: list[ET.Element], + xfrm: Xfrm, + *, + palette: ColorPalette | None, +) -> tuple[dict[str, Any], list[SeriesVisualStyle]]: + series_indices_by_plot = [ + _plot_series_indices(chart, "unsupported-combo-series-order") + for chart in chart_nodes + ] + flat_series_indices = [ + index + for series_indices in series_indices_by_plot + for index in series_indices + ] + if sorted(flat_series_indices) != list(range(len(flat_series_indices))): + raise _UnsupportedChart("unsupported-combo-series-order") + axes_by_id = _axis_nodes_by_id(plot_area) + if not axes_by_id: + raise _UnsupportedChart("unsupported-combo-chart") + axes: dict[str, dict[str, Any]] = {} + axis_pairs: dict[str, tuple[str, str]] = {} + primary_categories: list[Any] | None = None + primary_categories_are_numeric = False + plots: list[dict[str, Any]] = [] + visual_styles: list[SeriesVisualStyle] = [] + colors: list[str] = [] + referenced_axis_ids: set[str] = set() + + for chart, series_indices in zip(chart_nodes, series_indices_by_plot): + chart_tag = _local_name(chart.tag) + if chart_tag == "barChart": + if _bar_chart_type(chart) != "column": + raise _UnsupportedChart("unsupported-combo-chart") + chart_type = "column" + elif chart_tag == "lineChart": + chart_type = "line" + elif chart_tag == "areaChart": + chart_type = "area" + else: + raise _UnsupportedChart("unsupported-combo-chart") + + cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(chart, axes_by_id) + if _local_name(cat_axis.tag) != "catAx": + raise _UnsupportedChart("unsupported-combo-chart") + val_position = _element_val(val_axis.find("c:axPos", C_NS)) + if val_position == "l": + axis_name = "primary" + category_role = "category" + value_role = "value" + allowed_value_crosses = {"autoZero"} + elif val_position == "r": + axis_name = "secondary" + category_role = "secondary_category" + value_role = "secondary_value" + allowed_value_crosses = {"max"} + else: + raise _UnsupportedChart("unsupported-combo-chart") + + axis_pair = (cat_id, val_id) + previous_pair = axis_pairs.get(axis_name) + if previous_pair is not None and previous_pair != axis_pair: + raise _UnsupportedChart("unsupported-combo-chart") + if any( + set(existing_pair).intersection(axis_pair) + for name, existing_pair in axis_pairs.items() + if name != axis_name + ): + raise _UnsupportedChart("unsupported-combo-chart") + axis_pairs[axis_name] = axis_pair + + category_axis = _axis_config_from_xml( + cat_axis, + role=category_role, + expected_cross_axis_id=val_id, + allowed_crosses={"autoZero"}, + expected_cross_between=None, + ) + value_axis = _axis_config_from_xml( + val_axis, + role=value_role, + expected_cross_axis_id=cat_id, + allowed_crosses=allowed_value_crosses, + expected_cross_between="between", + ) + for role, config in ( + (category_role, category_axis), + (value_role, value_axis), + ): + if role in axes and axes[role] != config: + raise _UnsupportedChart("unsupported-combo-chart") + axes[role] = config + referenced_axis_ids.update((cat_id, val_id)) + + categories_are_numeric = _category_cache_is_numeric(chart) + if categories_are_numeric: + expected_category_format = category_axis.get( + "number_format", + "General", + ) + if _numeric_category_cache_format(chart) != expected_category_format: + raise _UnsupportedChart("unsupported-combo-category-format") + plot_payload = _category_payload( + chart, + chart_type, + xfrm, + category_kind="numeric" if categories_are_numeric else "text", + ) + if axis_name == "primary" and primary_categories is None: + primary_categories = list(plot_payload["categories"]) + primary_categories_are_numeric = categories_are_numeric + plot_styles = _validate_chart_semantics( + plot_payload, + plot_area, + chart, + palette=palette, + validate_axes=False, + ) + visual_styles.extend(plot_styles) + colors.extend( + str(color) + for color in plot_payload.get("style", {}).get("colors", []) + ) + _apply_plot_data_labels(plot_payload, chart) + plot_entry: dict[str, Any] = { + "axis": axis_name, + "categories": list(plot_payload["categories"]), + "category_numeric": categories_are_numeric, + "series": plot_payload["series"], + "series_indices": series_indices, + "type": chart_type, + } + for key in ("data_labels", "grouping", "line_style"): + if plot_payload.get(key) is not None: + plot_entry[key] = plot_payload[key] + plots.append(plot_entry) + + if primary_categories is None or not plots: + raise _UnsupportedChart("unsupported-combo-chart") + if referenced_axis_ids != set(axes_by_id): + raise _UnsupportedChart("unsupported-combo-chart") + + category_layouts = { + ( + bool(plot["category_numeric"]), + tuple(plot["categories"]), + ) + for plot in plots + } + if len(category_layouts) == 1: + for plot in plots: + plot.pop("categories") + plot.pop("category_numeric") + payload: dict[str, Any] = { + **_bounds_payload(xfrm), + "axes": axes, + "categories": primary_categories, + "plots": plots, + "type": "combo", + } + if primary_categories_are_numeric: + payload["category_numeric"] = True + if colors: + payload["style"] = {"colors": colors} + _apply_chart_metadata( + payload, + chart_root, + plot_area, + chart_nodes[0], + include_plot_labels=False, + ) + return payload, visual_styles + + +def _validate_stock_semantics( + plot_area: ET.Element, + chart: ET.Element, +) -> None: + allowed_children = {"axId", "dLbls", "hiLowLines", "ser", "upDownBars"} + if any(_local_name(child.tag) not in allowed_children for child in chart): + raise _UnsupportedChart("unsupported-stock-chart") + if plot_area.find("c:dTable", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-data-table") + for tag in ("dropLines", "errBars", "trendline"): + if chart.find(f".//c:{tag}", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-analysis-features") + if _data_labels_payload(chart.find("c:dLbls", C_NS)) is not None: + raise _UnsupportedChart("unsupported-chart-data-labels") + if len(chart.findall("c:dLbls", C_NS)) > 1: + raise _UnsupportedChart("unsupported-stock-chart") + + hi_low_lines = chart.findall("c:hiLowLines", C_NS) + up_down_bars = chart.findall("c:upDownBars", C_NS) + if len(hi_low_lines) != 1 or len(up_down_bars) != 1: + raise _UnsupportedChart("unsupported-stock-chart") + hi_low_styles = hi_low_lines[0].findall("c:spPr", C_NS) + if ( + hi_low_lines[0].attrib + or len(hi_low_styles) > 1 + or any(_local_name(child.tag) != "spPr" for child in hi_low_lines[0]) + ): + raise _UnsupportedChart("unsupported-stock-chart") + up_down = up_down_bars[0] + if up_down.attrib or any( + _local_name(child.tag) not in {"downBars", "gapWidth", "upBars"} + for child in up_down + ): + raise _UnsupportedChart("unsupported-stock-chart") + gap_widths = up_down.findall("c:gapWidth", C_NS) + if ( + len(gap_widths) != 1 + or set(gap_widths[0].attrib) != {"val"} + or list(gap_widths[0]) + or _element_val(gap_widths[0]) != "150" + ): + raise _UnsupportedChart("unsupported-stock-chart") + for tag in ("upBars", "downBars"): + nodes = up_down.findall(f"c:{tag}", C_NS) + styles = nodes[0].findall("c:spPr", C_NS) if nodes else [] + if ( + len(nodes) != 1 + or nodes[0].attrib + or len(styles) > 1 + or any(_local_name(child.tag) != "spPr" for child in nodes[0]) + ): + raise _UnsupportedChart("unsupported-stock-chart") + + allowed_series_children = { + "cat", "extLst", "idx", "marker", "order", "smooth", "spPr", + "tx", "val", + } + for series_node in chart.findall("c:ser", C_NS): + if any( + _local_name(child.tag) not in allowed_series_children + for child in series_node + ): + raise _UnsupportedChart("unsupported-stock-chart") + for child_name in allowed_series_children - {"extLst"}: + if len(series_node.findall(f"c:{child_name}", C_NS)) > 1: + raise _UnsupportedChart("unsupported-stock-chart") + marker = series_node.find("c:marker", C_NS) + symbol = marker.find("c:symbol", C_NS) if marker is not None else None + if ( + marker is not None + and ( + marker.attrib + or len(marker) != 1 + or symbol is None + or set(symbol.attrib) != {"val"} + or list(symbol) + or _element_val(symbol) != "none" + ) + ): + raise _UnsupportedChart("unsupported-stock-chart") + smooth = series_node.find("c:smooth", C_NS) + if smooth is not None: + if set(smooth.attrib) != {"val"} or list(smooth): + raise _UnsupportedChart("unsupported-stock-chart") + if _strict_axis_bool(smooth, False): + raise _UnsupportedChart("unsupported-stock-chart") + for child_name in ("idx", "order"): + child = series_node.find(f"c:{child_name}", C_NS) + if child is None or set(child.attrib) != {"val"} or list(child): + raise _UnsupportedChart("unsupported-stock-chart") + + +def _stock_payload( + chart_root: ET.Element, + plot_area: ET.Element, + chart: ET.Element, + xfrm: Xfrm, + *, + palette: ColorPalette | None, +) -> tuple[dict[str, Any], list[SeriesVisualStyle]]: + series_nodes = chart.findall("c:ser", C_NS) + if len(series_nodes) != 4: + raise _UnsupportedChart("unsupported-stock-chart") + categories = _numeric_values(series_nodes[0].find("c:cat", C_NS)) + if not categories: + raise _UnsupportedChart("unsupported-chart-cache") + series: list[dict[str, Any]] = [] + for index, series_node in enumerate(series_nodes, start=1): + expected_index = str(index - 1) + if ( + _element_val(series_node.find("c:idx", C_NS)) != expected_index + or _element_val(series_node.find("c:order", C_NS)) != expected_index + ): + raise _UnsupportedChart("unsupported-stock-chart") + if _numeric_values(series_node.find("c:cat", C_NS)) != categories: + raise _UnsupportedChart("unsupported-chart-cache") + values = _numeric_values(series_node.find("c:val", C_NS)) + if len(values) != len(categories): + raise _UnsupportedChart("unsupported-chart-cache") + series.append({ + "name": _series_name(series_node, index), + "values": values, + }) + axes = _single_axis_contract( + plot_area, + chart, + category_kind="date", + cross_between="between", + ) + expected_category_format = axes["category"].get( + "number_format", + "m/d/yyyy", + ) + category_cache_format = _numeric_category_cache_format(chart) + if category_cache_format not in {"General", expected_category_format}: + raise _UnsupportedChart("unsupported-chart-category-format") + _validate_stock_semantics(plot_area, chart) + payload: dict[str, Any] = { + **_bounds_payload(xfrm), + "axes": axes, + "categories": categories, + "series": series, + "type": "stock", + } + visual_styles = _chart_visual_styles(payload, chart, palette) + _apply_chart_metadata(payload, chart_root, plot_area, chart) + return payload, visual_styles def _canonical_srgb_color(fill: ET.Element | None) -> str: @@ -155,111 +596,723 @@ def _canonical_srgb_color(fill: ET.Element | None) -> str: return color.upper() -def _canonical_sp_pr_color(sp_pr: ET.Element, *, line_visible: bool) -> str: - """Extract a color only from the exact series style emitted here.""" +@dataclass(frozen=True) +class _LinePaint: + color: str | None + opacity: float + width: float + cap: str + visible: bool + automatic: bool + + +@dataclass(frozen=True) +class _ShapePaint: + fill: str | None + fill_opacity: float + fill_explicit: bool + line: _LinePaint | None + + +@dataclass(frozen=True) +class _MarkerPaint: + symbol: str | None + size: float + shape: _ShapePaint + + +_FALLBACK_CHART_COLORS = ( + "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", + "#5B9BD5", "#70AD47", "#264478", "#9E480E", +) +_COLOR_MODIFIERS_WITH_VALUE = { + "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff", "lumMod", + "lumOff", "satMod", "satOff", "shade", "tint", +} +_COLOR_MODIFIERS_WITHOUT_VALUE = {"comp", "gray", "inv"} + + +def _resolved_solid_fill( + fill: ET.Element, + palette: ColorPalette | None, +) -> tuple[str, float]: + if fill.attrib or len(fill) != 1: + raise _UnsupportedChart("unsupported-chart-series-style") + color = find_color_elem(fill) + if color is None or color is not fill[0]: + raise _UnsupportedChart("unsupported-chart-series-style") + tag = _local_name(color.tag) + allowed_attrs = { + "srgbClr": {"val"}, + "schemeClr": {"val"}, + "sysClr": {"val", "lastClr"}, + "prstClr": {"val"}, + "hslClr": {"hue", "sat", "lum"}, + "scrgbClr": {"r", "g", "b"}, + }.get(tag) + if allowed_attrs is None or not set(color.attrib).issubset(allowed_attrs): + raise _UnsupportedChart("unsupported-chart-series-style") + required = { + "srgbClr": {"val"}, "schemeClr": {"val"}, "prstClr": {"val"}, + "hslClr": {"hue", "sat", "lum"}, "scrgbClr": {"r", "g", "b"}, + }.get(tag, set()) + if not required.issubset(color.attrib): + raise _UnsupportedChart("unsupported-chart-series-style") + for modifier in color: + modifier_name = _local_name(modifier.tag) + if modifier_name in _COLOR_MODIFIERS_WITH_VALUE: + if set(modifier.attrib) != {"val"} or list(modifier): + raise _UnsupportedChart("unsupported-chart-series-style") + try: + modifier_value = float(modifier.attrib["val"]) + except (TypeError, ValueError, OverflowError): + raise _UnsupportedChart("unsupported-chart-series-style") from None + if not math.isfinite(modifier_value): + raise _UnsupportedChart("unsupported-chart-series-style") + elif modifier_name in _COLOR_MODIFIERS_WITHOUT_VALUE: + if modifier.attrib or list(modifier): + raise _UnsupportedChart("unsupported-chart-series-style") + else: + raise _UnsupportedChart("unsupported-chart-series-style") + try: + resolved, alpha = resolve_color(color, palette) + except (TypeError, ValueError, OverflowError): + raise _UnsupportedChart("unsupported-chart-series-style") from None + if resolved is None or not math.isfinite(alpha): + raise _UnsupportedChart("unsupported-chart-series-style") + return resolved.upper(), max(0.0, min(1.0, alpha)) + + +def _resolved_line( + line: ET.Element, + palette: ColorPalette | None, +) -> _LinePaint: + if not set(line.attrib).issubset({"w", "cap"}): + raise _UnsupportedChart("unsupported-chart-series-style") + raw_width = line.attrib.get("w") + if raw_width is None: + width = 1.5 + else: + if re.fullmatch(r"[0-9]+", raw_width) is None: + raise _UnsupportedChart("unsupported-chart-series-style") + width = int(raw_width) / 9525.0 + if not 0 <= width <= 1000: + raise _UnsupportedChart("unsupported-chart-series-style") + cap_aliases = {None: "round", "rnd": "round", "sq": "square", "flat": "butt"} + cap = cap_aliases.get(line.attrib.get("cap")) + if cap is None: + raise _UnsupportedChart("unsupported-chart-series-style") + + fill_nodes: list[ET.Element] = [] + for child in line: + name = _local_name(child.tag) + if name in {"solidFill", "noFill"}: + fill_nodes.append(child) + elif name == "prstDash": + if child.attrib != {"val": "solid"} or list(child): + raise _UnsupportedChart("unsupported-chart-series-style") + elif name in {"round", "bevel"}: + if child.attrib or list(child): + raise _UnsupportedChart("unsupported-chart-series-style") + elif name == "miter": + if not set(child.attrib).issubset({"lim"}) or list(child): + raise _UnsupportedChart("unsupported-chart-series-style") + raw_limit = child.attrib.get("lim") + if raw_limit is not None and re.fullmatch(r"[0-9]+", raw_limit) is None: + raise _UnsupportedChart("unsupported-chart-series-style") + else: + raise _UnsupportedChart("unsupported-chart-series-style") + if len(fill_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-series-style") + if not fill_nodes: + return _LinePaint(None, 1.0, width, cap, True, True) + fill = fill_nodes[0] + if _local_name(fill.tag) == "noFill": + if fill.attrib or list(fill): + raise _UnsupportedChart("unsupported-chart-series-style") + return _LinePaint(None, 1.0, width, cap, False, False) + color, opacity = _resolved_solid_fill(fill, palette) + return _LinePaint(color, opacity, width, cap, True, False) + + +def _resolved_shape_paint( + sp_pr: ET.Element | None, + palette: ColorPalette | None, +) -> _ShapePaint: + if sp_pr is None: + return _ShapePaint(None, 1.0, False, None) if sp_pr.attrib: raise _UnsupportedChart("unsupported-chart-series-style") - children = list(sp_pr) - if [_local_name(child.tag) for child in children] != ["solidFill", "ln"]: - raise _UnsupportedChart("unsupported-chart-series-style") - color = _canonical_srgb_color(children[0]) - line = children[1] - if line.attrib or len(line) != 1: - raise _UnsupportedChart("unsupported-chart-series-style") - line_child = line[0] - if line_visible: - if _local_name(line_child.tag) != "solidFill": + fill_nodes: list[ET.Element] = [] + line_nodes: list[ET.Element] = [] + for child in sp_pr: + name = _local_name(child.tag) + if name in {"solidFill", "noFill"}: + fill_nodes.append(child) + elif name == "ln": + line_nodes.append(child) + elif name == "effectLst": + if child.attrib or list(child): + raise _UnsupportedChart("unsupported-chart-series-style") + else: raise _UnsupportedChart("unsupported-chart-series-style") - if _canonical_srgb_color(line_child) != color: - raise _UnsupportedChart("unsupported-chart-series-style") - elif ( - _local_name(line_child.tag) != "noFill" - or line_child.attrib - or list(line_child) + if len(fill_nodes) > 1 or len(line_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-series-style") + fill_color: str | None = None + fill_opacity = 1.0 + fill_explicit = bool(fill_nodes) + if fill_nodes: + fill = fill_nodes[0] + if _local_name(fill.tag) == "noFill": + if fill.attrib or list(fill): + raise _UnsupportedChart("unsupported-chart-series-style") + else: + fill_color, fill_opacity = _resolved_solid_fill(fill, palette) + line = _resolved_line(line_nodes[0], palette) if line_nodes else None + return _ShapePaint(fill_color, fill_opacity, fill_explicit, line) + + +def _resolved_marker( + marker: ET.Element | None, + palette: ColorPalette | None, +) -> _MarkerPaint: + if marker is None: + return _MarkerPaint(None, 5.0, _resolved_shape_paint(None, palette)) + if marker.attrib: + raise _UnsupportedChart("unsupported-chart-series-style") + allowed = {"symbol", "size", "spPr"} + names = [_local_name(child.tag) for child in marker] + if any(name not in allowed for name in names) or len(names) != len(set(names)): + raise _UnsupportedChart("unsupported-chart-series-style") + symbol_node = marker.find("c:symbol", C_NS) + symbol = _element_val(symbol_node) + if symbol_node is not None and ( + set(symbol_node.attrib) != {"val"} or list(symbol_node) ): raise _UnsupportedChart("unsupported-chart-series-style") - return color + if symbol not in {None, "circle", "none"}: + raise _UnsupportedChart("unsupported-chart-series-style") + size_node = marker.find("c:size", C_NS) + size = 5.0 + if size_node is not None: + raw_size = size_node.attrib.get("val", "") + if set(size_node.attrib) != {"val"} or list(size_node) or re.fullmatch(r"[0-9]+", raw_size) is None: + raise _UnsupportedChart("unsupported-chart-series-style") + size = float(raw_size) + if not 2 <= size <= 72: + raise _UnsupportedChart("unsupported-chart-series-style") + return _MarkerPaint( + symbol, + size, + _resolved_shape_paint(marker.find("c:spPr", C_NS), palette), + ) -def _canonical_chart_colors(payload: dict[str, Any], plot: ET.Element) -> list[str] | None: - """Recover colors from canonical series or pie data-point formatting.""" +def _automatic_color(palette: ColorPalette | None, index: int) -> str: + if palette is not None: + resolved = palette.resolve_scheme(f"accent{index % 6 + 1}") + if resolved: + return f"#{resolved.upper()}" + return _FALLBACK_CHART_COLORS[index % len(_FALLBACK_CHART_COLORS)] + + +def _line_color(line: _LinePaint | None, default: str | None) -> str | None: + if line is None: + return default + if not line.visible: + return None + return default if line.automatic else line.color + + +def _series_visual_style( + shape: _ShapePaint, + marker: _MarkerPaint, + *, + chart_type: str, + auto_color: str, +) -> SeriesVisualStyle: + fill_default = auto_color if chart_type in {"area", "bar", "bubble", "column"} else None + fill = shape.fill if shape.fill_explicit else fill_default + if chart_type in {"line", "scatter"}: + line_default = auto_color + elif chart_type == "area": + line_default = auto_color + else: + line_default = None + stroke = _line_color(shape.line, line_default) + marker_fill = marker.shape.fill if marker.shape.fill_explicit else auto_color + marker_stroke = _line_color(marker.shape.line, auto_color) + line = shape.line + marker_line = marker.shape.line + return SeriesVisualStyle( + fill=fill, + fill_opacity=shape.fill_opacity, + stroke=stroke, + stroke_opacity=line.opacity if line is not None else 1.0, + stroke_width=line.width if line is not None else 1.5, + line_cap=line.cap if line is not None else "round", + marker_fill=marker_fill, + marker_fill_opacity=marker.shape.fill_opacity, + marker_stroke=marker_stroke, + marker_stroke_opacity=marker_line.opacity if marker_line is not None else 1.0, + marker_stroke_width=marker_line.width if marker_line is not None else 1.0, + marker_size=marker.size, + ) + + +def _pie_visual_styles( + payload: dict[str, Any], + series: ET.Element, + palette: ColorPalette | None, +) -> list[SeriesVisualStyle]: + chart_type = payload["type"] + expected_count = len(payload["categories"]) + (1 if chart_type == "of_pie" else 0) + base_shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette) + point_nodes = series.findall("c:dPt", C_NS) + points: dict[int, _ShapePaint] = {} + for point in point_nodes: + if point.attrib: + raise _UnsupportedChart("unsupported-chart-series-style") + allowed = {"idx", "bubble3D", "explosion", "spPr"} + names = [_local_name(child.tag) for child in point] + if any(name not in allowed for name in names) or len(names) != len(set(names)): + raise _UnsupportedChart("unsupported-chart-series-style") + idx = point.find("c:idx", C_NS) + if idx is None or set(idx.attrib) != {"val"} or list(idx): + raise _UnsupportedChart("unsupported-chart-series-style") + try: + point_index = int(idx.attrib["val"]) + except ValueError: + raise _UnsupportedChart("unsupported-chart-series-style") from None + bubble_3d = point.find("c:bubble3D", C_NS) + if bubble_3d is not None and ( + not set(bubble_3d.attrib).issubset({"val"}) + or list(bubble_3d) + or ooxml_bool(bubble_3d.attrib.get("val"), True) + ): + raise _UnsupportedChart("unsupported-chart-series-style") + if point_index < 0 or point_index >= expected_count or point_index in points: + raise _UnsupportedChart("unsupported-chart-series-style") + points[point_index] = _resolved_shape_paint(point.find("c:spPr", C_NS), palette) + if points and set(points) != set(range(expected_count)): + raise _UnsupportedChart("unsupported-chart-series-style") + + styles: list[SeriesVisualStyle] = [] + for index in range(expected_count): + auto = _automatic_color(palette, index) + point_shape = points.get(index, base_shape) + fill = point_shape.fill if point_shape.fill_explicit else auto + stroke = _line_color(point_shape.line, "#FFFFFF") + line = point_shape.line + styles.append( + SeriesVisualStyle( + fill=fill, + fill_opacity=point_shape.fill_opacity, + stroke=stroke, + stroke_opacity=line.opacity if line is not None else 1.0, + stroke_width=line.width if line is not None else 1.0, + line_cap=line.cap if line is not None else "round", + marker_fill=fill, + marker_stroke=stroke, + ) + ) + return styles + + +def _chart_visual_styles( + payload: dict[str, Any], + plot: ET.Element, + palette: ColorPalette | None, +) -> list[SeriesVisualStyle]: chart_type = payload["type"] series_nodes = plot.findall("c:ser", C_NS) if chart_type in {"pie", "doughnut", "of_pie"}: - if any(series.find("c:spPr", C_NS) is not None for series in series_nodes): + if len(series_nodes) != 1: raise _UnsupportedChart("unsupported-chart-series-style") - point_nodes = [ - point - for series in series_nodes - for point in series.findall("c:dPt", C_NS) - ] - if not point_nodes: - return None - expected_count = len(payload["categories"]) + (1 if chart_type == "of_pie" else 0) - colors: dict[int, str] = {} - for point in point_nodes: - if point.attrib or [_local_name(child.tag) for child in point] != ["idx", "spPr"]: - raise _UnsupportedChart("unsupported-chart-series-style") - idx = point.find("c:idx", C_NS) - sp_pr = point.find("c:spPr", C_NS) - try: - point_index = int(idx.attrib.get("val", "")) if idx is not None else -1 - except ValueError: - raise _UnsupportedChart("unsupported-chart-series-style") from None - if ( - idx is None - or set(idx.attrib) != {"val"} - or list(idx) - or sp_pr is None - or point_index in colors - ): - raise _UnsupportedChart("unsupported-chart-series-style") - colors[point_index] = _canonical_sp_pr_color(sp_pr, line_visible=True) - if set(colors) != set(range(expected_count)): + styles = _pie_visual_styles(payload, series_nodes[0], palette) + else: + if any(series.find("c:dPt", C_NS) is not None for series in series_nodes): raise _UnsupportedChart("unsupported-chart-series-style") - return [f"#{colors[index]}" for index in range(expected_count)] - - if any(series.find("c:dPt", C_NS) is not None for series in series_nodes): - raise _UnsupportedChart("unsupported-chart-series-style") - sp_pr_nodes = [series.find("c:spPr", C_NS) for series in series_nodes] - if not any(sp_pr is not None for sp_pr in sp_pr_nodes): - return None - if any(sp_pr is None for sp_pr in sp_pr_nodes): - raise _UnsupportedChart("unsupported-chart-series-style") - line_visible = not ( - chart_type == "scatter" - and payload.get("scatter_style", "marker") == "marker" - ) - return [ - f"#{_canonical_sp_pr_color(sp_pr, line_visible=line_visible)}" - for sp_pr in sp_pr_nodes - if sp_pr is not None + styles = [] + for index, series in enumerate(series_nodes): + auto = _automatic_color(palette, index) + shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette) + marker = _resolved_marker(series.find("c:marker", C_NS), palette) + styles.append( + _series_visual_style( + shape, + marker, + chart_type=chart_type, + auto_color=auto, + ) + ) + colors = [ + style.fill or style.stroke or style.marker_fill or _automatic_color(palette, index) + for index, style in enumerate(styles) ] - - -def _validate_chart_semantics( - payload: dict[str, Any], - plot_area: ET.Element, - plot: ET.Element, -) -> None: - """Reject valid chart features the compact marker cannot reproduce.""" - chart_type = payload["type"] - colors = _canonical_chart_colors(payload, plot) if colors: payload["style"] = {"colors": colors} - for tag in ( - "trendline", "errBars", "dropLines", "hiLowLines", "upDownBars", - ): - if plot.find(f".//c:{tag}", C_NS) is not None: - raise _UnsupportedChart("unsupported-chart-analysis-features") - if plot_area.find("c:dTable", C_NS) is not None: - raise _UnsupportedChart("unsupported-chart-data-table") - ser_lines = plot.find("c:serLines", C_NS) - if ser_lines is not None and ( - chart_type != "of_pie" or ser_lines.attrib or list(ser_lines) - ): - raise _UnsupportedChart("unsupported-chart-analysis-features") + return styles + +def _strict_axis_bool(elem: ET.Element | None, default: bool) -> bool: + if elem is None: + return default + raw = elem.attrib.get("val") + if raw is None: + return default + key = raw.strip().lower() + if key in {"1", "on", "true"}: + return True + if key in {"0", "false", "off"}: + return False + raise _UnsupportedChart("unsupported-chart-axis-options") + + +def _validate_canonical_series_order( + plots: list[ET.Element], + status: str, +) -> None: + expected_index = 0 + for plot in plots: + for series in plot.findall("c:ser", C_NS): + expected = str(expected_index) + for child_name in ("idx", "order"): + child = series.find(f"c:{child_name}", C_NS) + if ( + child is None + or set(child.attrib) != {"val"} + or list(child) + or _element_val(child) != expected + ): + raise _UnsupportedChart(status) + expected_index += 1 + + +def _plot_series_indices(plot: ET.Element, status: str) -> list[int]: + indices: list[int] = [] + for series in plot.findall("c:ser", C_NS): + values: list[int] = [] + for child_name in ("idx", "order"): + child = series.find(f"c:{child_name}", C_NS) + raw_value = _element_val(child) + if ( + child is None + or set(child.attrib) != {"val"} + or list(child) + or raw_value is None + or re.fullmatch(r"[0-9]+", raw_value) is None + ): + raise _UnsupportedChart(status) + values.append(int(raw_value)) + if values[0] != values[1]: + raise _UnsupportedChart(status) + indices.append(values[0]) + if not indices or len(set(indices)) != len(indices): + raise _UnsupportedChart(status) + return indices + + +def _axis_number(elem: ET.Element | None) -> int | float | None: + if elem is None: + return None + raw = elem.attrib.get("val") + try: + number = float(raw) if raw is not None else math.nan + except (TypeError, ValueError, OverflowError): + raise _UnsupportedChart("unsupported-chart-axis-options") from None + if not math.isfinite(number): + raise _UnsupportedChart("unsupported-chart-axis-options") + return int(number) if number.is_integer() else number + + +def _axis_nodes_by_id(plot_area: ET.Element) -> dict[str, ET.Element]: + if plot_area.find("c:serAx", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-axis-options") + axes: dict[str, ET.Element] = {} + for tag in ("catAx", "dateAx", "valAx"): + for axis in plot_area.findall(f"c:{tag}", C_NS): + axis_id = _element_val(axis.find("c:axId", C_NS)) + if not axis_id or axis_id in axes: + raise _UnsupportedChart("unsupported-chart-axis-options") + axes[axis_id] = axis + return axes + + +def _plot_axis_pair( + plot: ET.Element, + axes_by_id: dict[str, ET.Element], +) -> tuple[str, ET.Element, str, ET.Element]: + axis_ids = [_element_val(elem) for elem in plot.findall("c:axId", C_NS)] + if len(axis_ids) != 2 or any(not axis_id for axis_id in axis_ids): + raise _UnsupportedChart("unsupported-chart-axis-options") + if len(set(axis_ids)) != 2: + raise _UnsupportedChart("unsupported-chart-axis-options") + resolved = [(axis_id, axes_by_id.get(str(axis_id))) for axis_id in axis_ids] + if any(axis is None for _, axis in resolved): + raise _UnsupportedChart("unsupported-chart-axis-options") + category_axes = [ + (str(axis_id), axis) + for axis_id, axis in resolved + if axis is not None and _local_name(axis.tag) in {"catAx", "dateAx"} + ] + value_axes = [ + (str(axis_id), axis) + for axis_id, axis in resolved + if axis is not None and _local_name(axis.tag) == "valAx" + ] + if len(category_axes) != 1 or len(value_axes) != 1: + raise _UnsupportedChart("unsupported-chart-axis-options") + cat_id, cat_axis = category_axes[0] + val_id, val_axis = value_axes[0] + return cat_id, cat_axis, val_id, val_axis + + +def _axis_config_from_xml( + axis: ET.Element, + *, + role: str, + expected_cross_axis_id: str, + allowed_crosses: set[str], + expected_cross_between: str | None, +) -> dict[str, Any]: + axis_kind = _local_name(axis.tag) + allowed_children = { + "auto", "axId", "axPos", "baseTimeUnit", "crossAx", "crossBetween", + "crosses", "delete", "lblAlgn", "lblOffset", "majorGridlines", + "majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl", + "numFmt", "scaling", "spPr", "tickLblPos", "title", "txPr", + } + if any(_local_name(child.tag) not in allowed_children for child in axis): + raise _UnsupportedChart("unsupported-chart-axis-options") + if axis.attrib: + raise _UnsupportedChart("unsupported-chart-axis-options") + for child_name in allowed_children: + if len(axis.findall(f"c:{child_name}", C_NS)) > 1: + raise _UnsupportedChart("unsupported-chart-axis-options") + simple_children = { + "auto", "axId", "axPos", "baseTimeUnit", "crossAx", + "crossBetween", "crosses", "delete", "lblAlgn", "lblOffset", + "majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl", + "tickLblPos", + } + if any( + _local_name(child.tag) in simple_children + and (set(child.attrib) != {"val"} or list(child)) + for child in axis + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + if axis.find("c:minorGridlines", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-axis-options") + + scaling = axis.find("c:scaling", C_NS) + config: dict[str, Any] = {} + if scaling is not None: + if scaling.attrib or any( + _local_name(child.tag) not in {"max", "min", "orientation"} + for child in scaling + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + for child_name in ("orientation", "max", "min"): + if len(scaling.findall(f"c:{child_name}", C_NS)) > 1: + raise _UnsupportedChart("unsupported-chart-axis-options") + if any( + set(child.attrib) != {"val"} or list(child) + for child in scaling + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + orientation = _element_val(scaling.find("c:orientation", C_NS)) or "minMax" + if orientation not in {"maxMin", "minMax"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + config["reverse"] = orientation == "maxMin" + minimum = _axis_number(scaling.find("c:min", C_NS)) + maximum = _axis_number(scaling.find("c:max", C_NS)) + if minimum is not None: + config["minimum"] = minimum + if maximum is not None: + config["maximum"] = maximum + if minimum is not None and maximum is not None and minimum >= maximum: + raise _UnsupportedChart("unsupported-chart-axis-options") + else: + config["reverse"] = False + + position_aliases = {"b": "bottom", "l": "left", "r": "right", "t": "top"} + position = position_aliases.get(_element_val(axis.find("c:axPos", C_NS)) or "") + if position is None: + raise _UnsupportedChart("unsupported-chart-axis-options") + if role in {"category", "secondary_category"}: + if position not in {"bottom", "top"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + config["kind"] = "date" if axis_kind == "dateAx" else "text" + elif role == "x": + if position not in {"bottom", "top"} or axis_kind != "valAx": + raise _UnsupportedChart("unsupported-chart-axis-options") + config["kind"] = "value" + else: + if position not in {"left", "right"} or axis_kind != "valAx": + raise _UnsupportedChart("unsupported-chart-axis-options") + config["kind"] = "value" + config["position"] = position + config["visible"] = not _strict_axis_bool(axis.find("c:delete", C_NS), False) + + tick_label_aliases = { + "high": "high", + "low": "low", + "nextTo": "next_to", + "none": "none", + } + tick_label_position = _element_val(axis.find("c:tickLblPos", C_NS)) or "nextTo" + if tick_label_position not in tick_label_aliases: + raise _UnsupportedChart("unsupported-chart-axis-options") + config["label_position"] = tick_label_aliases[tick_label_position] + config["major_gridlines"] = axis.find("c:majorGridlines", C_NS) is not None + + num_fmt = axis.find("c:numFmt", C_NS) + if num_fmt is not None: + if list(num_fmt) or not set(num_fmt.attrib).issubset({"formatCode", "sourceLinked"}): + raise _UnsupportedChart("unsupported-chart-axis-options") + number_format = num_fmt.attrib.get("formatCode", "") + if not number_format.strip(): + raise _UnsupportedChart("unsupported-chart-axis-number-format") + config["number_format"] = number_format + + major_unit = _axis_number(axis.find("c:majorUnit", C_NS)) + if major_unit is not None: + if role not in {"value", "secondary_value", "x", "y"} or major_unit <= 0: + raise _UnsupportedChart("unsupported-chart-axis-options") + config["major_unit"] = major_unit + + if _element_val(axis.find("c:crossAx", C_NS)) != expected_cross_axis_id: + raise _UnsupportedChart("unsupported-chart-axis-options") + crosses = _element_val(axis.find("c:crosses", C_NS)) or "autoZero" + if crosses not in allowed_crosses: + raise _UnsupportedChart("unsupported-chart-axis-options") + cross_between = _element_val(axis.find("c:crossBetween", C_NS)) + if cross_between != expected_cross_between: + raise _UnsupportedChart("unsupported-chart-axis-options") + auto = axis.find("c:auto", C_NS) + if auto is not None: + _strict_axis_bool(auto, True) + major_tick = _element_val(axis.find("c:majorTickMark", C_NS)) + minor_tick = _element_val(axis.find("c:minorTickMark", C_NS)) + if major_tick not in {None, "none", "out"} or minor_tick not in {None, "none"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + label_alignment = _element_val(axis.find("c:lblAlgn", C_NS)) + label_offset = _element_val(axis.find("c:lblOffset", C_NS)) + no_multi_level = _element_val(axis.find("c:noMultiLvlLbl", C_NS)) + if role in {"category", "secondary_category"}: + if label_alignment not in {None, "ctr"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + if label_offset not in {None, "100"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + if no_multi_level not in {None, "0"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + elif any( + value is not None + for value in (label_alignment, label_offset, no_multi_level, auto) + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + if axis_kind == "dateAx": + if _element_val(axis.find("c:baseTimeUnit", C_NS)) not in {None, "days"}: + raise _UnsupportedChart("unsupported-chart-axis-options") + elif axis.find("c:baseTimeUnit", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-axis-options") + return config + + +def _single_axis_contract( + plot_area: ET.Element, + plot: ET.Element, + *, + category_kind: str, + cross_between: str, +) -> dict[str, dict[str, Any]]: + axes_by_id = _axis_nodes_by_id(plot_area) + cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id) + if set(axes_by_id) != {cat_id, val_id}: + raise _UnsupportedChart("unsupported-chart-axis-options") + if _local_name(cat_axis.tag) != ("dateAx" if category_kind == "date" else "catAx"): + raise _UnsupportedChart("unsupported-chart-axis-options") + category = _axis_config_from_xml( + cat_axis, + role="category", + expected_cross_axis_id=val_id, + allowed_crosses={"autoZero"}, + expected_cross_between=None, + ) + value = _axis_config_from_xml( + val_axis, + role="value", + expected_cross_axis_id=cat_id, + allowed_crosses={"autoZero"}, + expected_cross_between=cross_between, + ) + return {"category": category, "value": value} + + +def _xy_axis_contract( + plot_area: ET.Element, + plot: ET.Element, +) -> dict[str, dict[str, Any]]: + """Return the closed two-value-axis contract used by XY charts.""" + axes_by_id = _axis_nodes_by_id(plot_area) + plot_axis_nodes = plot.findall("c:axId", C_NS) + if len(plot_axis_nodes) != 2 or any( + set(node.attrib) != {"val"} or list(node) + for node in plot_axis_nodes + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + axis_ids = [_element_val(node) for node in plot_axis_nodes] + if any(not axis_id for axis_id in axis_ids) or len(set(axis_ids)) != 2: + raise _UnsupportedChart("unsupported-chart-axis-options") + if set(axes_by_id) != set(axis_ids): + raise _UnsupportedChart("unsupported-chart-axis-options") + + resolved = [ + (str(axis_id), axes_by_id.get(str(axis_id))) + for axis_id in axis_ids + ] + if any( + axis is None or _local_name(axis.tag) != "valAx" + for _, axis in resolved + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + horizontal = [ + (axis_id, axis) + for axis_id, axis in resolved + if axis is not None + and _element_val(axis.find("c:axPos", C_NS)) in {"b", "t"} + ] + vertical = [ + (axis_id, axis) + for axis_id, axis in resolved + if axis is not None + and _element_val(axis.find("c:axPos", C_NS)) in {"l", "r"} + ] + if len(horizontal) != 1 or len(vertical) != 1: + raise _UnsupportedChart("unsupported-chart-axis-options") + + x_id, x_axis = horizontal[0] + y_id, y_axis = vertical[0] + x_config = _axis_config_from_xml( + x_axis, + role="x", + expected_cross_axis_id=y_id, + allowed_crosses={"autoZero"}, + expected_cross_between="midCat", + ) + y_config = _axis_config_from_xml( + y_axis, + role="y", + expected_cross_axis_id=x_id, + allowed_crosses={"autoZero"}, + expected_cross_between="midCat", + ) + return {"x": x_config, "y": y_config} + + +def _validate_legacy_axes(payload: dict[str, Any], plot_area: ET.Element) -> None: + """Keep the original narrow axis gate for payloads without axes metadata.""" grouping = payload.get("grouping") axes = plot_area.findall("c:catAx", C_NS) + plot_area.findall("c:valAx", C_NS) for axis in axes: @@ -315,6 +1368,266 @@ def _validate_chart_semantics( if axis.find("c:minorGridlines", C_NS) is not None: raise _UnsupportedChart("unsupported-chart-axis-options") + +def _validate_normalized_axis_topology( + payload: dict[str, Any], + plot_area: ET.Element, + plot: ET.Element, +) -> None: + """Validate a category/value pair whose presentation will normalize.""" + axes_by_id = _axis_nodes_by_id(plot_area) + cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id) + if set(axes_by_id) != {cat_id, val_id}: + raise _UnsupportedChart("unsupported-chart-axis-options") + + plot_axis_nodes = plot.findall("c:axId", C_NS) + if any(set(node.attrib) != {"val"} or list(node) for node in plot_axis_nodes): + raise _UnsupportedChart("unsupported-chart-axis-options") + for axis, axis_id, cross_axis_id in ( + (cat_axis, cat_id, val_id), + (val_axis, val_id, cat_id), + ): + if axis.attrib: + raise _UnsupportedChart("unsupported-chart-axis-options") + id_nodes = axis.findall("c:axId", C_NS) + cross_nodes = axis.findall("c:crossAx", C_NS) + if ( + len(id_nodes) != 1 + or len(cross_nodes) != 1 + or set(id_nodes[0].attrib) != {"val"} + or set(cross_nodes[0].attrib) != {"val"} + or list(id_nodes[0]) + or list(cross_nodes[0]) + or _element_val(id_nodes[0]) != axis_id + or _element_val(cross_nodes[0]) != cross_axis_id + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + + position_nodes = ( + cat_axis.findall("c:axPos", C_NS), + val_axis.findall("c:axPos", C_NS), + ) + if any( + len(nodes) != 1 + or set(nodes[0].attrib) != {"val"} + or list(nodes[0]) + for nodes in position_nodes + ): + raise _UnsupportedChart("unsupported-chart-axis-options") + category_position = _element_val(position_nodes[0][0]) + value_position = _element_val(position_nodes[1][0]) + if payload["type"] == "bar": + valid_positions = ( + category_position in {"l", "r"} + and value_position in {"b", "t"} + ) + else: + valid_positions = ( + category_position in {"b", "t"} + and value_position in {"l", "r"} + ) + if not valid_positions: + raise _UnsupportedChart("unsupported-chart-axis-options") + + +def _validate_or_normalize_legacy_axes( + payload: dict[str, Any], + plot_area: ET.Element, + plot: ET.Element, +) -> None: + """Keep exact legacy payloads while allowing presentation normalization.""" + try: + _validate_legacy_axes(payload, plot_area) + except _UnsupportedChart as exc: + if exc.status not in { + "unsupported-chart-axis-number-format", + "unsupported-chart-axis-options", + }: + raise + if payload["type"] in {"area", "column", "line"}: + try: + payload["axes"] = _single_axis_contract( + plot_area, + plot, + category_kind="text", + cross_between="between", + ) + return + except _UnsupportedChart: + payload.pop("axes", None) + _validate_normalized_axis_topology(payload, plot_area, plot) + + +def _effective_radar_style(plot: ET.Element) -> str: + """Map Office radar marker variants to the writer's uniform style set.""" + style_nodes = plot.findall("c:radarStyle", C_NS) + if len(style_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-radar-style") + style_node = style_nodes[0] if style_nodes else None + if style_node is not None and ( + set(style_node.attrib) != {"val"} or list(style_node) + ): + raise _UnsupportedChart("unsupported-chart-radar-style") + style = _element_val(style_node) if style_node is not None else "standard" + if style == "filled": + return "filled" + if style not in {"marker", "standard"}: + raise _UnsupportedChart("unsupported-chart-radar-style") + + default_marker = style == "marker" + marker_states: list[bool] = [] + for series in plot.findall("c:ser", C_NS): + symbol = _element_val(series.find("c:marker/c:symbol", C_NS)) + marker_states.append( + default_marker if symbol is None else symbol != "none" + ) + return "lineMarker" if any(marker_states) else "line" + + +def _effective_scatter_style( + plot: ET.Element, + visual_styles: list[SeriesVisualStyle], +) -> str: + """Normalize plot-level line intent plus uniform series overrides.""" + style_nodes = plot.findall("c:scatterStyle", C_NS) + if len(style_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-scatter-style") + style_node = style_nodes[0] if style_nodes else None + if style_node is not None and ( + set(style_node.attrib) != {"val"} or list(style_node) + ): + raise _UnsupportedChart("unsupported-chart-scatter-style") + plot_style = _element_val(style_node) or "marker" + if plot_style not in {"line", "lineMarker", "marker", "smooth", "smoothMarker"}: + raise _UnsupportedChart("unsupported-chart-scatter-style") + plot_has_line = plot_style in {"line", "lineMarker", "smooth", "smoothMarker"} + plot_is_smooth = plot_style in {"smooth", "smoothMarker"} + + style_by_state = { + (False, True, False): "marker", + (True, False, False): "line", + (True, True, False): "lineMarker", + (True, False, True): "smooth", + (True, True, True): "smoothMarker", + } + effective_styles: set[str] = set() + series_nodes = plot.findall("c:ser", C_NS) + if len(series_nodes) != len(visual_styles): + raise _UnsupportedChart("unsupported-chart-scatter-style") + for series, visual_style in zip(series_nodes, visual_styles): + line_node = series.find("c:spPr/a:ln", C_NS) + has_line = plot_has_line if line_node is None else ( + visual_style.stroke is not None + and visual_style.stroke_opacity > 0 + ) + markers = series.findall("c:marker", C_NS) + if len(markers) != 1: + raise _UnsupportedChart("unsupported-chart-scatter-style") + marker = markers[0] + symbol = marker.find("c:symbol", C_NS) if marker is not None else None + if ( + marker is None + or symbol is None + or set(symbol.attrib) != {"val"} + or list(symbol) + ): + raise _UnsupportedChart("unsupported-chart-scatter-style") + symbol_value = _element_val(symbol) + if symbol_value not in {"circle", "none"}: + raise _UnsupportedChart("unsupported-chart-scatter-style") + has_marker = symbol_value == "circle" + + smooth_nodes = series.findall("c:smooth", C_NS) + if len(smooth_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-scatter-style") + smooth = smooth_nodes[0] if smooth_nodes else None + is_smooth = plot_is_smooth + if smooth is not None: + if not set(smooth.attrib).issubset({"val"}) or list(smooth): + raise _UnsupportedChart("unsupported-chart-scatter-style") + raw_smooth = smooth.attrib.get("val") + key = raw_smooth.strip().lower() if raw_smooth is not None else "true" + if key in {"1", "on", "true"}: + is_smooth = True + elif key in {"0", "false", "off"}: + is_smooth = False + else: + raise _UnsupportedChart("unsupported-chart-scatter-style") + if is_smooth and not has_line: + raise _UnsupportedChart("unsupported-chart-scatter-style") + + effective_style = style_by_state.get((has_line, has_marker, is_smooth)) + if effective_style is None: + raise _UnsupportedChart("unsupported-chart-scatter-style") + effective_styles.add(effective_style) + + if len(effective_styles) != 1: + raise _UnsupportedChart("unsupported-chart-scatter-style") + return effective_styles.pop() + + +def _bounded_plot_integer( + plot: ET.Element, + name: str, + *, + minimum: int, + maximum: int, + status: str, +) -> int | None: + nodes = plot.findall(f"c:{name}", C_NS) + if len(nodes) > 1: + raise _UnsupportedChart(status) + if not nodes: + return None + node = nodes[0] + raw_value = node.attrib.get("val") + if ( + set(node.attrib) != {"val"} + or list(node) + or raw_value is None + or re.fullmatch(r"-?[0-9]+", raw_value) is None + ): + raise _UnsupportedChart(status) + value = int(raw_value) + if not minimum <= value <= maximum: + raise _UnsupportedChart(status) + return value + + +def _validate_chart_semantics( + payload: dict[str, Any], + plot_area: ET.Element, + plot: ET.Element, + *, + palette: ColorPalette | None = None, + validate_axes: bool = True, +) -> list[SeriesVisualStyle]: + """Reject valid chart features the compact marker cannot reproduce.""" + chart_type = payload["type"] + grouping = payload.get("grouping") + visual_styles = _chart_visual_styles(payload, plot, palette) + for tag in ( + "trendline", "errBars", "dropLines", "hiLowLines", "upDownBars", + ): + if plot.find(f".//c:{tag}", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-analysis-features") + if plot_area.find("c:dTable", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-data-table") + ser_line_nodes = plot.findall("c:serLines", C_NS) + if len(ser_line_nodes) > 1: + raise _UnsupportedChart("unsupported-chart-analysis-features") + if ser_line_nodes: + ser_lines = ser_line_nodes[0] + if ( + chart_type != "of_pie" + or ser_lines.attrib + or len(ser_lines) > 1 + or any(_local_name(child.tag) != "spPr" for child in ser_lines) + ): + raise _UnsupportedChart("unsupported-chart-analysis-features") + if validate_axes: + _validate_or_normalize_legacy_axes(payload, plot_area, plot) + if chart_type == "line": smooth_nodes = [plot.find("c:smooth", C_NS), *plot.findall("c:ser/c:smooth", C_NS)] if any( @@ -330,11 +1643,6 @@ def _validate_chart_semantics( marker_states: set[bool] = set() for series in plot.findall("c:ser", C_NS): marker_node = series.find("c:marker", C_NS) - if marker_node is not None and any( - child.tag.rsplit("}", 1)[-1] != "symbol" - for child in marker_node - ): - raise _UnsupportedChart("unsupported-chart-line-style") symbol = _element_val(series.find("c:marker/c:symbol", C_NS)) if symbol not in {None, "circle", "none"}: raise _UnsupportedChart("unsupported-chart-line-style") @@ -343,16 +1651,20 @@ def _validate_chart_semantics( raise _UnsupportedChart("unsupported-chart-line-style") if chart_type in {"bar", "column"}: - gap_width = _element_val(plot.find("c:gapWidth", C_NS)) - if gap_width not in {None, "150"}: - raise _UnsupportedChart("unsupported-chart-bar-options") - overlap = _element_val(plot.find("c:overlap", C_NS)) - expected_overlap = "100" if grouping in {"stacked", "percentStacked"} else "0" - if overlap is None: - if expected_overlap != "0": - raise _UnsupportedChart("unsupported-chart-bar-options") - elif overlap != expected_overlap: - raise _UnsupportedChart("unsupported-chart-bar-options") + _bounded_plot_integer( + plot, + "gapWidth", + minimum=0, + maximum=500, + status="unsupported-chart-bar-options", + ) + _bounded_plot_integer( + plot, + "overlap", + minimum=-100, + maximum=100, + status="unsupported-chart-bar-options", + ) if chart_type == "bubble": bubble_scale = _element_val(plot.find("c:bubbleScale", C_NS)) @@ -378,31 +1690,7 @@ def _validate_chart_semantics( raise _UnsupportedChart("unsupported-chart-bubble-options") if chart_type == "scatter": - scatter_style = _element_val(plot.find("c:scatterStyle", C_NS)) or "marker" - expected_marker = ( - "circle" - if scatter_style in {"lineMarker", "marker", "smoothMarker"} - else "none" - ) - expected_smooth = scatter_style in {"smooth", "smoothMarker"} - for series in plot.findall("c:ser", C_NS): - marker = series.find("c:marker", C_NS) - if marker is None: - raise _UnsupportedChart("unsupported-chart-scatter-style") - if any( - child.tag.rsplit("}", 1)[-1] != "symbol" - for child in marker - ): - raise _UnsupportedChart("unsupported-chart-scatter-style") - symbol = _element_val(marker.find("c:symbol", C_NS)) - if symbol != expected_marker: - raise _UnsupportedChart("unsupported-chart-scatter-style") - smooth = series.find("c:smooth", C_NS) - actual_smooth = bool( - smooth is not None and ooxml_bool(smooth.attrib.get("val"), True) - ) - if actual_smooth != expected_smooth: - raise _UnsupportedChart("unsupported-chart-scatter-style") + payload["scatter_style"] = _effective_scatter_style(plot, visual_styles) if chart_type in {"pie", "doughnut", "of_pie"}: for explosion in plot.findall(".//c:explosion", C_NS): @@ -425,6 +1713,26 @@ def _validate_chart_semantics( second_size = _element_val(plot.find("c:secondPieSize", C_NS)) if second_size not in {None, "75"}: raise _UnsupportedChart("unsupported-chart-of-pie-options") + return visual_styles + + +def _apply_plot_data_labels(payload: dict[str, Any], plot: ET.Element) -> None: + if plot.find("c:ser/c:dLbls", C_NS) is not None: + raise _UnsupportedChart("unsupported-chart-series-data-labels") + data_labels = _data_labels_payload(plot.find("c:dLbls", C_NS)) + if not data_labels: + return + if payload["type"] not in {"area", "bar", "column", "line"}: + raise _UnsupportedChart("unsupported-chart-data-labels") + try: + validate_data_label_position( + data_labels.get("position"), + payload["type"], + payload.get("grouping"), + ) + except RuntimeError: + raise _UnsupportedChart("unsupported-chart-data-labels") from None + payload["data_labels"] = data_labels def _apply_chart_metadata( @@ -432,6 +1740,8 @@ def _apply_chart_metadata( chart_root: ET.Element, plot_area: ET.Element, plot: ET.Element, + *, + include_plot_labels: bool = True, ) -> None: """Copy visible classic-chart chrome supported by the native schema.""" chart = chart_root.find("c:chart", C_NS) @@ -439,16 +1749,7 @@ def _apply_chart_metadata( return title_element = chart.find("c:title", C_NS) - overlay = title_element.find("c:overlay", C_NS) if title_element is not None else None - if overlay is not None and ooxml_bool(overlay.attrib.get("val"), True): - raise _UnsupportedChart("unsupported-chart-title-overlay") title_entries = _canonical_title_entries(title_element) - if ( - title_element is not None - and title_element.find("c:tx/c:rich", C_NS) is not None - and title_entries is None - ): - raise _UnsupportedChart("unsupported-chart-title-format") title = _chart_text(title_element) if title: if title_entries is not None: @@ -462,37 +1763,23 @@ def _apply_chart_metadata( if legend is not None: delete = legend.find("c:delete", C_NS) if delete is None or not ooxml_bool(delete.attrib.get("val"), True): - if legend.find("c:legendEntry", C_NS) is not None: - raise _UnsupportedChart("unsupported-chart-legend-filter") - overlay = legend.find("c:overlay", C_NS) - if overlay is not None and ooxml_bool(overlay.attrib.get("val"), True): - raise _UnsupportedChart("unsupported-chart-legend-overlay") position = _element_val(legend.find("c:legendPos", C_NS)) or "r" if position not in {"b", "l", "r", "t"}: raise _UnsupportedChart("unsupported-chart-legend-position") payload["show_legend"] = True payload["legend_position"] = position - if plot.find("c:ser/c:dLbls", C_NS) is not None: - raise _UnsupportedChart("unsupported-chart-series-data-labels") - data_labels = _data_labels_payload(plot.find("c:dLbls", C_NS)) - if data_labels: - if payload["type"] not in {"area", "bar", "column", "line"}: - raise _UnsupportedChart("unsupported-chart-data-labels") - try: - validate_data_label_position( - data_labels.get("position"), - payload["type"], - payload.get("grouping"), - ) - except RuntimeError: - raise _UnsupportedChart("unsupported-chart-data-labels") from None - payload["data_labels"] = data_labels + if include_plot_labels: + _apply_plot_data_labels(payload, plot) axis_titles: dict[str, str] = {} + category_axis_nodes = ( + plot_area.findall("c:catAx", C_NS) + + plot_area.findall("c:dateAx", C_NS) + ) category_titles = [ text - for axis in plot_area.findall("c:catAx", C_NS) + for axis in category_axis_nodes if (text := _chart_text(axis.find("c:title", C_NS))) ] value_titles = [ @@ -500,7 +1787,21 @@ def _apply_chart_metadata( for axis in plot_area.findall("c:valAx", C_NS) if (text := _chart_text(axis.find("c:title", C_NS))) ] - if payload["type"] in {"scatter", "bubble"}: + if payload["type"] == "combo": + if len(set(category_titles)) > 1: + raise _UnsupportedChart("unsupported-chart-axis-titles") + if category_titles: + axis_titles["category"] = category_titles[0] + for axis in plot_area.findall("c:valAx", C_NS): + text = _chart_text(axis.find("c:title", C_NS)) + if not text: + continue + position = _element_val(axis.find("c:axPos", C_NS)) + key = "secondary_value" if position == "r" else "value" + if key in axis_titles: + raise _UnsupportedChart("unsupported-chart-axis-titles") + axis_titles[key] = text + elif payload["type"] in {"scatter", "bubble"}: if category_titles: raise _UnsupportedChart("unsupported-chart-axis-titles") titled_value_axes = [ @@ -823,18 +2124,29 @@ def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None: return config -def _category_payload(chart: ET.Element, chart_type: str, xfrm: Xfrm) -> dict[str, Any]: +def _category_payload( + chart: ET.Element, + chart_type: str, + xfrm: Xfrm, + *, + category_kind: str = "text", +) -> dict[str, Any]: series_nodes = chart.findall("c:ser", C_NS) if not series_nodes: raise _UnsupportedChart("unsupported-chart-cache") - categories = _category_values(series_nodes[0].find("c:cat", C_NS)) + category_reader = ( + _numeric_values + if category_kind in {"date", "numeric"} + else _category_values + ) + categories = category_reader(series_nodes[0].find("c:cat", C_NS)) if not categories: raise _UnsupportedChart("unsupported-chart-cache") series: list[dict[str, Any]] = [] for idx, ser in enumerate(series_nodes, start=1): - if _category_values(ser.find("c:cat", C_NS)) != categories: + if category_reader(ser.find("c:cat", C_NS)) != categories: raise _UnsupportedChart("unsupported-chart-cache") values = _numeric_values(ser.find("c:val", C_NS)) if not values or len(values) != len(categories): @@ -888,10 +2200,6 @@ def _xy_payload(chart: ET.Element, chart_type: str, xfrm: Xfrm) -> dict[str, Any "series": series, "type": chart_type, } - if chart_type == "scatter": - style = _element_val(chart.find("c:scatterStyle", C_NS)) - if style: - payload["scatter_style"] = style return payload @@ -928,6 +2236,47 @@ def _category_values(cat: ET.Element | None) -> list[str]: return [str(value) for value in numbers] +def _category_cache_is_numeric(chart: ET.Element) -> bool: + """Return the cache representation shared by every series in one plot.""" + result: bool | None = None + for series in chart.findall("c:ser", C_NS): + category = series.find("c:cat", C_NS) + has_text = _first_cache(category, ("strCache", "strLit")) is not None + has_number = _first_cache(category, ("numCache", "numLit")) is not None + if has_text == has_number: + raise _UnsupportedChart("unsupported-combo-category-layout") + current = has_number + if result is not None and current != result: + raise _UnsupportedChart("unsupported-combo-category-layout") + result = current + if result is None: + raise _UnsupportedChart("unsupported-combo-category-layout") + return result + + +def _numeric_category_cache_format(chart: ET.Element) -> str: + """Return one exact numeric category-cache format shared by the plot.""" + formats: set[str] = set() + for series in chart.findall("c:ser", C_NS): + cache = _first_cache( + series.find("c:cat", C_NS), + ("numCache", "numLit"), + ) + if cache is None: + raise _UnsupportedChart("unsupported-chart-category-format") + number_format = cache.findtext( + "c:formatCode", + default="", + namespaces=C_NS, + ) + if not number_format.strip(): + raise _UnsupportedChart("unsupported-chart-category-format") + formats.add(number_format) + if len(formats) != 1: + raise _UnsupportedChart("unsupported-chart-category-format") + return next(iter(formats)) + + def _series_name(ser: ET.Element, index: int) -> str: tx = ser.find("c:tx", C_NS) values = _text_cache_values(tx) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chartex_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chartex_to_svg.py new file mode 100644 index 00000000..4356f9a0 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chartex_to_svg.py @@ -0,0 +1,421 @@ +"""Extract native ChartEx payloads from PPTX chart parts. + +The parser is deliberately closed around the seven ChartEx data models that +the native writer already emits. Data topology and caches must be complete; +unmodeled chart chrome, labels, axes, binning options, and style details are +allowed to normalize during editable native reconstruction. +""" + +from __future__ import annotations + +import math +import re +from typing import Any +from xml.etree import ElementTree as ET + +from svg_to_pptx.native_objects.chart_data import validate_chart_payload +from svg_to_pptx.native_objects.marker_common import CHART_COLOR_STYLE_REL_TYPE + +from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color +from .emu_units import NS, Xfrm +from .ooxml_loader import OoxmlPackage, PartRef + + +CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex" + +CX_NS = { + **NS, + "cx": CHARTEX_URI, +} + +_INT_TOKEN_RE = re.compile(r"[0-9]+") +_HIERARCHY_LAYOUTS = {"sunburst", "treemap"} +_FLAT_LAYOUTS = {"funnel", "waterfall"} +_LEGEND_POSITIONS = {"b", "l", "r", "t"} + + +class UnsupportedChartEx(RuntimeError): + """A ChartEx part cannot map to the current native authoring schema.""" + + def __init__(self, status: str) -> None: + super().__init__(status) + self.status = status + + +def extract_native_chartex_payload( + graphic_data: ET.Element | None, + xfrm: Xfrm, + slide_part: PartRef, + pkg: OoxmlPackage, + palette: ColorPalette | None = None, +) -> dict[str, Any]: + """Return a writer-valid payload for one supported ChartEx reference. + + ``UnsupportedChartEx.status`` is suitable for ``data-pptx-native-status`` + when the reference, relationship, data topology, or cache is unusable. + Style-part failures never reject an otherwise valid data payload. + """ + if graphic_data is None: + raise UnsupportedChartEx("unsupported-chart-reference") + if xfrm.rot or xfrm.flip_h or xfrm.flip_v: + raise UnsupportedChartEx("unsupported-native-transform") + + chart_ref = graphic_data.find("cx:chart", CX_NS) + if chart_ref is None: + raise UnsupportedChartEx("unsupported-chart-reference") + rid = chart_ref.attrib.get(f"{{{NS['r']}}}id") + if not rid: + raise UnsupportedChartEx("unsupported-chart-reference") + + chart_path = slide_part.resolve_rel(rid) + if not chart_path: + raise UnsupportedChartEx("unsupported-chart-relationship") + chart_part = pkg.load_part(chart_path) + if chart_part is None: + raise UnsupportedChartEx("unsupported-chart-part") + + try: + payload = _payload_from_chartex_xml(chart_part.xml, xfrm) + colors = _resolved_chart_colors(chart_part, pkg, palette) + if colors: + payload["style"] = {"colors": colors} + validate_chart_payload(payload) + except UnsupportedChartEx: + raise + except RuntimeError as exc: + raise UnsupportedChartEx("unsupported-chartex-schema") from exc + except (AttributeError, OverflowError, TypeError, ValueError) as exc: + raise UnsupportedChartEx("unsupported-chartex-parse") from exc + return payload + + +def _payload_from_chartex_xml( + chart_root: ET.Element, + xfrm: Xfrm, +) -> dict[str, Any]: + if chart_root.tag != f"{{{CHARTEX_URI}}}chartSpace": + raise UnsupportedChartEx("unsupported-chartex-part") + + chart_data = _one_child(chart_root, "chartData", "unsupported-chartex-structure") + chart = _one_child(chart_root, "chart", "unsupported-chartex-structure") + plot_area = _one_child(chart, "plotArea", "unsupported-chartex-structure") + region = _one_child(plot_area, "plotAreaRegion", "unsupported-chartex-structure") + + data_by_id = _data_parts(chart_data) + series_nodes = _children(region, "series") + if not series_nodes: + raise UnsupportedChartEx("unsupported-chartex-series") + + chart_type = _chart_type(series_nodes) + payload: dict[str, Any] = { + **_bounds_payload(xfrm), + "type": chart_type, + } + + referenced_data_ids: list[int] + if chart_type in _HIERARCHY_LAYOUTS: + data_id = _series_data_id(series_nodes[0]) + data = _require_data(data_by_id, data_id) + raw_levels = _string_dimension(data, expected_levels=None) + values = _numeric_dimension(data, "size") + if not raw_levels or any(len(level) != len(values) for level in raw_levels): + raise UnsupportedChartEx("unsupported-chartex-cache") + # ChartEx serializes the innermost level first; the authoring schema + # and workbook writer use the natural outermost-to-innermost order. + payload["levels"] = list(reversed(raw_levels)) + payload["values"] = values + if chart_type == "treemap": + parent_labels = _treemap_parent_labels(series_nodes[0]) + if parent_labels is not None: + payload["parent_label_layout"] = parent_labels + referenced_data_ids = [data_id] + elif chart_type == "histogram": + data_id = _series_data_id(series_nodes[0]) + data = _require_data(data_by_id, data_id) + _reject_dimensions(data, allowed={"numDim"}) + payload["values"] = _numeric_dimension(data, "val") + referenced_data_ids = [data_id] + elif chart_type in {"funnel", "pareto", "waterfall"}: + data_id = _series_data_id(series_nodes[0]) + data = _require_data(data_by_id, data_id) + categories = _string_dimension(data, expected_levels=1)[0] + values = _numeric_dimension(data, "val") + if len(categories) != len(values): + raise UnsupportedChartEx("unsupported-chartex-cache") + payload["categories"] = categories + payload["values"] = values + if chart_type == "waterfall": + payload["subtotals"] = _waterfall_subtotals(series_nodes[0]) + referenced_data_ids = [data_id] + elif chart_type == "box_whisker": + items: list[dict[str, Any]] = [] + referenced_data_ids = [] + for index, series in enumerate(series_nodes, start=1): + data_id = _series_data_id(series) + if data_id in referenced_data_ids: + raise UnsupportedChartEx("unsupported-chartex-data-id") + data = _require_data(data_by_id, data_id) + categories = _string_dimension(data, expected_levels=1)[0] + values = _numeric_dimension(data, "val") + if len(categories) != len(values): + raise UnsupportedChartEx("unsupported-chartex-cache") + items.append({ + "categories": categories, + "name": _series_name(series, index), + "values": values, + }) + referenced_data_ids.append(data_id) + payload["series"] = items + else: # pragma: no cover - _chart_type is closed, keep the invariant explicit. + raise UnsupportedChartEx("unsupported-chartex-type") + + if set(referenced_data_ids) != set(data_by_id): + raise UnsupportedChartEx("unsupported-chartex-data-id") + + legend = chart.find("cx:legend", CX_NS) + if legend is not None: + payload["show_legend"] = True + position = legend.attrib.get("pos") + if position in _LEGEND_POSITIONS: + payload["legend_position"] = position + return payload + + +def _chart_type(series_nodes: list[ET.Element]) -> str: + layouts = [series.attrib.get("layoutId", "") for series in series_nodes] + if len(series_nodes) == 1 and layouts[0] in _HIERARCHY_LAYOUTS | _FLAT_LAYOUTS: + return layouts[0] + if layouts and all(layout == "boxWhisker" for layout in layouts): + return "box_whisker" + if len(series_nodes) == 1 and layouts == ["clusteredColumn"]: + if series_nodes[0].find("cx:layoutPr/cx:binning", CX_NS) is None: + raise UnsupportedChartEx("unsupported-chartex-series") + return "histogram" + if len(series_nodes) == 2 and layouts == ["clusteredColumn", "paretoLine"]: + primary, line = series_nodes + if ( + primary.find("cx:layoutPr/cx:aggregation", CX_NS) is None + or line.attrib.get("ownerIdx") != "0" + or line.find("cx:dataId", CX_NS) is not None + ): + raise UnsupportedChartEx("unsupported-chartex-series") + return "pareto" + raise UnsupportedChartEx("unsupported-chartex-type") + + +def _data_parts(chart_data: ET.Element) -> dict[int, ET.Element]: + result: dict[int, ET.Element] = {} + for data in _children(chart_data, "data"): + data_id = _nonnegative_int(data.attrib.get("id"), "unsupported-chartex-data-id") + if data_id in result: + raise UnsupportedChartEx("unsupported-chartex-data-id") + result[data_id] = data + if not result: + raise UnsupportedChartEx("unsupported-chartex-data-id") + return result + + +def _series_data_id(series: ET.Element) -> int: + data_ids = _children(series, "dataId") + if len(data_ids) != 1: + raise UnsupportedChartEx("unsupported-chartex-data-id") + return _nonnegative_int( + data_ids[0].attrib.get("val"), + "unsupported-chartex-data-id", + ) + + +def _require_data(data_by_id: dict[int, ET.Element], data_id: int) -> ET.Element: + data = data_by_id.get(data_id) + if data is None: + raise UnsupportedChartEx("unsupported-chartex-data-id") + return data + + +def _string_dimension( + data: ET.Element, + *, + expected_levels: int | None, +) -> list[list[str]]: + _reject_dimensions(data, allowed={"strDim", "numDim"}) + dimensions = [ + child + for child in _children(data, "strDim") + if child.attrib.get("type") == "cat" + ] + if len(dimensions) != 1 or len(_children(data, "strDim")) != 1: + raise UnsupportedChartEx("unsupported-chartex-dimension") + levels = _children(dimensions[0], "lvl") + if not levels or (expected_levels is not None and len(levels) != expected_levels): + raise UnsupportedChartEx("unsupported-chartex-dimension") + return [_level_values(level, numeric=False) for level in levels] + + +def _numeric_dimension(data: ET.Element, dim_type: str) -> list[int | float]: + dimensions = [ + child + for child in _children(data, "numDim") + if child.attrib.get("type") == dim_type + ] + if len(dimensions) != 1 or len(_children(data, "numDim")) != 1: + raise UnsupportedChartEx("unsupported-chartex-dimension") + levels = _children(dimensions[0], "lvl") + if len(levels) != 1: + raise UnsupportedChartEx("unsupported-chartex-dimension") + return _level_values(levels[0], numeric=True) + + +def _reject_dimensions(data: ET.Element, *, allowed: set[str]) -> None: + for child in data: + name = _local_name(child.tag) + if name.endswith("Dim") and name not in allowed: + raise UnsupportedChartEx("unsupported-chartex-dimension") + + +def _level_values( + level: ET.Element, + *, + numeric: bool, +) -> list[Any]: + point_count = _nonnegative_int( + level.attrib.get("ptCount"), + "unsupported-chartex-cache", + ) + points: dict[int, Any] = {} + for point in _children(level, "pt"): + point_index = _nonnegative_int( + point.attrib.get("idx"), + "unsupported-chartex-cache", + ) + if point_index >= point_count or point_index in points or list(point): + raise UnsupportedChartEx("unsupported-chartex-cache") + raw_value = point.text or "" + points[point_index] = ( + _numeric_value(raw_value) + if numeric + else raw_value + ) + if ( + len(points) != point_count + or any(index not in points for index in range(point_count)) + ): + raise UnsupportedChartEx("unsupported-chartex-cache") + return [points[index] for index in range(point_count)] + + +def _numeric_value(raw_value: str) -> int | float: + if not raw_value.strip(): + raise UnsupportedChartEx("unsupported-chartex-cache") + try: + number = float(raw_value) + except (OverflowError, ValueError): + raise UnsupportedChartEx("unsupported-chartex-cache") from None + if not math.isfinite(number): + raise UnsupportedChartEx("unsupported-chartex-cache") + return int(number) if number.is_integer() else number + + +def _waterfall_subtotals(series: ET.Element) -> list[int]: + subtotals: list[int] = [] + for item in series.findall("cx:layoutPr/cx:subtotals/cx:idx", CX_NS): + subtotals.append( + _nonnegative_int(item.attrib.get("val"), "unsupported-chartex-cache") + ) + return subtotals + + +def _treemap_parent_labels(series: ET.Element) -> str | None: + parent = series.find("cx:layoutPr/cx:parentLabelLayout", CX_NS) + if parent is None: + return None + value = parent.attrib.get("val") + return value if value in {"banner", "none", "overlapping"} else None + + +def _series_name(series: ET.Element, index: int) -> str: + value = series.findtext("cx:tx/cx:txData/cx:v", default="", namespaces=CX_NS) + return value or f"Series {index}" + + +def _resolved_chart_colors( + chart_part: PartRef, + pkg: OoxmlPackage, + palette: ColorPalette | None, +) -> list[str]: + """Resolve the base color cycle; any style failure normalizes silently.""" + try: + targets = [ + info.get("target") + for info in chart_part.rels.values() + if info.get("type") == CHART_COLOR_STYLE_REL_TYPE + and not info.get("external") + and info.get("target") + ] + if len(targets) != 1: + return [] + colors_part = pkg.load_part(str(targets[0])) + if colors_part is None: + return [] + color_nodes = [ + child + for child in colors_part.xml + if _namespace(child.tag) == NS["a"] + and _local_name(child.tag) in COLOR_TAGS + ] + if not color_nodes: + return [] + colors: list[str] = [] + for color_node in color_nodes: + color, _alpha = resolve_color(color_node, palette) + if color is None: + return [] + colors.append(color) + return colors + except (AttributeError, OverflowError, RuntimeError, TypeError, ValueError): + return [] + + +def _one_child(parent: ET.Element, name: str, status: str) -> ET.Element: + children = _children(parent, name) + if len(children) != 1: + raise UnsupportedChartEx(status) + return children[0] + + +def _children(parent: ET.Element, name: str) -> list[ET.Element]: + return [ + child + for child in parent + if child.tag == f"{{{CHARTEX_URI}}}{name}" + ] + + +def _nonnegative_int(raw_value: str | None, status: str) -> int: + if raw_value is None or _INT_TOKEN_RE.fullmatch(raw_value) is None: + raise UnsupportedChartEx(status) + value = int(raw_value) + if value < 0: + raise UnsupportedChartEx(status) + return value + + +def _bounds_payload(xfrm: Xfrm) -> dict[str, int | float]: + return { + "height": _round_payload_number(xfrm.h), + "width": _round_payload_number(xfrm.w), + "x": _round_payload_number(xfrm.x), + "y": _round_payload_number(xfrm.y), + } + + +def _round_payload_number(value: float) -> int | float: + rounded = round(float(value), 3) + return int(rounded) if rounded.is_integer() else rounded + + +def _namespace(tag: str) -> str: + return tag[1:].split("}", 1)[0] if tag.startswith("{") else "" + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] if "}" in tag else tag diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/custgeom_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/custgeom_to_svg.py index ce3635c9..e976fd01 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/custgeom_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/custgeom_to_svg.py @@ -11,182 +11,155 @@ Path command mapping: to SVG endpoint parameterization) -> Z -DrawingML defines a local EMU coordinate system. We -remap path coordinates from path-local to slide-absolute pixels using the -shape's xfrm. +DrawingML defines a shape-local coordinate system. +Each path resolves guide formulas against that local coordinate system, then +its coordinates are projected into the shape's absolute SVG frame. """ from __future__ import annotations -import math +from dataclasses import dataclass from xml.etree import ElementTree as ET -from .emu_units import NS, Xfrm, emu_to_px, fmt_num +from pptx_shapes import FormulaEvaluationError, FormulaEvaluator + +from .emu_units import NS, Xfrm +from .preset_registry_to_svg import render_evaluated_path + + +@dataclass(frozen=True) +class _EvaluatedCommand: + name: str + parameters: tuple[float, ...] def convert_custom_geom( cust_geom: ET.Element, xfrm: Xfrm, ) -> str | None: - """Return an SVG path d="..." string in slide-absolute coordinates, or None. - """ + """Evaluate a complete custom geometry and return absolute SVG path data.""" path_lst = cust_geom.find("a:pathLst", NS) if path_lst is None: return None - - paths = path_lst.findall("a:path", NS) - if not paths: - return None - - d_segments: list[str] = [] - for path_elem in paths: - d = _convert_one_path(path_elem, xfrm) - if d: - d_segments.append(d) - - if not d_segments: - return None - return " ".join(d_segments) + shape_evaluator = _custom_geometry_evaluator(cust_geom, xfrm.w, xfrm.h) + paths = [ + _convert_one_path(path_elem, cust_geom, xfrm, shape_evaluator) + for path_elem in path_lst.findall("a:path", NS) + ] + rendered = [path for path in paths if path] + return " ".join(rendered) if rendered else None -def _convert_one_path(path_elem: ET.Element, xfrm: Xfrm) -> str: - """Convert a single to SVG path commands (slide-absolute coords).""" - try: - path_w_emu = int(path_elem.attrib.get("w", "0")) - path_h_emu = int(path_elem.attrib.get("h", "0")) - except ValueError: - return "" - path_w_px = emu_to_px(path_w_emu) if path_w_emu else xfrm.w - path_h_px = emu_to_px(path_h_emu) if path_h_emu else xfrm.h - if path_w_px <= 0 or path_h_px <= 0: - return "" - sx = xfrm.w / path_w_px if path_w_px else 1.0 - sy = xfrm.h / path_h_px if path_h_px else 1.0 - - def map_pt(x_emu: float, y_emu: float) -> tuple[float, float]: - x = emu_to_px(x_emu) * sx + xfrm.x - y = emu_to_px(y_emu) * sy + xfrm.y - return x, y - - # Track current point so a:arcTo (center-based) can compute its endpoint - cx, cy = 0.0, 0.0 # slide-absolute pixels - - parts: list[str] = [] - for child in list(path_elem): - if not isinstance(child.tag, str): +def _custom_geometry_evaluator( + cust_geom: ET.Element, + width: float, + height: float, +) -> FormulaEvaluator: + evaluator = FormulaEvaluator(width, height) + for list_name in ("avLst", "gdLst"): + guide_list = cust_geom.find(f"a:{list_name}", NS) + if guide_list is None: continue - local = child.tag.split("}", 1)[-1] - if local == "moveTo": - pt = child.find("a:pt", NS) - if pt is None: - continue - x, y = _read_pt(pt, map_pt) - parts.append(f"M {fmt_num(x)} {fmt_num(y)}") - cx, cy = x, y - elif local == "lnTo": - pt = child.find("a:pt", NS) - if pt is None: - continue - x, y = _read_pt(pt, map_pt) - parts.append(f"L {fmt_num(x)} {fmt_num(y)}") - cx, cy = x, y - elif local == "cubicBezTo": - pts = child.findall("a:pt", NS) - if len(pts) < 3: - continue - p1 = _read_pt(pts[0], map_pt) - p2 = _read_pt(pts[1], map_pt) - p3 = _read_pt(pts[2], map_pt) - parts.append( - f"C {fmt_num(p1[0])} {fmt_num(p1[1])} " - f"{fmt_num(p2[0])} {fmt_num(p2[1])} " - f"{fmt_num(p3[0])} {fmt_num(p3[1])}" - ) - cx, cy = p3 - elif local == "quadBezTo": - pts = child.findall("a:pt", NS) - if len(pts) < 2: - continue - p1 = _read_pt(pts[0], map_pt) - p2 = _read_pt(pts[1], map_pt) - parts.append( - f"Q {fmt_num(p1[0])} {fmt_num(p1[1])} " - f"{fmt_num(p2[0])} {fmt_num(p2[1])}" - ) - cx, cy = p2 - elif local == "arcTo": - arc_d, end_x, end_y = _arc_to_svg(child, cx, cy, sx, sy) - if arc_d: - parts.append(arc_d) - cx, cy = end_x, end_y - elif local == "close": - parts.append("Z") - # SVG semantics: Z returns to subpath start; we don't track that - # explicitly here. cx/cy stays as-is — subsequent moveTo will reset. - - return " ".join(parts) + for guide in guide_list.findall("a:gd", NS): + name = guide.attrib.get("name", "").strip() + formula = guide.attrib.get("fmla", "").strip() + if not name or not formula: + raise FormulaEvaluationError( + f"Custom geometry {list_name} contains an incomplete guide" + ) + evaluator.bind(name, evaluator.evaluate(formula)) + return evaluator -def _read_pt(pt_elem: ET.Element, mapper) -> tuple[float, float]: - try: - x = float(pt_elem.attrib.get("x", "0")) - y = float(pt_elem.attrib.get("y", "0")) - except ValueError: - x = 0.0 - y = 0.0 - return mapper(x, y) - - -def _arc_to_svg( - arc_elem: ET.Element, - cx: float, cy: float, - sx: float, sy: float, -) -> tuple[str, float, float]: - """Convert to an SVG A command. - - DrawingML semantics: starting at the current point, draw an elliptical arc - where the ellipse has radii (wR, hR) in path-local EMU. stAng/swAng are - 1/60000 degrees, with 0° = +x axis, increasing clockwise. - - The center of the ellipse is at: - center.x = cur.x - wR * cos(stAng) - center.y = cur.y - hR * sin(stAng) - The end point is on the same ellipse at angle (stAng + swAng). - - We emit a single SVG A command. SVG's sweep_flag = 1 means clockwise; the - DrawingML convention is also clockwise so we pass sweep_flag = 1 when - swAng > 0. - """ - try: - wR_emu = float(arc_elem.attrib.get("wR", "0")) - hR_emu = float(arc_elem.attrib.get("hR", "0")) - st_ang = float(arc_elem.attrib.get("stAng", "0")) - sw_ang = float(arc_elem.attrib.get("swAng", "0")) - except ValueError: - return "", cx, cy - - if wR_emu <= 0 or hR_emu <= 0: - return "", cx, cy - - rx = emu_to_px(wR_emu) * sx - ry = emu_to_px(hR_emu) * sy - st_rad = math.radians(st_ang / 60000.0) - sw_rad = math.radians(sw_ang / 60000.0) - end_rad = st_rad + sw_rad - - # Center of the ellipse in slide-absolute coords - arc_cx = cx - rx * math.cos(st_rad) - arc_cy = cy - ry * math.sin(st_rad) - end_x = arc_cx + rx * math.cos(end_rad) - end_y = arc_cy + ry * math.sin(end_rad) - - abs_sw = abs(sw_ang) / 60000.0 - large_arc = 1 if abs_sw > 180.0 else 0 - sweep = 1 if sw_ang >= 0 else 0 - - return ( - f"A {fmt_num(rx)} {fmt_num(ry)} 0 {large_arc} {sweep} " - f"{fmt_num(end_x)} {fmt_num(end_y)}", - end_x, - end_y, +def _convert_one_path( + path_elem: ET.Element, + cust_geom: ET.Element, + xfrm: Xfrm, + shape_evaluator: FormulaEvaluator, +) -> str: + coordinate_width = _path_extent( + path_elem.get("w"), + xfrm.w, + shape_evaluator, ) + coordinate_height = _path_extent( + path_elem.get("h"), + xfrm.h, + shape_evaluator, + ) + path_evaluator = _custom_geometry_evaluator( + cust_geom, + coordinate_width, + coordinate_height, + ) + commands = tuple( + _evaluate_path_command(command, path_evaluator) + for command in path_elem + if isinstance(command.tag, str) + ) + return render_evaluated_path( + commands, + x=xfrm.x, + y=xfrm.y, + width=xfrm.w, + height=xfrm.h, + coordinate_width=coordinate_width, + coordinate_height=coordinate_height, + ) + + +def _evaluate_path_command( + element: ET.Element, + evaluator: FormulaEvaluator, +) -> _EvaluatedCommand: + name = element.tag.rsplit("}", 1)[-1] + if name == "arcTo": + values = tuple( + evaluator.evaluate_value(_required_attr(element, attr)) + for attr in ("wR", "hR", "stAng", "swAng") + ) + return _EvaluatedCommand(name=name, parameters=values) + expected_points = { + "moveTo": 1, + "lnTo": 1, + "quadBezTo": 2, + "cubicBezTo": 3, + "close": 0, + }.get(name) + if expected_points is None: + raise FormulaEvaluationError( + f"Unsupported custom geometry path command: {name!r}" + ) + points = element.findall("a:pt", NS) + if len(points) != expected_points: + raise FormulaEvaluationError( + f"Custom path command {name!r} expects {expected_points} point(s), " + f"found {len(points)}" + ) + values = tuple( + evaluator.evaluate_value(_required_attr(point, coordinate)) + for point in points + for coordinate in ("x", "y") + ) + return _EvaluatedCommand(name=name, parameters=values) + + +def _path_extent( + raw: str | None, + shape_extent: float, + evaluator: FormulaEvaluator, +) -> float: + if raw is None: + return shape_extent + value = evaluator.evaluate_value(raw) + return shape_extent if value == 0 else value + + +def _required_attr(element: ET.Element, name: str) -> str: + value = element.attrib.get(name) + if value is None or not value.strip(): + raise FormulaEvaluationError( + f"Custom geometry element {element.tag.rsplit('}', 1)[-1]!r} " + f"requires attribute {name!r}" + ) + return value.strip() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/emu_units.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/emu_units.py index af28850f..9ebb09c4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/emu_units.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/emu_units.py @@ -174,14 +174,16 @@ class Xfrm: cy = self.y + self.h / 2.0 parts: list[str] = [] if self.rot: - parts.append(f"rotate({_fmt(self.rot)} {_fmt(cx)} {_fmt(cy)})") + parts.append( + f"rotate({_fmt(self.rot, 8)} {_fmt(cx, 8)} {_fmt(cy, 8)})" + ) if self.flip_h or self.flip_v: sx = -1 if self.flip_h else 1 sy = -1 if self.flip_v else 1 # scale around shape center - parts.append(f"translate({_fmt(cx)} {_fmt(cy)})") + parts.append(f"translate({_fmt(cx, 8)} {_fmt(cy, 8)})") parts.append(f"scale({sx} {sy})") - parts.append(f"translate({_fmt(-cx)} {_fmt(-cy)})") + parts.append(f"translate({_fmt(-cx, 8)} {_fmt(-cy, 8)})") return " ".join(parts) if parts else None diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/normalized_chart_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/normalized_chart_svg.py new file mode 100644 index 00000000..f4065dbc --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/normalized_chart_svg.py @@ -0,0 +1,1217 @@ +"""Deterministic normalized SVG fallback for parsed classic charts. + +The renderer is intentionally independent from the native-chart OOXML +emitter. It visualizes only data and semantics that ``chart_to_svg`` has +already accepted, so a rendering failure can never invalidate an otherwise +valid editable-chart payload. +""" + +from __future__ import annotations + +import html +import math +import re +from dataclasses import dataclass +from datetime import date, timedelta +from decimal import Decimal, ROUND_HALF_UP +from typing import Any + + +_DEFAULT_COLORS = ( + "#4472C4", + "#ED7D31", + "#A5A5A5", + "#FFC000", + "#5B9BD5", + "#70AD47", + "#264478", + "#9E480E", +) + + +@dataclass(frozen=True) +class SeriesVisualStyle: + """Resolved paint used only by the normalized SVG fallback.""" + + fill: str | None + fill_opacity: float = 1.0 + stroke: str | None = None + stroke_opacity: float = 1.0 + stroke_width: float = 1.5 + line_cap: str = "round" + marker_fill: str | None = None + marker_fill_opacity: float = 1.0 + marker_stroke: str | None = None + marker_stroke_opacity: float = 1.0 + marker_stroke_width: float = 1.0 + marker_size: float = 5.0 + + +@dataclass(frozen=True) +class _Rect: + x: float + y: float + w: float + h: float + + +def render_normalized_chart_svg( + payload: dict[str, Any], + styles: list[SeriesVisualStyle], +) -> str | None: + """Render a readable normalized fallback for supported classic charts.""" + chart_type = str(payload.get("type") or "") + if chart_type not in { + "area", "bar", "column", "doughnut", "line", "pie", + "scatter", "bubble", + }: + return None + + try: + bounds = _Rect( + float(payload["x"]), + float(payload["y"]), + float(payload["width"]), + float(payload["height"]), + ) + except (KeyError, TypeError, ValueError, OverflowError): + return None + if ( + bounds.w <= 0 + or bounds.h <= 0 + or not all(math.isfinite(value) for value in vars(bounds).values()) + ): + return None + + legend_entries = _legend_entries(payload, styles) + content, legend_rect, title_parts = _outer_layout( + payload, + bounds, + len(legend_entries), + ) + parts = [*title_parts] + if chart_type in {"pie", "doughnut"}: + parts.extend(_render_pie(payload, styles, content, chart_type == "doughnut")) + elif chart_type in {"scatter", "bubble"}: + parts.extend(_render_xy(payload, styles, content, chart_type)) + else: + parts.extend(_render_category(payload, styles, content, chart_type)) + if legend_rect is not None: + parts.extend( + _render_legend( + legend_entries, + legend_rect, + str(payload.get("legend_position") or "b"), + ) + ) + return "\n".join(part for part in parts if part) + + +def _outer_layout( + payload: dict[str, Any], + bounds: _Rect, + legend_count: int, +) -> tuple[_Rect, _Rect | None, list[str]]: + pad = max(4.0, min(bounds.w, bounds.h) * 0.018) + content = _Rect( + bounds.x + pad, + bounds.y + pad, + max(1.0, bounds.w - 2 * pad), + max(1.0, bounds.h - 2 * pad), + ) + title_parts: list[str] = [] + title = _entry_text(payload.get("title")) + subtitle = _entry_text(payload.get("subtitle")) + if title: + title_size = _font_size( + _entry_value(payload.get("title"), "font_size"), + min(18.0, max(10.0, bounds.h * 0.055)), + bounds, + ) + title_y = content.y + title_size + title_parts.append( + _text( + content.x + content.w / 2, + title_y, + title, + size=title_size, + anchor="middle", + weight="600", + fill=_entry_color(payload.get("title"), "#333333"), + ) + ) + used = title_size + max(4.0, title_size * 0.35) + content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used)) + if subtitle: + subtitle_size = _font_size( + _entry_value(payload.get("subtitle"), "font_size"), + min(12.0, max(8.0, bounds.h * 0.035)), + bounds, + ) + title_parts.append( + _text( + content.x + content.w / 2, + content.y + subtitle_size, + subtitle, + size=subtitle_size, + anchor="middle", + fill=_entry_color(payload.get("subtitle"), "#666666"), + ) + ) + used = subtitle_size + max(3.0, subtitle_size * 0.3) + content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used)) + + if not payload.get("show_legend") or legend_count <= 0: + return content, None, title_parts + position = str(payload.get("legend_position") or "b").lower() + if position in {"l", "r", "left", "right"}: + legend_w = min(max(86.0, content.w * 0.24), max(1.0, content.w * 0.38)) + if position in {"l", "left"}: + legend = _Rect(content.x, content.y, legend_w, content.h) + content = _Rect( + content.x + legend_w, + content.y, + max(1.0, content.w - legend_w), + content.h, + ) + else: + legend = _Rect(content.x + content.w - legend_w, content.y, legend_w, content.h) + content = _Rect(content.x, content.y, max(1.0, content.w - legend_w), content.h) + return content, legend, title_parts + + columns = max(1, min(4, int(content.w // 130) or 1)) + rows = math.ceil(legend_count / columns) + legend_h = min(content.h * 0.32, max(20.0, rows * 17.0 + 4.0)) + if position in {"t", "top"}: + legend = _Rect(content.x, content.y, content.w, legend_h) + content = _Rect( + content.x, + content.y + legend_h, + content.w, + max(1.0, content.h - legend_h), + ) + else: + legend = _Rect(content.x, content.y + content.h - legend_h, content.w, legend_h) + content = _Rect(content.x, content.y, content.w, max(1.0, content.h - legend_h)) + return content, legend, title_parts + + +def _render_category( + payload: dict[str, Any], + styles: list[SeriesVisualStyle], + content: _Rect, + chart_type: str, +) -> list[str]: + categories = _category_labels(payload) + series = payload.get("series") or [] + if categories is None or not categories or not series: + return [] + styles = _complete_styles(styles, len(series)) + axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {} + is_bar = chart_type == "bar" + label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021)) + left = max(34.0, content.w * 0.075) + bottom = max(25.0, content.h * 0.105) + if is_bar: + left = min(content.w * 0.34, max(66.0, content.w * 0.18)) + bottom = max(24.0, content.h * 0.085) + if axis_titles.get("value"): + left += 14.0 if not is_bar else 0.0 + bottom += 14.0 if is_bar else 0.0 + if axis_titles.get("category"): + bottom += 14.0 if not is_bar else 0.0 + left += 14.0 if is_bar else 0.0 + plot = _Rect( + content.x + left, + content.y + 5.0, + max(12.0, content.w - left - 10.0), + max(12.0, content.h - bottom - 8.0), + ) + + grouping = str(payload.get("grouping") or ("clustered" if chart_type in {"bar", "column"} else "standard")) + segments, percent = _category_segments(series, len(categories), grouping) + scale_values = [value for row in segments for pair in row for value in pair] + lo, hi, ticks = _nice_scale(scale_values, include_zero=True, percent=percent) + parts: list[str] = [] + show_value_labels = payload.get("show_value_axis_labels") is not False + + if is_bar: + parts.extend(_horizontal_grid_and_ticks( + plot, + ticks, + lo, + hi, + label_size, + percent, + show_labels=show_value_labels, + )) + parts.extend(_bar_category_labels(plot, categories, label_size)) + else: + parts.extend(_vertical_grid_and_ticks( + plot, + ticks, + lo, + hi, + label_size, + percent, + show_labels=show_value_labels, + )) + parts.extend(_column_category_labels( + plot, + categories, + label_size, + point_aligned=chart_type in {"line", "area"}, + )) + parts.extend(_axis_titles(plot, content, axis_titles, label_size, is_bar=is_bar)) + + if chart_type in {"bar", "column"}: + parts.extend( + _render_bars( + payload, + categories, + series, + styles, + segments, + plot, + lo, + hi, + grouping, + horizontal=is_bar, + label_size=label_size, + percent=percent, + ) + ) + else: + parts.extend( + _render_lines_or_areas( + payload, + categories, + series, + styles, + segments, + plot, + lo, + hi, + chart_type, + label_size, + percent, + ) + ) + return parts + + +def _category_labels(payload: dict[str, Any]) -> list[str] | None: + raw_categories = payload.get("categories") or [] + axes = payload.get("axes") + category_axis = axes.get("category") if isinstance(axes, dict) else None + if not isinstance(category_axis, dict) or category_axis.get("kind") != "date": + return [str(value) for value in raw_categories] + + number_format = str(category_axis.get("number_format") or "").lower() + normalized_format = re.sub(r'\\.|"[^"]*"|\[[^]]*]', "", number_format) + labels: list[str] = [] + for raw_value in raw_categories: + try: + serial = float(raw_value) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(serial): + return None + date_parts = _excel_1900_date_parts(serial) + if date_parts is None: + return None + year, month, day = date_parts + if "yyyy-mm-dd" in normalized_format: + labels.append(f"{year:04d}-{month:02d}-{day:02d}") + elif "mm/dd/yyyy" in normalized_format: + labels.append(f"{month:02d}/{day:02d}/{year:04d}") + elif "m/d/yyyy" in normalized_format: + labels.append(f"{month}/{day}/{year:04d}") + else: + labels.append(f"{year:04d}-{month:02d}-{day:02d}") + return labels + + +def _excel_1900_date_parts(serial: float) -> tuple[int, int, int] | None: + day_number = math.floor(serial) + if day_number < 1: + return None + if day_number == 60: + return 1900, 2, 29 + epoch = date(1899, 12, 31) if day_number < 60 else date(1899, 12, 30) + try: + value = epoch + timedelta(days=day_number) + except (OverflowError, ValueError): + return None + return value.year, value.month, value.day + + +def _category_segments( + series: list[dict[str, Any]], + count: int, + grouping: str, +) -> tuple[list[list[tuple[float, float]]], bool]: + raw = [ + [float(value) for value in item.get("values", [])[:count]] + for item in series + ] + percent = grouping == "percentStacked" + if percent: + positive = [sum(max(row[idx], 0.0) for row in raw) for idx in range(count)] + negative = [sum(abs(min(row[idx], 0.0)) for row in raw) for idx in range(count)] + for row in raw: + for idx, value in enumerate(row): + denominator = positive[idx] if value >= 0 else negative[idx] + row[idx] = value / denominator if denominator else 0.0 + if grouping not in {"stacked", "percentStacked"}: + return [[(0.0, value) for value in row] for row in raw], percent + + positive_base = [0.0] * count + negative_base = [0.0] * count + result: list[list[tuple[float, float]]] = [] + for row in raw: + segments: list[tuple[float, float]] = [] + for idx, value in enumerate(row): + if value >= 0: + start = positive_base[idx] + positive_base[idx] += value + end = positive_base[idx] + else: + start = negative_base[idx] + negative_base[idx] += value + end = negative_base[idx] + segments.append((start, end)) + result.append(segments) + return result, percent + + +def _render_bars( + payload: dict[str, Any], + categories: list[str], + series: list[dict[str, Any]], + styles: list[SeriesVisualStyle], + segments: list[list[tuple[float, float]]], + plot: _Rect, + lo: float, + hi: float, + grouping: str, + *, + horizontal: bool, + label_size: float, + percent: bool, +) -> list[str]: + parts: list[str] = [] + stacked = grouping in {"stacked", "percentStacked"} + category_span = (plot.h if horizontal else plot.w) / max(len(categories), 1) + cluster = category_span * 0.72 + bar_span = cluster if stacked else cluster / max(len(series), 1) + labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None + for series_index, (item, style, row) in enumerate(zip(series, styles, segments)): + fill = style.fill or "none" + stroke = style.stroke or "none" + for category_index, (start, end) in enumerate(row): + offset = 0.0 if stacked else series_index * bar_span + display_index = ( + len(categories) - 1 - category_index + if horizontal else category_index + ) + cluster_start = display_index * category_span + (category_span - cluster) / 2 + if horizontal: + x1 = _map(end, lo, hi, plot.x, plot.x + plot.w) + x0 = _map(start, lo, hi, plot.x, plot.x + plot.w) + x = min(x0, x1) + y = plot.y + cluster_start + offset + w = max(abs(x1 - x0), 0.35) + h = max(bar_span * 0.9, 0.5) + else: + y1 = _map(end, lo, hi, plot.y + plot.h, plot.y) + y0 = _map(start, lo, hi, plot.y + plot.h, plot.y) + x = plot.x + cluster_start + offset + y = min(y0, y1) + w = max(bar_span * 0.9, 0.5) + h = max(abs(y1 - y0), 0.35) + parts.append( + f'' + ) + if labels: + value = float(item["values"][category_index]) + normalized_percent = end - start if percent else None + label = _data_label( + labels, + str(item.get("name") or ""), + categories[category_index], + value, + percent_value=normalized_percent, + ) + if label: + if horizontal: + tx = x1 + (4.0 if end >= start else -4.0) + ty = y + h / 2 + label_size * 0.32 + anchor = "start" if end >= start else "end" + else: + tx = x + w / 2 + ty = y1 - 4.0 if end >= start else y1 + label_size + 3.0 + anchor = "middle" + parts.append(_text(tx, ty, label, size=max(6.0, label_size - 1), anchor=anchor)) + return parts + + +def _render_lines_or_areas( + payload: dict[str, Any], + categories: list[str], + series: list[dict[str, Any]], + styles: list[SeriesVisualStyle], + segments: list[list[tuple[float, float]]], + plot: _Rect, + lo: float, + hi: float, + chart_type: str, + label_size: float, + percent: bool, +) -> list[str]: + parts: list[str] = [] + count = len(categories) + x_positions = [ + _category_point_x(plot, idx, count) + for idx in range(count) + ] + labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None + show_markers = chart_type == "line" and payload.get("line_style") == "lineMarker" + for series_index, (item, style, row) in enumerate(zip(series, styles, segments)): + fill_color = style.fill or "none" + line_color = style.stroke + top = [ + (x_positions[idx], _map(end, lo, hi, plot.y + plot.h, plot.y)) + for idx, (_start, end) in enumerate(row) + ] + if chart_type == "area": + bottom = [ + (x_positions[idx], _map(start, lo, hi, plot.y + plot.h, plot.y)) + for idx, (start, _end) in enumerate(row) + ] + points = top + list(reversed(bottom)) + parts.append( + f'' + ) + elif line_color is not None: + parts.append( + f'' + ) + if show_markers: + for x, y in top: + radius = max(2.0, style.marker_size / 2) + parts.append( + f'' + ) + if labels: + for idx, (x, y) in enumerate(top): + value = float(item["values"][idx]) + start, end = row[idx] + normalized_percent = end - start if percent else None + label = _data_label( + labels, + str(item.get("name") or ""), + categories[idx], + value, + percent_value=normalized_percent, + ) + if label: + parts.append(_text(x, y - 5.0, label, size=max(6.0, label_size - 1), anchor="middle")) + return parts + + +def _render_pie( + payload: dict[str, Any], + styles: list[SeriesVisualStyle], + content: _Rect, + doughnut: bool, +) -> list[str]: + categories = [str(value) for value in payload.get("categories") or []] + series = payload.get("series") or [] + if not categories or len(series) != 1: + return [] + values = [abs(float(value)) for value in series[0].get("values") or []] + total = sum(values) + styles = _complete_styles(styles, len(categories)) + label_size = max(6.0, min(10.0, content.h * 0.035, content.w * 0.018)) + radius = max(5.0, min(content.w, content.h) * 0.34) + cx = content.x + content.w / 2 + cy = content.y + content.h / 2 + if total <= 0: + parts = [ + f'' + ] + if doughnut: + parts.append( + f'' + ) + parts.append( + _text(cx, cy + label_size * 0.32, "0", size=label_size, anchor="middle") + ) + return parts + parts: list[str] = [] + angle = -math.pi / 2 + for idx, (category, value, style) in enumerate(zip(categories, values, styles)): + sweep = math.tau * value / total + end = angle + sweep + # Pie styles are completed before rendering, so ``None`` here means + # the source explicitly used ``noFill`` rather than an absent style. + fill = style.fill or "none" + stroke = style.stroke or "none" + if sweep >= math.tau - 1e-9: + if doughnut: + inner = radius * 0.75 + path = _full_ring_path(cx, cy, radius, inner) + parts.append( + f'' + ) + else: + parts.append( + f'' + ) + else: + path = _sector_path(cx, cy, radius, angle, end, radius * 0.75 if doughnut else 0.0) + parts.append( + f'' + ) + mid = angle + sweep / 2 + label_radius = radius * (0.87 if doughnut else 0.68) + lx = cx + math.cos(mid) * label_radius + ly = cy + math.sin(mid) * label_radius + label_size * 0.32 + percent = value / total + label = f"{category} {_format_percent(percent)}" + parts.append( + _text( + lx, + ly, + label, + size=label_size, + anchor="middle", + fill="#444444" if fill == "none" else _contrast_color(fill), + ) + ) + angle = end + return parts + + +def _render_xy( + payload: dict[str, Any], + styles: list[SeriesVisualStyle], + content: _Rect, + chart_type: str, +) -> list[str]: + series = payload.get("series") or [] + if not series: + return [] + styles = _complete_styles(styles, len(series)) + axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {} + label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021)) + left = max(40.0, content.w * 0.09) + (14.0 if axis_titles.get("y") else 0.0) + bottom = max(26.0, content.h * 0.1) + (14.0 if axis_titles.get("x") else 0.0) + plot = _Rect( + content.x + left, + content.y + 6.0, + max(12.0, content.w - left - 10.0), + max(12.0, content.h - bottom - 8.0), + ) + x_values = [float(value) for item in series for value in item.get("x") or []] + y_values = [float(value) for item in series for value in item.get("y") or []] + x_lo, x_hi, x_ticks = _nice_scale(x_values, include_zero=False) + y_lo, y_hi, y_ticks = _nice_scale(y_values, include_zero=False) + axes = payload.get("axes") + x_axis = axes.get("x") if isinstance(axes, dict) else None + y_axis = axes.get("y") if isinstance(axes, dict) else None + x_major_gridlines = ( + bool(x_axis.get("major_gridlines", False)) + if isinstance(x_axis, dict) + else False + ) + y_major_gridlines = ( + bool(y_axis.get("major_gridlines", True)) + if isinstance(y_axis, dict) + else True + ) + parts = _xy_grid_and_ticks( + plot, + x_ticks, + y_ticks, + x_lo, + x_hi, + y_lo, + y_hi, + label_size, + show_x_gridlines=x_major_gridlines, + show_y_gridlines=y_major_gridlines, + ) + parts.extend(_xy_axis_titles(plot, content, axis_titles, label_size)) + nonnegative_bubble_sizes = [ + float(value) + for item in series + for value in item.get("sizes") or [] + if float(value) >= 0 + ] + bubble_max = max(max(nonnegative_bubble_sizes or [0.0]), 1e-12) + scatter_style = str(payload.get("scatter_style") or "marker") + has_line = chart_type == "scatter" and scatter_style in {"line", "lineMarker", "smooth", "smoothMarker"} + has_marker = chart_type == "bubble" or scatter_style in {"marker", "lineMarker", "smoothMarker"} + for idx, (item, style) in enumerate(zip(series, styles)): + color = style.stroke or style.fill or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)] + points = [ + ( + _map(float(x), x_lo, x_hi, plot.x, plot.x + plot.w), + _map(float(y), y_lo, y_hi, plot.y + plot.h, plot.y), + ) + for x, y in zip(item.get("x") or [], item.get("y") or []) + ] + if has_line and style.stroke is not None and len(points) >= 2: + parts.append( + f'' + ) + if has_marker: + sizes = item.get("sizes") or [style.marker_size * style.marker_size] * len(points) + for point_index, (x, y) in enumerate(points): + if chart_type == "bubble": + bubble_size = float(sizes[point_index]) + if bubble_size < 0: + continue + radius = 3.0 + 13.0 * math.sqrt(bubble_size / bubble_max) + else: + radius = max(2.0, style.marker_size / 2) + fill = ( + style.fill or "none" + if chart_type == "bubble" + else style.marker_fill or "none" + ) + stroke = ( + style.stroke or "none" + if chart_type == "bubble" + else style.marker_stroke or "none" + ) + parts.append( + f'' + ) + return parts + + +def _vertical_grid_and_ticks( + plot: _Rect, + ticks: list[float], + lo: float, + hi: float, + size: float, + percent: bool, + *, + show_labels: bool, + show_gridlines: bool = True, +) -> list[str]: + parts: list[str] = [] + for tick in ticks: + y = _map(tick, lo, hi, plot.y + plot.h, plot.y) + if show_gridlines: + parts.append( + f'' + ) + if show_labels: + label = _format_percent(tick) if percent else _format_number(tick) + parts.append(_text(plot.x - 5.0, y + size * 0.32, label, size=size, anchor="end", fill="#666666")) + parts.append( + f'' + ) + return parts + + +def _horizontal_grid_and_ticks( + plot: _Rect, + ticks: list[float], + lo: float, + hi: float, + size: float, + percent: bool, + *, + show_labels: bool, +) -> list[str]: + parts: list[str] = [] + for tick in ticks: + x = _map(tick, lo, hi, plot.x, plot.x + plot.w) + parts.append( + f'' + ) + if show_labels: + label = _format_percent(tick) if percent else _format_number(tick) + parts.append(_text(x, plot.y + plot.h + size + 4.0, label, size=size, anchor="middle", fill="#666666")) + parts.append( + f'' + ) + return parts + + +def _column_category_labels( + plot: _Rect, + categories: list[str], + size: float, + *, + point_aligned: bool, +) -> list[str]: + parts: list[str] = [] + span = plot.w / max(len(categories), 1) + rotate = len(categories) > 8 or any(len(value) > 10 for value in categories) + for idx, category in enumerate(categories): + if point_aligned: + x = _category_point_x(plot, idx, len(categories)) + else: + x = plot.x + span * (idx + 0.5) + y = plot.y + plot.h + size + 5.0 + transform = f' transform="rotate(-35 {_fmt(x)} {_fmt(y)})"' if rotate else "" + anchor = "end" if rotate else "middle" + parts.append( + f'' + f'{html.escape(category)}' + ) + return parts + + +def _category_point_x(plot: _Rect, index: int, count: int) -> float: + """Align line/area data points and their category labels.""" + if count <= 1: + return plot.x + plot.w / 2 + return plot.x + plot.w * index / (count - 1) + + +def _bar_category_labels(plot: _Rect, categories: list[str], size: float) -> list[str]: + span = plot.h / max(len(categories), 1) + return [ + _text( + plot.x - 6.0, + plot.y + span * (len(categories) - idx - 0.5) + size * 0.32, + category, + size=size, + anchor="end", + fill="#555555", + ) + for idx, category in enumerate(categories) + ] + + +def _xy_grid_and_ticks( + plot: _Rect, + x_ticks: list[float], + y_ticks: list[float], + x_lo: float, + x_hi: float, + y_lo: float, + y_hi: float, + size: float, + *, + show_x_gridlines: bool, + show_y_gridlines: bool, +) -> list[str]: + parts = _vertical_grid_and_ticks( + plot, + y_ticks, + y_lo, + y_hi, + size, + False, + show_labels=True, + show_gridlines=show_y_gridlines, + ) + for tick in x_ticks: + x = _map(tick, x_lo, x_hi, plot.x, plot.x + plot.w) + if show_x_gridlines: + parts.append( + f'' + ) + parts.append(_text(x, plot.y + plot.h + size + 4.0, _format_number(tick), size=size, anchor="middle", fill="#666666")) + parts.append( + f'' + ) + return parts + + +def _axis_titles( + plot: _Rect, + content: _Rect, + titles: dict[str, Any], + size: float, + *, + is_bar: bool, +) -> list[str]: + category = str(titles.get("category") or "") + value = str(titles.get("value") or "") + x_title = value if is_bar else category + y_title = category if is_bar else value + return _draw_axis_titles(plot, content, x_title, y_title, size) + + +def _xy_axis_titles( + plot: _Rect, + content: _Rect, + titles: dict[str, Any], + size: float, +) -> list[str]: + return _draw_axis_titles( + plot, + content, + str(titles.get("x") or ""), + str(titles.get("y") or ""), + size, + ) + + +def _draw_axis_titles( + plot: _Rect, + content: _Rect, + x_title: str, + y_title: str, + size: float, +) -> list[str]: + parts: list[str] = [] + if x_title: + parts.append(_text(plot.x + plot.w / 2, content.y + content.h - 2.0, x_title, size=size, anchor="middle", weight="600")) + if y_title: + x = content.x + size + y = plot.y + plot.h / 2 + parts.append( + f'' + f'{html.escape(y_title)}' + ) + return parts + + +def _legend_entries( + payload: dict[str, Any], + styles: list[SeriesVisualStyle], +) -> list[tuple[str, str]]: + chart_type = str(payload.get("type") or "") + if chart_type in {"pie", "doughnut"}: + labels = [str(value) for value in payload.get("categories") or []] + else: + labels = [str(item.get("name") or f"Series {idx + 1}") for idx, item in enumerate(payload.get("series") or [])] + completed = _complete_styles(styles, len(labels)) + return [ + ( + label, + style.fill or style.stroke or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)], + ) + for idx, (label, style) in enumerate(zip(labels, completed)) + ] + + +def _render_legend( + entries: list[tuple[str, str]], + rect: _Rect, + position: str, +) -> list[str]: + if not entries: + return [] + size = max(6.0, min(10.0, rect.h * 0.18 if position.lower() in {"l", "r", "left", "right"} else 9.0)) + parts: list[str] = [] + if position.lower() in {"l", "r", "left", "right"}: + row_h = min(18.0, rect.h / max(len(entries), 1)) + for idx, (label, color) in enumerate(entries): + y = rect.y + row_h * (idx + 0.5) + parts.append( + f'' + ) + parts.append(_text(rect.x + 18.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444")) + return parts + + columns = max(1, min(4, int(rect.w // 130) or 1)) + rows = math.ceil(len(entries) / columns) + row_h = rect.h / max(rows, 1) + col_w = rect.w / columns + for idx, (label, color) in enumerate(entries): + row = idx // columns + col = idx % columns + x = rect.x + col * col_w + 5.0 + y = rect.y + row_h * (row + 0.5) + parts.append( + f'' + ) + parts.append(_text(x + 13.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444")) + return parts + + +def _data_label( + config: dict[str, Any], + series: str, + category: str, + value: float, + *, + percent_value: float | None, +) -> str: + fields: list[str] = [] + if config.get("show_series"): + fields.append(series) + if config.get("show_category"): + fields.append(category) + if config.get("show_value"): + fields.append(_format_data_label_value( + value, + config.get("number_format"), + )) + if config.get("show_percent"): + if percent_value is None: + raise ValueError( + "normalized percent labels require percent-stacked segments" + ) + number_format = config.get("number_format") + normalized_format = str(number_format or "").strip() + percent_format = ( + "0%" + if not normalized_format or normalized_format.lower() == "general" + else normalized_format + ) + fields.append(_format_data_label_value( + percent_value, + percent_format, + )) + return " · ".join(field for field in fields if field) + + +def _format_data_label_value(value: float, number_format: Any) -> str: + """Render the safe numeric subset used by normalized data labels. + + Unknown Excel format programs raise so the caller falls back to the + reconstruction-only route instead of displaying a materially wrong label. + """ + code = str(number_format or "").strip() + if not code or code.lower() == "general": + return _format_number(value) + match = re.fullmatch( + r"(?P[$¥¥€£]?)(?P#,##)?0" + r"(?P\.0+)?(?P%?)", + code, + ) + if match is None: + raise ValueError(f"unsupported normalized data-label format: {code!r}") + scaled = value * 100.0 if match.group("percent") else value + decimals = len((match.group("decimals") or "").lstrip(".")) + grouping = "," if match.group("group") else "" + quantum = Decimal(1).scaleb(-decimals) + rounded = Decimal(str(abs(scaled))).quantize( + quantum, + rounding=ROUND_HALF_UP, + ) + absolute = format(rounded, f"{grouping}.{decimals}f") + sign = "-" if scaled < 0 else "" + return ( + f"{sign}{match.group('prefix')}{absolute}{match.group('percent')}" + ) + + +def _nice_scale( + values: list[float], + *, + include_zero: bool, + percent: bool = False, +) -> tuple[float, float, list[float]]: + finite = [value for value in values if math.isfinite(value)] + if not finite: + finite = [0.0, 1.0] + lo = min(finite) + hi = max(finite) + if include_zero: + lo = min(lo, 0.0) + hi = max(hi, 0.0) + if percent: + lo = min(lo, 0.0) + hi = max(hi, 1.0) + if math.isclose(lo, hi): + delta = max(abs(lo) * 0.1, 1.0) + lo -= delta + hi += delta + raw_step = (hi - lo) / 5.0 + magnitude = 10 ** math.floor(math.log10(raw_step)) + normalized = raw_step / magnitude + nice = 1.0 if normalized <= 1 else 2.0 if normalized <= 2 else 2.5 if normalized <= 2.5 else 5.0 if normalized <= 5 else 10.0 + step = nice * magnitude + nice_lo = math.floor(lo / step) * step + nice_hi = math.ceil(hi / step) * step + count = max(1, int(round((nice_hi - nice_lo) / step))) + ticks = [nice_lo + idx * step for idx in range(count + 1)] + return nice_lo, nice_hi, ticks + + +def _sector_path( + cx: float, + cy: float, + outer: float, + start: float, + end: float, + inner: float, +) -> str: + x1 = cx + math.cos(start) * outer + y1 = cy + math.sin(start) * outer + x2 = cx + math.cos(end) * outer + y2 = cy + math.sin(end) * outer + large = 1 if end - start > math.pi else 0 + if inner <= 0: + return ( + f"M {_fmt(cx)} {_fmt(cy)} L {_fmt(x1)} {_fmt(y1)} " + f"A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} Z" + ) + ix2 = cx + math.cos(end) * inner + iy2 = cy + math.sin(end) * inner + ix1 = cx + math.cos(start) * inner + iy1 = cy + math.sin(start) * inner + return ( + f"M {_fmt(x1)} {_fmt(y1)} A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} " + f"L {_fmt(ix2)} {_fmt(iy2)} A {_fmt(inner)} {_fmt(inner)} 0 {large} 0 {_fmt(ix1)} {_fmt(iy1)} Z" + ) + + +def _full_ring_path(cx: float, cy: float, outer: float, inner: float) -> str: + return ( + f"M {_fmt(cx + outer)} {_fmt(cy)} " + f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx - outer)} {_fmt(cy)} " + f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx + outer)} {_fmt(cy)} Z " + f"M {_fmt(cx + inner)} {_fmt(cy)} " + f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx - inner)} {_fmt(cy)} " + f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx + inner)} {_fmt(cy)} Z" + ) + + +def _complete_styles( + styles: list[SeriesVisualStyle], + count: int, +) -> list[SeriesVisualStyle]: + result = list(styles[:count]) + while len(result) < count: + color = _DEFAULT_COLORS[len(result) % len(_DEFAULT_COLORS)] + result.append( + SeriesVisualStyle( + fill=color, + stroke=color, + marker_fill=color, + marker_stroke=color, + ) + ) + return result + + +def _entry_text(value: Any) -> str: + if isinstance(value, dict): + return str(value.get("text") or "") + return str(value or "") + + +def _entry_value(value: Any, key: str) -> Any: + return value.get(key) if isinstance(value, dict) else None + + +def _entry_color(value: Any, default: str) -> str: + color = _entry_value(value, "color") + return str(color) if isinstance(color, str) and color.startswith("#") else default + + +def _font_size(value: Any, default: float, bounds: _Rect) -> float: + try: + size = float(value) + except (TypeError, ValueError, OverflowError): + size = default + return max(6.0, min(size, max(8.0, bounds.h * 0.18))) + + +def _text( + x: float, + y: float, + value: str, + *, + size: float, + anchor: str = "start", + fill: str = "#444444", + weight: str | None = None, +) -> str: + weight_attr = f' font-weight="{weight}"' if weight else "" + return ( + f'' + f'{html.escape(str(value))}' + ) + + +def _map(value: float, lo: float, hi: float, out_lo: float, out_hi: float) -> float: + if math.isclose(lo, hi): + return (out_lo + out_hi) / 2 + return out_lo + (value - lo) * (out_hi - out_lo) / (hi - lo) + + +def _points(points: list[tuple[float, float]]) -> str: + return " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in points) + + +def _format_number(value: float) -> str: + if abs(value) >= 1_000_000: + return f"{value / 1_000_000:.1f}M".replace(".0M", "M") + if abs(value) >= 1_000: + return f"{value / 1_000:.1f}K".replace(".0K", "K") + if math.isclose(value, round(value)): + return str(int(round(value))) + return f"{value:.2f}".rstrip("0").rstrip(".") + + +def _format_percent(value: float) -> str: + percent = value * 100.0 + return f"{percent:.1f}%".replace(".0%", "%") + + +def _contrast_color(color: str) -> str: + token = color.lstrip("#") + try: + r, g, b = (int(token[idx:idx + 2], 16) for idx in (0, 2, 4)) + except (ValueError, TypeError): + return "#FFFFFF" + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 + return "#222222" if luminance > 0.62 else "#FFFFFF" + + +def _fmt(value: float) -> str: + if not math.isfinite(float(value)): + return "0" + rounded = round(float(value), 3) + if rounded == 0: + return "0" + if rounded.is_integer(): + return str(int(rounded)) + return f"{rounded:.3f}".rstrip("0").rstrip(".") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/ooxml_loader.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/ooxml_loader.py index e3de69f3..4a9ae1f0 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/ooxml_loader.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/ooxml_loader.py @@ -130,6 +130,7 @@ REL_TYPES = { "slideLayout": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", "slideMaster": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster", "theme": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", + "tableStyles": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles", "image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", "media": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/media", } @@ -156,6 +157,8 @@ class OoxmlPackage: self._layouts: dict[str, PartRef] = {} self._masters: dict[str, PartRef] = {} self._themes: dict[str, PartRef] = {} + self._table_styles: PartRef | None = None + self._table_styles_loaded = False # ------------------- context manager ------------------- @@ -311,6 +314,32 @@ class OoxmlPackage: return cached return None + def resolve_table_styles(self) -> PartRef | None: + """Return the presentation-level table style list, when usable. + + Table style definitions are optional and some producers emit only the + built-in style id. A missing or malformed style part must therefore + not prevent otherwise valid slides from being converted. + """ + if self._table_styles_loaded: + return self._table_styles + self._table_styles_loaded = True + + target: str | None = None + if self.presentation is not None: + for info in self.presentation.rels.values(): + if info.get("type") == REL_TYPES["tableStyles"]: + target = info.get("target") + break + if target is None: + target = "ppt/tableStyles.xml" + + try: + self._table_styles = self._load_part(target) + except RuntimeError: + self._table_styles = None + return self._table_styles + # ------------------- public iteration ------------------- def iter_slides(self) -> Iterator[SlideRef]: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_authoring.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_authoring.py new file mode 100644 index 00000000..e47a8953 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_authoring.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +""" +PPT Master - Authored Preset Shape Contract + +Build and validate canonical SVG fragments for newly authored PowerPoint +preset shapes. + +Usage: + Import render_preset_shape_fragment or validate_authored_preset_group. + +Examples: + fragment = render_preset_shape_fragment("rightArrow", (80, 120, 240, 96)) + +Dependencies: + None (only uses standard library and local PPT Master modules) +""" + +from __future__ import annotations + +import math +import re +from typing import Mapping +from xml.etree import ElementTree as ET + +from pptx_shapes import ( + CONNECTOR_PRESET_TYPES, + OOXML_COORDINATE_MAX, + OOXML_COORDINATE_MIN, + SUPPORTED_OPERATORS, + get_preset_registry, + resolve_preset_preview_hash, + svg_preset_preview_fingerprint, + validate_ooxml_line_width, + validate_ooxml_xfrm, +) + +from .emu_units import EMU_PER_PX, Xfrm, fmt_num +from .preset_registry_to_svg import render_preset_geometry +from .preset_svg_markup import attrs_to_xml, serialize_preset_layers + + +AUTHORING_ATTR = "data-pptx-authoring" +AUTHORING_VALUE = "preset" +_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") +_PAINT_RE = re.compile(r"(?:none|#[0-9A-Fa-f]{6})") +_INTEGER_RE = re.compile(r"[+-]?\d+") +_ADJUSTMENT_PREFIX = "data-pptx-av-" +_STYLE_ATTRS = ( + "fill", + "fill-opacity", + "stroke", + "stroke-linecap", + "stroke-linejoin", + "stroke-opacity", + "stroke-width", +) +_SEMANTIC_ATTRS = ( + AUTHORING_ATTR, + "data-pptx-object", + "data-pptx-prst", + "data-pptx-frame", +) + + +def render_preset_shape_fragment( + preset: str, + frame: tuple[float, float, float, float], + *, + adjustments: Mapping[str, str | int | float] | None = None, + object_kind: str = "shape", + element_id: str, + name: str | None = None, + style: Mapping[str, str] | None = None, +) -> str: + """Render one complete authored preset fragment for manual SVG insertion.""" + registry = get_preset_registry() + if preset not in registry: + raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") + if _ID_RE.fullmatch(element_id) is None: + raise ValueError(f"Invalid SVG element id: {element_id!r}") + if object_kind not in {"shape", "connector"}: + raise ValueError("object_kind must be 'shape' or 'connector'") + if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": + raise ValueError( + f"Connector preset {preset!r} requires object_kind='connector'" + ) + if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: + raise ValueError( + f"Authored connector requires a connector preset, got {preset!r}" + ) + + x, y, width, height = _validate_frame(frame, object_kind) + adjustment_values = _normalize_adjustments(adjustments or {}) + _validate_adjustments(preset, adjustment_values) + registry.evaluate( + preset, + width, + height, + adjustments=adjustment_values, + ) + rendered = render_preset_geometry( + preset, + Xfrm(x=x, y=y, w=width, h=height), + adjustment_values, + ) + if not rendered.paths: + raise ValueError(f"Preset {preset!r} produced no visible SVG paths") + + frame_text = " ".join( + fmt_num(value, 8) for value in (x, y, width, height) + ) + semantic_attrs = { + AUTHORING_ATTR: AUTHORING_VALUE, + "data-pptx-object": object_kind, + "data-pptx-prst": preset, + "data-pptx-frame": frame_text, + } + if name: + semantic_attrs["data-pptx-shape-name"] = name + for guide_name, formula in adjustment_values.items(): + semantic_attrs[f"{_ADJUSTMENT_PREFIX}{guide_name}"] = str(formula) + + style_attrs = _validate_style(style or {}) + if object_kind == "connector": + if style_attrs.get("fill", "none") != "none": + raise ValueError("Authored connector fill must be none") + if not _has_visible_stroke(style_attrs): + raise ValueError("Authored connector requires a visible stroke") + markup = serialize_preset_layers( + rendered.paths, + semantic_attrs, + style_attrs, + ) + group_attrs = { + "id": element_id, + **semantic_attrs, + "data-pptx-preview-sha256": markup.preview_hash, + } + return ( + f'\n' + f"{markup.markup}\n" + "" + ) + + +def validate_authored_preset_group(group: ET.Element) -> list[str]: + """Return canonical authored-preset contract errors for one logical group.""" + if group.get(AUTHORING_ATTR) != AUTHORING_VALUE: + return [] + errors: list[str] = [] + if _local_name(group.tag) != "g": + return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG '] + element_id = group.get("id") + if element_id is None: + errors.append("Authored preset logical group requires a stable id") + elif _ID_RE.fullmatch(element_id) is None: + errors.append(f"Authored preset logical group has invalid id {element_id!r}") + + unexpected_group_attrs = sorted( + name for name in group.attrib + if _is_unexpected_group_attr(name) + ) + if unexpected_group_attrs: + errors.append( + "Authored preset logical group has unsupported attributes: " + + ", ".join(unexpected_group_attrs) + ) + + direct_children = list(group) + carriers = [ + child + for child in direct_children + if child.get("data-pptx-part") == "geometry" + ] + previews = [ + child + for child in direct_children + if child.get("data-pptx-part") == "geometry-preview" + ] + if len(carriers) != 1: + errors.append( + f"Authored preset requires exactly one direct geometry carrier; " + f"found {len(carriers)}" + ) + if len(previews) != 1: + errors.append( + f"Authored preset requires exactly one direct geometry preview; " + f"found {len(previews)}" + ) + allowed_children = set(carriers + previews) + foreign_children = [ + child for child in direct_children + if child not in allowed_children + ] + if foreign_children: + errors.append( + "Authored preset groups are atomic; place labels or decorations " + "in a parent group" + ) + if len(carriers) != 1 or len(previews) != 1: + return errors + + carrier = carriers[0] + preview = previews[0] + if _local_name(carrier.tag) != "path": + errors.append("Authored preset geometry carrier must be an SVG ") + if _local_name(preview.tag) != "g": + errors.append("Authored preset geometry preview must be an SVG ") + if carrier.get("visibility") != "hidden": + errors.append('Authored preset carrier requires visibility="hidden"') + if carrier.get("pointer-events") != "none": + errors.append('Authored preset carrier requires pointer-events="none"') + + for attr_name in _SEMANTIC_ATTRS: + if group.get(attr_name) != carrier.get(attr_name): + errors.append( + f"Authored preset group/carrier {attr_name} values differ" + ) + adjustment_names = { + name + for element in (group, carrier) + for name in element.attrib + if name.startswith(_ADJUSTMENT_PREFIX) + } + for attr_name in sorted(adjustment_names): + if group.get(attr_name) != carrier.get(attr_name): + errors.append( + f"Authored preset group/carrier {attr_name} values differ" + ) + + unexpected_carrier_attrs = [ + name + for name in carrier.attrib + if _is_unexpected_carrier_attr(name) + ] + if unexpected_carrier_attrs: + errors.append( + "Authored preset carrier has unsupported presentation attributes: " + + ", ".join(sorted(unexpected_carrier_attrs)) + ) + + preset = carrier.get("data-pptx-prst") or "" + object_kind = carrier.get("data-pptx-object") or "" + if object_kind not in {"shape", "connector"}: + errors.append( + "Authored preset data-pptx-object must be 'shape' or 'connector'" + ) + if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector": + errors.append( + f"Connector preset {preset!r} requires data-pptx-object='connector'" + ) + if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES: + errors.append( + f"Authored connector requires a connector preset, got {preset!r}" + ) + try: + frame = _parse_frame(carrier.get("data-pptx-frame"), object_kind) + adjustments = { + name[len(_ADJUSTMENT_PREFIX):]: value + for name, value in carrier.attrib.items() + if name.startswith(_ADJUSTMENT_PREFIX) + } + _validate_adjustments(preset, adjustments) + rendered = render_preset_geometry( + preset, + Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), + adjustments, + ) + style_attrs = _validate_style({ + name: carrier.attrib[name] + for name in _STYLE_ATTRS + if name in carrier.attrib + }) + if object_kind == "connector": + if style_attrs.get("fill", "none") != "none": + raise ValueError("Authored connector fill must be none") + if not _has_visible_stroke(style_attrs): + raise ValueError("Authored connector requires a visible stroke") + expected = serialize_preset_layers( + rendered.paths, + { + name: value + for name, value in carrier.attrib.items() + if name in _SEMANTIC_ATTRS + or name.startswith(_ADJUSTMENT_PREFIX) + or name == "data-pptx-shape-name" + }, + style_attrs, + ) + except ValueError as exc: + errors.append(f"Cannot regenerate authored preset preview: {exc}") + return errors + + if (carrier.get("d") or "").strip() != _carrier_path(rendered.paths): + errors.append("Authored preset carrier path differs from registry output") + actual_preview_hash = svg_preset_preview_fingerprint(group) + if actual_preview_hash != expected.preview_hash: + errors.append("Authored preset visible preview differs from registry output") + try: + stored_hash = resolve_preset_preview_hash(group) + except ValueError as exc: + errors.append(f"Invalid authored preset preview fingerprint: {exc}") + else: + if stored_hash != expected.preview_hash: + errors.append( + "Authored preset fingerprint does not match regenerated metadata" + ) + return errors + + +def validate_authored_preset_tree(root: ET.Element) -> list[str]: + """Return structural errors for every authored preset marker in one SVG.""" + errors: list[str] = [] + id_counts: dict[str, int] = {} + for element in root.iter(): + element_id = element.get("id") + if element_id: + id_counts[element_id] = id_counts.get(element_id, 0) + 1 + parents = { + child: parent + for parent in root.iter() + for child in parent + } + for element in root.iter(): + authoring = element.get(AUTHORING_ATTR) + if authoring is None: + continue + tag = _local_name(element.tag) + label = _element_label(element) + if authoring != AUTHORING_VALUE: + errors.append( + f"{label}: unsupported {AUTHORING_ATTR} value {authoring!r}" + ) + continue + if tag == "g": + errors.extend( + f"{label}: {error}" + for error in validate_authored_preset_group(element) + ) + element_id = element.get("id") + if element_id and id_counts.get(element_id, 0) > 1: + errors.append( + f"{label}: authored preset logical group id must be " + "globally unique" + ) + continue + if element.get("data-pptx-part") != "geometry": + errors.append( + f"{label}: authored preset metadata is allowed only on the " + "logical group and its direct geometry carrier" + ) + continue + parent = parents.get(element) + if ( + parent is None + or _local_name(parent.tag) != "g" + or parent.get(AUTHORING_ATTR) != AUTHORING_VALUE + ): + errors.append( + f"{label}: authored preset geometry carrier must be a direct " + "child of its authored logical group" + ) + return errors + + +def _validate_frame( + frame: tuple[float, float, float, float], + object_kind: str, +) -> tuple[float, float, float, float]: + if len(frame) != 4: + raise ValueError("frame must contain x, y, width, and height") + values = tuple(float(value) for value in frame) + if not all(math.isfinite(value) for value in values): + raise ValueError("frame values must be finite") + width, height = values[2], values[3] + if object_kind == "connector": + if width < 0 or height < 0 or (width == 0 and height == 0): + raise ValueError( + "connector frame dimensions must be non-negative and not both zero" + ) + elif width <= 0 or height <= 0: + raise ValueError("shape frame width and height must be positive") + validate_ooxml_xfrm( + round(values[0] * EMU_PER_PX), + round(values[1] * EMU_PER_PX), + round(width * EMU_PER_PX), + round(height * EMU_PER_PX), + ) + return values + + +def _parse_frame( + raw: str | None, + object_kind: str, +) -> tuple[float, float, float, float]: + if raw is None: + raise ValueError("authored preset requires data-pptx-frame") + parts = re.split(r"[\s,]+", raw.strip()) + if len(parts) != 4: + raise ValueError("data-pptx-frame must contain four numbers") + return _validate_frame(tuple(float(part) for part in parts), object_kind) + + +def _validate_style(style: Mapping[str, str]) -> dict[str, str]: + unknown = sorted(set(style) - set(_STYLE_ATTRS)) + if unknown: + raise ValueError(f"Unsupported authored preset style attributes: {unknown}") + normalized = {name: str(value).strip() for name, value in style.items()} + if not normalized: + raise ValueError("Authored preset requires explicit fill and/or stroke") + if normalized.get("fill", "none") == "none" and normalized.get( + "stroke", "none" + ) == "none": + raise ValueError("Authored preset cannot have both fill and stroke set to none") + for name in ("fill", "stroke"): + value = normalized.get(name, "none") + if _PAINT_RE.fullmatch(value) is None: + raise ValueError(f"{name} must be none or a six-digit HEX color") + normalized[name] = value.upper() if value != "none" else value + if normalized.get("stroke", "none") == "none": + unused_stroke_attrs = sorted( + name for name in normalized + if name.startswith("stroke-") + ) + if unused_stroke_attrs: + raise ValueError( + "Stroke presentation attributes require a visible stroke: " + + ", ".join(unused_stroke_attrs) + ) + if normalized.get("stroke-linecap") not in {None, "butt", "round", "square"}: + raise ValueError("stroke-linecap must be butt, round, or square") + if normalized.get("stroke-linejoin") not in {None, "miter", "round", "bevel"}: + raise ValueError("stroke-linejoin must be miter, round, or bevel") + for name in ("fill-opacity", "stroke-opacity"): + if name not in normalized: + continue + value = float(normalized[name]) + if not math.isfinite(value) or value < 0 or value > 1: + raise ValueError(f"{name} must be between 0 and 1") + normalized[name] = fmt_num(value, 6) + if "stroke-width" in normalized: + width = float(normalized["stroke-width"]) + if not math.isfinite(width) or width < 0: + raise ValueError("stroke-width must be finite and non-negative") + validate_ooxml_line_width(round(width * EMU_PER_PX)) + normalized["stroke-width"] = fmt_num(width, 6) + if normalized.get("fill", "none") == "none" and "fill-opacity" in normalized: + raise ValueError("fill-opacity requires a visible fill paint") + if not _has_visible_fill(normalized) and not _has_visible_stroke(normalized): + raise ValueError( + "Authored preset requires at least one non-transparent visible paint" + ) + return normalized + + +def _normalize_adjustments( + adjustments: Mapping[str, str | int | float], +) -> dict[str, str]: + normalized: dict[str, str] = {} + for name, value in adjustments.items(): + if isinstance(value, bool): + raise ValueError(f"Adjustment {name!r} must not be boolean") + if isinstance(value, int): + formula = f"val {value}" + elif isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise ValueError( + f"Numeric adjustment {name!r} must be a finite integer" + ) + formula = f"val {int(value)}" + else: + formula = str(value).strip() + if len(formula.split()) == 1: + formula = f"val {formula}" + normalized[str(name)] = formula + return normalized + + +def _validate_adjustments( + preset: str, + adjustments: Mapping[str, str | int | float], +) -> None: + registry = get_preset_registry() + if preset not in registry: + raise ValueError(f"Unknown DrawingML preset shape: {preset!r}") + for name, formula in adjustments.items(): + if not isinstance(formula, str) or not formula.strip(): + raise ValueError(f"Adjustment {name!r} requires a formula") + parts = formula.split() + if parts[0] not in SUPPORTED_OPERATORS: + raise ValueError( + f"Adjustment {name!r} must use a DrawingML formula operator" + ) + if parts[0] == "val" and len(parts) == 2: + try: + float(parts[1]) + except ValueError: + pass + else: + if _INTEGER_RE.fullmatch(parts[1]) is None: + raise ValueError( + f"Adjustment {name!r} val operand must be an integer " + "coordinate" + ) + if not adjustments: + return + evaluated = registry.evaluate( + preset, + 100000, + 100000, + adjustments=adjustments, + ) + for name, value in evaluated.adjustments.items(): + if name not in adjustments: + continue + if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: + raise ValueError( + f"Adjustment {name!r} evaluates outside OOXML coordinate range" + ) + + +def _has_visible_fill(style: Mapping[str, str]) -> bool: + return ( + style.get("fill", "none") != "none" + and float(style.get("fill-opacity", "1")) > 0 + ) + + +def _has_visible_stroke(style: Mapping[str, str]) -> bool: + return ( + style.get("stroke", "none") != "none" + and float(style.get("stroke-opacity", "1")) > 0 + and float(style.get("stroke-width", "1")) > 0 + ) + + +def _is_unexpected_carrier_attr(name: str) -> bool: + if name in { + "d", + "data-pptx-preview-sha256", + "data-pptx-part", + "data-pptx-shape-name", + "visibility", + "pointer-events", + *_SEMANTIC_ATTRS, + *_STYLE_ATTRS, + }: + return False + return not name.startswith(_ADJUSTMENT_PREFIX) + + +def _is_unexpected_group_attr(name: str) -> bool: + if name in { + "id", + "transform", + "data-pptx-preview-sha256", + "data-pptx-shape-name", + *_SEMANTIC_ATTRS, + }: + return False + if name.startswith(_ADJUSTMENT_PREFIX): + return False + if name.startswith("data-pptx-runtime-") or name.startswith("aria-"): + return False + return name not in {"role", "tabindex"} + + +def _carrier_path(paths) -> str: + return " ".join(path.d for path in paths).strip() + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _element_label(element: ET.Element) -> str: + tag = _local_name(element.tag) + element_id = element.get("id") + if element_id: + return f'<{tag} id="{element_id}">' + return f"<{tag}>" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_registry_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_registry_to_svg.py new file mode 100644 index 00000000..db0a7fd2 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_registry_to_svg.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +PPT Master - Preset Geometry SVG Adapter + +Render evaluated DrawingML preset geometry as absolute SVG path layers. + +Usage: + Import render_preset_geometry from pptx_to_svg.preset_registry_to_svg. + +Examples: + geometry = render_preset_geometry("rightArrow", xfrm) + +Dependencies: + None (only uses standard library and local PPT Master modules) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Mapping + +from pptx_shapes import get_preset_registry + +from .emu_units import Xfrm, fmt_num + + +@dataclass(frozen=True) +class SvgPresetPath: + """One visible layer from a DrawingML preset's ``a:pathLst``.""" + + d: str + fill: str + stroke: bool + + +@dataclass(frozen=True) +class SvgPresetGeometry: + """A fully evaluated preset preview in slide-absolute SVG coordinates.""" + + paths: tuple[SvgPresetPath, ...] + + +def render_preset_geometry( + preset: str, + xfrm: Xfrm, + adjustments: Mapping[str, str | int | float] | None = None, +) -> SvgPresetGeometry: + """Evaluate ``preset`` and project every DrawingML path into SVG space.""" + + evaluated = get_preset_registry().evaluate( + preset, + xfrm.w, + xfrm.h, + adjustments=adjustments, + ) + layers = tuple( + SvgPresetPath( + d=render_evaluated_path( + path.commands, + x=xfrm.x, + y=xfrm.y, + width=xfrm.w, + height=xfrm.h, + coordinate_width=path.coordinate_width, + coordinate_height=path.coordinate_height, + ), + fill=path.fill, + stroke=path.stroke, + ) + for path in evaluated.paths + ) + return SvgPresetGeometry(paths=tuple(layer for layer in layers if layer.d)) + + +def render_evaluated_path( + commands, + *, + x: float, + y: float, + width: float, + height: float, + coordinate_width: float, + coordinate_height: float, +) -> str: + scale_x = width / coordinate_width if coordinate_width else 1.0 + scale_y = height / coordinate_height if coordinate_height else 1.0 + + def point(px: float, py: float) -> tuple[float, float]: + return x + px * scale_x, y + py * scale_y + + parts: list[str] = [] + current = (x, y) + subpath_start = current + for command in commands: + values = command.parameters + if command.name == "moveTo": + current = point(values[0], values[1]) + subpath_start = current + parts.append(f"M {fmt_num(current[0])} {fmt_num(current[1])}") + elif command.name == "lnTo": + current = point(values[0], values[1]) + parts.append(f"L {fmt_num(current[0])} {fmt_num(current[1])}") + elif command.name == "quadBezTo": + control = point(values[0], values[1]) + current = point(values[2], values[3]) + parts.append( + "Q " + f"{fmt_num(control[0])} {fmt_num(control[1])} " + f"{fmt_num(current[0])} {fmt_num(current[1])}" + ) + elif command.name == "cubicBezTo": + control_1 = point(values[0], values[1]) + control_2 = point(values[2], values[3]) + current = point(values[4], values[5]) + parts.append( + "C " + f"{fmt_num(control_1[0])} {fmt_num(control_1[1])} " + f"{fmt_num(control_2[0])} {fmt_num(control_2[1])} " + f"{fmt_num(current[0])} {fmt_num(current[1])}" + ) + elif command.name == "arcTo": + arc_parts, current = _render_arc( + current, + radius_x=values[0], + radius_y=values[1], + scale_x=scale_x, + scale_y=scale_y, + start_angle=values[2], + sweep_angle=values[3], + ) + parts.extend(arc_parts) + elif command.name == "close": + parts.append("Z") + current = subpath_start + return " ".join(parts) + + +def _render_arc( + current: tuple[float, float], + *, + radius_x: float, + radius_y: float, + scale_x: float = 1.0, + scale_y: float = 1.0, + start_angle: float, + sweep_angle: float, +) -> tuple[list[str], tuple[float, float]]: + """Render one DrawingML arc, splitting full circles for SVG validity. + + DrawingML resolves the polar angle in the path-local ellipse before the + path coordinate system is scaled into the shape frame. Applying the + angle correction to already-scaled radii bends explicit-extent paths such + as ``cloud`` when the containing shape has a non-square aspect ratio. + """ + + radius_x = abs(radius_x) + radius_y = abs(radius_y) + scaled_radius_x = abs(radius_x * scale_x) + scaled_radius_y = abs(radius_y * scale_y) + if ( + radius_x <= 1e-12 + or radius_y <= 1e-12 + or scaled_radius_x <= 1e-12 + or scaled_radius_y <= 1e-12 + or abs(sweep_angle) <= 1e-12 + ): + return [], current + + start_radians = _ellipse_parameter_angle( + start_angle, + radius_x, + radius_y, + ) + center_x = current[0] - scaled_radius_x * math.cos(start_radians) + center_y = current[1] - scaled_radius_y * math.sin(start_radians) + + # SVG cannot represent a 360-degree arc with one A command because its + # start and end points coincide. Chunks of at most 180 degrees also keep + # the large-arc flag deterministic for every preset definition. + remaining = sweep_angle + angle = start_angle + parts: list[str] = [] + endpoint = current + half_circle = 180.0 * 60000.0 + while abs(remaining) > 1e-9: + step = math.copysign(min(abs(remaining), half_circle), remaining) + angle += step + end_radians = _ellipse_parameter_angle(angle, radius_x, radius_y) + endpoint = ( + center_x + scaled_radius_x * math.cos(end_radians), + center_y + scaled_radius_y * math.sin(end_radians), + ) + large_arc = 1 if abs(step) > half_circle else 0 + sweep = 1 if step >= 0 else 0 + parts.append( + "A " + f"{fmt_num(scaled_radius_x)} {fmt_num(scaled_radius_y)} " + f"0 {large_arc} {sweep} " + f"{fmt_num(endpoint[0])} {fmt_num(endpoint[1])}" + ) + remaining -= step + return parts, endpoint + + +def _ellipse_parameter_angle( + ooxml_angle: float, + radius_x: float, + radius_y: float, +) -> float: + """Unskew an OOXML polar angle into an ellipse parameter angle.""" + radians = math.radians(ooxml_angle / 60000.0) + return math.atan2( + radius_x * math.sin(radians), + radius_y * math.cos(radians), + ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_svg_markup.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_svg_markup.py new file mode 100644 index 00000000..25658376 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/preset_svg_markup.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +PPT Master - Preset Shape SVG Markup + +Serialize evaluated DrawingML preset layers into one native carrier and one +browser-visible SVG preview. + +Usage: + Import serialize_preset_layers from pptx_to_svg.preset_svg_markup. + +Examples: + markup = serialize_preset_layers(layers, semantic_attrs, style_attrs) + +Dependencies: + None (only uses standard library and local PPT Master modules) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence +from xml.etree import ElementTree as ET + +from pptx_shapes import svg_preset_preview_fingerprint + +from .preset_registry_to_svg import SvgPresetPath + + +@dataclass(frozen=True) +class PresetSvgMarkup: + """Canonical hidden-carrier and visible-preview markup for one preset.""" + + carrier: str + preview: str + preview_hash: str + + @property + def markup(self) -> str: + """Return the carrier and preview in canonical document order.""" + return f"{self.carrier}\n{self.preview}" + + +def serialize_preset_layers( + layers: Sequence[SvgPresetPath], + semantic_attrs: Mapping[str, str], + style_attrs: Mapping[str, str], +) -> PresetSvgMarkup: + """Serialize one preset without duplicating its native PowerPoint object.""" + detail_style_attrs = dict(style_attrs) + preview_group_attrs = {"data-pptx-part": "geometry-preview"} + for name in ("filter", "opacity"): + value = detail_style_attrs.pop(name, None) + if value is not None: + preview_group_attrs[name] = value + + detail_layers: list[str] = [] + for layer in layers: + attrs = dict(detail_style_attrs) + apply_preset_path_fill(attrs, layer.fill) + if not layer.stroke: + remove_stroke_attrs(attrs) + attrs["stroke"] = "none" + attrs["data-pptx-part"] = "geometry-detail" + detail_layers.append( + f'' + ) + + preview = ( + f'\n' + + "\n".join(detail_layers) + + "\n" + ) + preview_root = ET.fromstring( + f'{preview}' + ) + preview_hash = svg_preset_preview_fingerprint(preview_root) + carrier_attrs = { + **style_attrs, + **semantic_attrs, + "data-pptx-preview-sha256": preview_hash, + "data-pptx-part": "geometry", + "visibility": "hidden", + "pointer-events": "none", + } + combined_path = " ".join(layer.d for layer in layers) + carrier = ( + f'' + ) + return PresetSvgMarkup( + carrier=carrier, + preview=preview, + preview_hash=preview_hash, + ) + + +def apply_preset_path_fill(attrs: dict[str, str], mode: str) -> None: + """Apply one DrawingML path fill mode to SVG presentation attributes.""" + if mode == "none": + attrs["fill"] = "none" + attrs.pop("fill-opacity", None) + return + if mode == "norm": + return + color = attrs.get("fill", "") + if not color.startswith("#") or len(color) != 7: + return + try: + channels = tuple( + int(color[offset:offset + 2], 16) + for offset in (1, 3, 5) + ) + except ValueError: + return + if mode in {"darken", "darkenLess"}: + factor = 0.65 if mode == "darken" else 0.82 + adjusted = tuple(round(channel * factor) for channel in channels) + elif mode in {"lighten", "lightenLess"}: + amount = 0.4 if mode == "lighten" else 0.2 + adjusted = tuple( + round(channel + (255 - channel) * amount) + for channel in channels + ) + else: + return + attrs["fill"] = "#" + "".join( + f"{channel:02X}" for channel in adjusted + ) + + +def remove_stroke_attrs(attrs: dict[str, str]) -> None: + """Remove inherited stroke and marker attributes from one path layer.""" + for name in tuple(attrs): + if name.startswith("stroke") or name in {"marker-start", "marker-end"}: + attrs.pop(name, None) + + +def attrs_to_xml(attrs: Mapping[str, str]) -> str: + """Serialize SVG attributes in deterministic insertion order.""" + return "".join( + f' {name}="{_xml_escape(value)}"' + for name, value in attrs.items() + ) + + +def _xml_escape(value: str) -> str: + text = str(value) + if any(not _is_xml_10_character(character) for character in text): + raise ValueError("SVG markup contains an XML 1.0-incompatible character") + return ( + text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def _is_xml_10_character(character: str) -> bool: + codepoint = ord(character) + return ( + codepoint in {0x09, 0x0A, 0x0D} + or 0x20 <= codepoint <= 0xD7FF + or 0xE000 <= codepoint <= 0xFFFD + or 0x10000 <= codepoint <= 0x10FFFF + ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/prstgeom_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/prstgeom_to_svg.py index 28eb3347..e60b1a08 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/prstgeom_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/prstgeom_to_svg.py @@ -1,23 +1,27 @@ """DrawingML -> SVG geometry conversion. -svg_to_pptx only emits 4 prstGeom presets (rect / roundRect / ellipse / line); -everything else goes through custGeom. So the reverse pipeline only needs -strong support for those four to handle round-tripped decks. We additionally -include an extended preset map covering the most common PowerPoint-authored -shapes (triangle, diamond, hexagon, parallelogram, arrow, star, etc.) so -hand-built decks like muban.pptx don't fall through to a placeholder. +The visible SVG geometry is accompanied by the source preset name and every +explicit ``a:avLst`` guide formula. This metadata is rendering-neutral and +lets the reverse converter distinguish a native PowerPoint shape from an +arbitrary SVG path without guessing from its appearance. -Each handler returns a SHAPE_TAG + attribute dict that the slide assembler -wraps with fill/stroke/effect attributes plus the absolute (x, y) translation. +The standard preset catalog is evaluated by the shared data-driven geometry +engine. Presets outside that locked catalog retain their source semantics on +an explicitly marked bounding-box fallback; they never masquerade as ``rect``. + +The evaluated result carries one or more painted SVG path layers plus the +rendering-neutral metadata used by the reverse converter. """ from __future__ import annotations -import math from dataclasses import dataclass, field from xml.etree import ElementTree as ET +from pptx_shapes import get_preset_registry + from .emu_units import NS, Xfrm, fmt_num +from .preset_registry_to_svg import SvgPresetPath, render_preset_geometry # --------------------------------------------------------------------------- @@ -40,52 +44,24 @@ class GeomResult: path_d: str | None = None # When tag == 'polygon' / 'polyline' use points for the points attribute. points: str | None = None + # Standard presets can contain multiple independently painted path layers. + layers: tuple[SvgPresetPath, ...] = () # --------------------------------------------------------------------------- -# Adjustment helper +# Explicit fallback for presets outside the locked standard catalog # --------------------------------------------------------------------------- -def _adj_value(sp_pr: ET.Element | None, adj_name: str = "adj", - default_pct: float = 0.0) -> float: - """Read an adjustment value from . - - Returns the value as a fraction in [0, 1] of the relevant dimension. If - the gd is absent or the formula is unparseable, returns default_pct. - """ - if sp_pr is None: - return default_pct - av_lst = sp_pr.find(".//a:avLst", NS) - if av_lst is None: - return default_pct - for gd in av_lst.findall("a:gd", NS): - if gd.attrib.get("name") == adj_name: - fmla = gd.attrib.get("fmla", "") - if fmla.startswith("val "): - try: - return float(fmla[4:]) / 100000.0 - except ValueError: - return default_pct - return default_pct - - -def _adj_int_value(sp_pr: ET.Element | None, adj_name: str, - default: int) -> int: - """Read an adjustment value as the original DrawingML integer.""" - if sp_pr is None: - return default - av_lst = sp_pr.find(".//a:avLst", NS) - if av_lst is None: - return default - for gd in av_lst.findall("a:gd", NS): - if gd.attrib.get("name") == adj_name: - fmla = gd.attrib.get("fmla", "") - if fmla.startswith("val "): - try: - return int(float(fmla[4:])) - except ValueError: - return default - return default +def _rect(xfrm: Xfrm) -> GeomResult: + return GeomResult( + tag="rect", + attrs={ + "x": fmt_num(xfrm.x), + "y": fmt_num(xfrm.y), + "width": fmt_num(xfrm.w), + "height": fmt_num(xfrm.h), + }, + ) # --------------------------------------------------------------------------- @@ -99,572 +75,88 @@ def convert_prst_geom( ) -> GeomResult | None: """Convert to a GeomResult. - Returns None if the preset has no v1 mapping; the caller can then choose - to render a fallback rect. + Every emitted result carries ``data-pptx-prst`` plus one + ``data-pptx-av-`` attribute per explicit adjustment guide. Unknown + presets use a visibly neutral bounding-box fallback with diagnostic + metadata instead of silently changing their semantic type to ``rect``. + + Returns ``None`` only when the logical frame cannot produce geometry. """ - # Line-style presets accept zero width OR zero height (axis-aligned lines). - if prst in ("line", "straightConnector1"): - if xfrm.w == 0 and xfrm.h == 0: - return None - return _line(xfrm, sp_pr) - if xfrm.w <= 0 or xfrm.h <= 0: + metadata = _preset_metadata(prst, sp_pr) + registry = get_preset_registry() + if prst not in registry: + result = _rect(xfrm) + result.attrs.update(metadata) + result.attrs.update({ + "data-pptx-geometry-status": "unsupported", + "data-pptx-geometry-reason": f"unsupported-preset:{prst}", + }) + return result + if xfrm.w < 0 or xfrm.h < 0 or (xfrm.w == 0 and xfrm.h == 0): return None - handler = _PRESET_HANDLERS.get(prst) - if handler is None: + + try: + rendered = render_preset_geometry( + prst, + xfrm, + _preset_adjustments(sp_pr), + ) + except ValueError as exc: + result = _rect(xfrm) + result.attrs.update(metadata) + result.attrs.update({ + "data-pptx-geometry-status": "unsupported", + "data-pptx-geometry-reason": ( + f"preset-evaluation-error:{type(exc).__name__}" + ), + }) + return result + if not rendered.paths: return None - return handler(xfrm, sp_pr) - - -# --------------------------------------------------------------------------- -# Per-preset handlers -# --------------------------------------------------------------------------- - -def _rect(xfrm: Xfrm, _sp_pr) -> GeomResult: - return GeomResult( - tag="rect", - attrs={ - "x": fmt_num(xfrm.x), - "y": fmt_num(xfrm.y), - "width": fmt_num(xfrm.w), - "height": fmt_num(xfrm.h), - }, - ) - - -def _round_rect(xfrm: Xfrm, sp_pr) -> GeomResult: - """roundRect adj = ratio of corner radius to half of shorter side. - - DrawingML default adj = 16667 (16.667%) when avLst is absent. - """ - adj = _adj_value(sp_pr, "adj", default_pct=0.16667) - short = min(xfrm.w, xfrm.h) - radius = adj * short / 2.0 # adj is fraction of "half shorter side" - # Actually DrawingML "adj" for roundRect is fraction of the shorter side - # itself (i.e. up to 50000 = half side = capsule). svg_to_pptx writes - # adj = radius / shorterSide * 100000, so we invert: radius = adj * shorter. - radius = adj * short - radius = min(radius, short / 2.0) - return GeomResult( - tag="rect", - attrs={ - "x": fmt_num(xfrm.x), - "y": fmt_num(xfrm.y), - "width": fmt_num(xfrm.w), - "height": fmt_num(xfrm.h), - "rx": fmt_num(radius), - "ry": fmt_num(radius), - }, - ) - - -def _round_2_diag_rect(xfrm: Xfrm, sp_pr) -> GeomResult: - """Rectangle with two diagonal rounded corners. - - DrawingML's round2DiagRect rounds the top-left and bottom-right corners. - SVG has no direct primitive for per-corner radii, so emit a native-friendly - path with quadratic curves. - """ - adj = _adj_value(sp_pr, "adj", default_pct=0.16667) - r = min(adj * min(xfrm.w, xfrm.h), min(xfrm.w, xfrm.h) / 2.0) - x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h - d = ( - f"M {fmt_num(x + r)} {fmt_num(y)} " - f"L {fmt_num(x + w)} {fmt_num(y)} " - f"L {fmt_num(x + w)} {fmt_num(y + h - r)} " - f"Q {fmt_num(x + w)} {fmt_num(y + h)} {fmt_num(x + w - r)} {fmt_num(y + h)} " - f"L {fmt_num(x)} {fmt_num(y + h)} " - f"L {fmt_num(x)} {fmt_num(y + r)} " - f"Q {fmt_num(x)} {fmt_num(y)} {fmt_num(x + r)} {fmt_num(y)} Z" - ) - return GeomResult(tag="path", path_d=d) - - -def _round_2_same_rect(xfrm: Xfrm, sp_pr) -> GeomResult: - """Rectangle with top and bottom same-side corner adjustments. - - Mirrors the OOXML preset definition: adj1 controls the top pair of corners, - adj2 controls the bottom pair. A common title-tab setting is adj1=0 and - adj2=50000, yielding square top corners and a capsule-like bottom edge. - """ - x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h - ss = min(w, h) - adj1 = _adj_int_value(sp_pr, "adj1", 16667) - adj2 = _adj_int_value(sp_pr, "adj2", 0) - tx = min(adj1 / 100000.0, 0.5) * ss - bx = min(adj2 / 100000.0, 0.5) * ss - parts = [ - f"M {fmt_num(x + tx)} {fmt_num(y)}", - f"L {fmt_num(x + w - tx)} {fmt_num(y)}", - ] - if tx > 0: - parts.append( - f"A {fmt_num(tx)} {fmt_num(tx)} 0 0 1 " - f"{fmt_num(x + w)} {fmt_num(y + tx)}" - ) - parts.append(f"L {fmt_num(x + w)} {fmt_num(y + h - bx)}") - if bx > 0: - parts.append( - f"A {fmt_num(bx)} {fmt_num(bx)} 0 0 1 " - f"{fmt_num(x + w - bx)} {fmt_num(y + h)}" - ) - else: - parts.append(f"L {fmt_num(x + w)} {fmt_num(y + h)}") - parts.append(f"L {fmt_num(x + bx)} {fmt_num(y + h)}") - if bx > 0: - parts.append( - f"A {fmt_num(bx)} {fmt_num(bx)} 0 0 1 " - f"{fmt_num(x)} {fmt_num(y + h - bx)}" - ) - else: - parts.append(f"L {fmt_num(x)} {fmt_num(y + h)}") - parts.append(f"L {fmt_num(x)} {fmt_num(y + tx)}") - if tx > 0: - parts.append( - f"A {fmt_num(tx)} {fmt_num(tx)} 0 0 1 " - f"{fmt_num(x + tx)} {fmt_num(y)}" - ) - d = " ".join(parts) + " Z" return GeomResult( tag="path", - attrs={ - "data-pptx-prst": "round2SameRect", - "data-pptx-adj1": str(adj1), - "data-pptx-adj2": str(adj2), - }, - path_d=d, + attrs=metadata, + path_d=" ".join(path.d for path in rendered.paths), + layers=rendered.paths, ) -def _ellipse(xfrm: Xfrm, _sp_pr) -> GeomResult: - cx = xfrm.x + xfrm.w / 2.0 - cy = xfrm.y + xfrm.h / 2.0 - rx = xfrm.w / 2.0 - ry = xfrm.h / 2.0 - return GeomResult( - tag="ellipse", - attrs={ - "cx": fmt_num(cx), "cy": fmt_num(cy), - "rx": fmt_num(rx), "ry": fmt_num(ry), - }, - ) - - -def _line(xfrm: Xfrm, _sp_pr) -> GeomResult: - # cxnSp line endpoints: x1,y1 = (x, y); x2,y2 = (x+w, y+h). flipH/flipV - # already baked into the xfrm via to_svg_transform. - return GeomResult( - tag="line", - attrs={ - "x1": fmt_num(xfrm.x), "y1": fmt_num(xfrm.y), - "x2": fmt_num(xfrm.x + xfrm.w), "y2": fmt_num(xfrm.y + xfrm.h), - }, - ) - - -# ---------- Polygon-based shapes ---------- - -def _polygon(points: list[tuple[float, float]]) -> GeomResult: - pts = " ".join(f"{fmt_num(x)},{fmt_num(y)}" for x, y in points) - return GeomResult(tag="polygon", points=pts) - - -def _triangle(xfrm: Xfrm, sp_pr) -> GeomResult: - """Isoceles triangle. adj controls apex x position (default 50%).""" - adj = _adj_value(sp_pr, "adj", default_pct=0.5) - apex_x = xfrm.x + adj * xfrm.w - return _polygon([ - (apex_x, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y + xfrm.h), - (xfrm.x, xfrm.y + xfrm.h), - ]) - - -def _rt_triangle(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _polygon([ - (xfrm.x, xfrm.y), - (xfrm.x, xfrm.y + xfrm.h), - (xfrm.x + xfrm.w, xfrm.y + xfrm.h), - ]) - - -def _diamond(xfrm: Xfrm, _sp_pr) -> GeomResult: - cx = xfrm.x + xfrm.w / 2.0 - cy = xfrm.y + xfrm.h / 2.0 - return _polygon([ - (cx, xfrm.y), - (xfrm.x + xfrm.w, cy), - (cx, xfrm.y + xfrm.h), - (xfrm.x, cy), - ]) - - -def _parallelogram(xfrm: Xfrm, sp_pr) -> GeomResult: - """adj = horizontal skew offset as fraction of width (default 25%).""" - adj = _adj_value(sp_pr, "adj", default_pct=0.25) - skew = adj * xfrm.w - return _polygon([ - (xfrm.x + skew, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y), - (xfrm.x + xfrm.w - skew, xfrm.y + xfrm.h), - (xfrm.x, xfrm.y + xfrm.h), - ]) - - -def _trapezoid(xfrm: Xfrm, sp_pr) -> GeomResult: - """OOXML trapezoid: x1 = ss * adj / 200000 (ss = min(w, h)). - - adj default 25000; maxAdj caps at 50000 * w / ss so the top can't invert. - """ - adj = _adj_int_value(sp_pr, "adj", 25000) - ss = min(xfrm.w, xfrm.h) - if ss <= 0: - return _rect(xfrm, sp_pr) - max_adj = 50000.0 * xfrm.w / ss - a = max(0.0, min(float(adj), max_adj)) - inset = ss * a / 200000.0 - return _polygon([ - (xfrm.x + inset, xfrm.y), - (xfrm.x + xfrm.w - inset, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y + xfrm.h), - (xfrm.x, xfrm.y + xfrm.h), - ]) - - -def _chevron(xfrm: Xfrm, sp_pr) -> GeomResult: - """OOXML chevron: dx = ss * adj / 100000 (ss = min(w, h)), default adj=50000. - - Both the right-pointing tip length and the left back-notch depth equal dx, - which is what lets a series of chevrons tile flush. - """ - adj = _adj_int_value(sp_pr, "adj", 50000) - x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h - ss = min(w, h) - if ss <= 0: - return _rect(xfrm, sp_pr) - max_adj = 100000.0 * w / ss - a = max(0.0, min(float(adj), max_adj)) - dx = ss * a / 100000.0 - cy = y + h / 2.0 - return _polygon([ - (x, y), - (x + w - dx, y), - (x + w, cy), - (x + w - dx, y + h), - (x, y + h), - (x + dx, cy), - ]) - - -def _home_plate(xfrm: Xfrm, sp_pr) -> GeomResult: - """OOXML homePlate: tip length = ss * adj / 100000; body fills 0..(w - tip). - - Same dx as chevron so a homePlate→chevron series tiles seamlessly. - """ - adj = _adj_int_value(sp_pr, "adj", 50000) - x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h - ss = min(w, h) - if ss <= 0: - return _rect(xfrm, sp_pr) - max_adj = 100000.0 * w / ss - a = max(0.0, min(float(adj), max_adj)) - dx = ss * a / 100000.0 - split = w - dx - cy = y + h / 2.0 - return _polygon([ - (x, y), - (x + split, y), - (x + w, cy), - (x + split, y + h), - (x, y + h), - ]) - - -def _flow_chart_extract(xfrm: Xfrm, _sp_pr) -> GeomResult: - """Flowchart "Extract": upward-pointing isoceles triangle.""" - return _polygon([ - (xfrm.x + xfrm.w / 2.0, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y + xfrm.h), - (xfrm.x, xfrm.y + xfrm.h), - ]) - - -def _flow_chart_merge(xfrm: Xfrm, _sp_pr) -> GeomResult: - """Flowchart "Merge": downward-pointing isoceles triangle.""" - return _polygon([ - (xfrm.x, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y), - (xfrm.x + xfrm.w / 2.0, xfrm.y + xfrm.h), - ]) - - -def _regular_polygon(xfrm: Xfrm, n_sides: int, *, rot_deg: float = -90.0) -> GeomResult: - """Regular polygon inscribed in the bounding box.""" - cx = xfrm.x + xfrm.w / 2.0 - cy = xfrm.y + xfrm.h / 2.0 - rx = xfrm.w / 2.0 - ry = xfrm.h / 2.0 - pts: list[tuple[float, float]] = [] - for i in range(n_sides): - ang = math.radians(rot_deg + i * 360.0 / n_sides) - pts.append((cx + rx * math.cos(ang), cy + ry * math.sin(ang))) - return _polygon(pts) - - -def _pentagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 5) - - -def _hexagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 6, rot_deg=0) - - -def _heptagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 7) - - -def _octagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 8, rot_deg=22.5) - - -def _decagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 10) - - -def _dodecagon(xfrm: Xfrm, _sp_pr) -> GeomResult: - return _regular_polygon(xfrm, 12) - - -def _star(n_points: int): - def handler(xfrm: Xfrm, _sp_pr) -> GeomResult: - cx = xfrm.x + xfrm.w / 2.0 - cy = xfrm.y + xfrm.h / 2.0 - r_outer_x = xfrm.w / 2.0 - r_outer_y = xfrm.h / 2.0 - # Inner radius: classic 5-pointed star uses ~0.382 of outer. - inner_ratio = 0.382 if n_points == 5 else 0.5 - r_inner_x = r_outer_x * inner_ratio - r_inner_y = r_outer_y * inner_ratio - pts: list[tuple[float, float]] = [] - for i in range(n_points * 2): - angle = math.radians(-90 + i * (360.0 / (n_points * 2))) - rx = r_outer_x if i % 2 == 0 else r_inner_x - ry = r_outer_y if i % 2 == 0 else r_inner_y - pts.append((cx + rx * math.cos(angle), cy + ry * math.sin(angle))) - return _polygon(pts) - return handler - - -# ---------- Arrow shapes ---------- - -def _right_arrow(xfrm: Xfrm, sp_pr) -> GeomResult: - """Right-pointing block arrow. adj1 = head width / shape height (50%); - adj2 = head length / shape width (50%).""" - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5) - adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5) - head_h = xfrm.h * adj1 - head_w = xfrm.w * adj2 - body_y_top = xfrm.y + (xfrm.h - head_h) / 2.0 - body_y_bot = xfrm.y + (xfrm.h + head_h) / 2.0 - head_x = xfrm.x + xfrm.w - head_w - return _polygon([ - (xfrm.x, body_y_top), - (head_x, body_y_top), - (head_x, xfrm.y), - (xfrm.x + xfrm.w, xfrm.y + xfrm.h / 2.0), - (head_x, xfrm.y + xfrm.h), - (head_x, body_y_bot), - (xfrm.x, body_y_bot), - ]) - - -def _left_arrow(xfrm: Xfrm, sp_pr) -> GeomResult: - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5) - adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5) - head_h = xfrm.h * adj1 - head_w = xfrm.w * adj2 - body_y_top = xfrm.y + (xfrm.h - head_h) / 2.0 - body_y_bot = xfrm.y + (xfrm.h + head_h) / 2.0 - head_x = xfrm.x + head_w - return _polygon([ - (xfrm.x + xfrm.w, body_y_top), - (head_x, body_y_top), - (head_x, xfrm.y), - (xfrm.x, xfrm.y + xfrm.h / 2.0), - (head_x, xfrm.y + xfrm.h), - (head_x, body_y_bot), - (xfrm.x + xfrm.w, body_y_bot), - ]) - - -def _down_arrow(xfrm: Xfrm, sp_pr) -> GeomResult: - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5) - adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5) - head_w = xfrm.w * adj1 - head_h = xfrm.h * adj2 - body_x_l = xfrm.x + (xfrm.w - head_w) / 2.0 - body_x_r = xfrm.x + (xfrm.w + head_w) / 2.0 - head_y = xfrm.y + xfrm.h - head_h - return _polygon([ - (body_x_l, xfrm.y), - (body_x_l, head_y), - (xfrm.x, head_y), - (xfrm.x + xfrm.w / 2.0, xfrm.y + xfrm.h), - (xfrm.x + xfrm.w, head_y), - (body_x_r, head_y), - (body_x_r, xfrm.y), - ]) - - -def _up_arrow(xfrm: Xfrm, sp_pr) -> GeomResult: - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.5) - adj2 = _adj_value(sp_pr, "adj2", default_pct=0.5) - head_w = xfrm.w * adj1 - head_h = xfrm.h * adj2 - body_x_l = xfrm.x + (xfrm.w - head_w) / 2.0 - body_x_r = xfrm.x + (xfrm.w + head_w) / 2.0 - head_y = xfrm.y + head_h - return _polygon([ - (body_x_l, xfrm.y + xfrm.h), - (body_x_l, head_y), - (xfrm.x, head_y), - (xfrm.x + xfrm.w / 2.0, xfrm.y), - (xfrm.x + xfrm.w, head_y), - (body_x_r, head_y), - (body_x_r, xfrm.y + xfrm.h), - ]) - - -# ---------- Decorative shapes ---------- - -def _plaque(xfrm: Xfrm, sp_pr) -> GeomResult: - """Approximate DrawingML plaque with concave curved corner cuts.""" - adj = _adj_value(sp_pr, "adj", default_pct=0.16667) - r = min(adj * min(xfrm.w, xfrm.h), min(xfrm.w, xfrm.h) / 2.0) - x, y, w, h = xfrm.x, xfrm.y, xfrm.w, xfrm.h - d = ( - f"M {fmt_num(x + r)} {fmt_num(y)} " - f"L {fmt_num(x + w - r)} {fmt_num(y)} " - f"Q {fmt_num(x + w - r)} {fmt_num(y + r)} {fmt_num(x + w)} {fmt_num(y + r)} " - f"L {fmt_num(x + w)} {fmt_num(y + h - r)} " - f"Q {fmt_num(x + w - r)} {fmt_num(y + h - r)} {fmt_num(x + w - r)} {fmt_num(y + h)} " - f"L {fmt_num(x + r)} {fmt_num(y + h)} " - f"Q {fmt_num(x + r)} {fmt_num(y + h - r)} {fmt_num(x)} {fmt_num(y + h - r)} " - f"L {fmt_num(x)} {fmt_num(y + r)} " - f"Q {fmt_num(x + r)} {fmt_num(y + r)} {fmt_num(x + r)} {fmt_num(y)} Z" - ) - return GeomResult(tag="path", path_d=d) - - -# ---------- Pie / chord / arc (path-based) ---------- - -def _pie(xfrm: Xfrm, sp_pr) -> GeomResult: - """Pie slice. adj1 = start angle, adj2 = end angle (1/60000 deg).""" - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.0) # default 0° - adj2 = _adj_value(sp_pr, "adj2", default_pct=270.0 / 360.0) # default 270° - # adj is in 100000ths of percent → degrees by * 360 - start_deg = adj1 * 360.0 - end_deg = adj2 * 360.0 - return _arc_path(xfrm, start_deg, end_deg, mode="pie") - - -def _chord(xfrm: Xfrm, sp_pr) -> GeomResult: - adj1 = _adj_value(sp_pr, "adj1", default_pct=0.0) - adj2 = _adj_value(sp_pr, "adj2", default_pct=270.0 / 360.0) - return _arc_path(xfrm, adj1 * 360.0, adj2 * 360.0, mode="chord") - - -def _arc(xfrm: Xfrm, sp_pr) -> GeomResult: - adj1 = _adj_value(sp_pr, "adj1", default_pct=270.0 / 360.0) - adj2 = _adj_value(sp_pr, "adj2", default_pct=0.0) - return _arc_path(xfrm, adj1 * 360.0, adj2 * 360.0, mode="arc") - - -def _arc_path(xfrm: Xfrm, start_deg: float, end_deg: float, *, mode: str) -> GeomResult: - cx = xfrm.x + xfrm.w / 2.0 - cy = xfrm.y + xfrm.h / 2.0 - rx = xfrm.w / 2.0 - ry = xfrm.h / 2.0 - sa = math.radians(start_deg) - ea = math.radians(end_deg) - sx = cx + rx * math.cos(sa) - sy = cy + ry * math.sin(sa) - ex = cx + rx * math.cos(ea) - ey = cy + ry * math.sin(ea) - # Sweep direction: PowerPoint draws clockwise; SVG arc sweep_flag = 1 = clockwise. - delta = (end_deg - start_deg) % 360.0 - large_arc = 1 if delta > 180 else 0 - sweep = 1 - parts = [f"M {fmt_num(sx)} {fmt_num(sy)}", - f"A {fmt_num(rx)} {fmt_num(ry)} 0 {large_arc} {sweep} {fmt_num(ex)} {fmt_num(ey)}"] - if mode == "pie": - parts.append(f"L {fmt_num(cx)} {fmt_num(cy)}") - parts.append("Z") - elif mode == "chord": - parts.append("Z") - return GeomResult(tag="path", path_d=" ".join(parts)) - - -# --------------------------------------------------------------------------- -# Preset table -# --------------------------------------------------------------------------- - -_PRESET_HANDLERS = { - # Core 4 (svg_to_pptx round-trip) - "rect": _rect, - "roundRect": _round_rect, - "round2DiagRect": _round_2_diag_rect, - "round2SameRect": _round_2_same_rect, - "ellipse": _ellipse, - "line": _line, - # Straight connector: same geometry as a `line` preset; head/tail markers - # come from . - "straightConnector1": _line, - - # Polygons - "triangle": _triangle, - "rtTriangle": _rt_triangle, - "diamond": _diamond, - "parallelogram": _parallelogram, - "trapezoid": _trapezoid, - "chevron": _chevron, - "homePlate": _home_plate, - "flowChartExtract": _flow_chart_extract, - "flowChartMerge": _flow_chart_merge, - "pentagon": _pentagon, - "hexagon": _hexagon, - "heptagon": _heptagon, - "octagon": _octagon, - "decagon": _decagon, - "dodecagon": _dodecagon, - - # Stars - "star4": _star(4), - "star5": _star(5), - "star6": _star(6), - "star7": _star(7), - "star8": _star(8), - "star10": _star(10), - "star12": _star(12), - "star16": _star(16), - "star24": _star(24), - "star32": _star(32), - - # Arrows - "rightArrow": _right_arrow, - "leftArrow": _left_arrow, - "downArrow": _down_arrow, - "upArrow": _up_arrow, - - # Decorative - "plaque": _plaque, - - # Pie / chord / arc - "pie": _pie, - "chord": _chord, - "arc": _arc, -} +def _preset_metadata( + prst: str, + prst_geom: ET.Element | None, +) -> dict[str, str]: + """Return rendering-neutral SVG attributes for native preset semantics.""" + attrs = {"data-pptx-prst": prst} + if prst_geom is None: + return attrs + + av_lst = prst_geom.find("a:avLst", NS) + if av_lst is None: + return attrs + for guide in av_lst.findall("a:gd", NS): + name = guide.attrib.get("name", "") + if not name: + continue + attrs[f"data-pptx-av-{name}"] = guide.attrib.get("fmla", "") + return attrs + + +def _preset_adjustments( + prst_geom: ET.Element | None, +) -> dict[str, str]: + """Return explicit instance adjustment formulas for registry evaluation.""" + if prst_geom is None: + return {} + av_lst = prst_geom.find("a:avLst", NS) + if av_lst is None: + return {} + return { + guide.attrib["name"]: guide.attrib.get("fmla", "") + for guide in av_lst.findall("a:gd", NS) + if guide.attrib.get("name") + } def supported_presets() -> set[str]: """Return the set of recognized prst values for diagnostics.""" - return set(_PRESET_HANDLERS.keys()) + return set(get_preset_registry().names) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py index ac56ea67..2cbb5270 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py @@ -22,10 +22,20 @@ animation anchor. from __future__ import annotations +import base64 +import hashlib import json from dataclasses import dataclass, field from xml.etree import ElementTree as ET +from pptx_shapes import ( + CONNECTOR_PRESET_TYPES, + NATIVE_FALLBACK_SHA256_ATTR, + has_relationship_attributes, + svg_native_fallback_markup_fingerprint, + svg_text_fingerprint, +) + from .color_resolver import ColorPalette, find_color_elem, resolve_color from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload from .custgeom_to_svg import convert_custom_geom @@ -36,6 +46,7 @@ from .ln_to_svg import resolve_stroke from .ooxml_loader import OoxmlPackage, PartRef, SlideRef from .pic_to_svg import convert_blip_fill, convert_picture from .prstgeom_to_svg import GeomResult, convert_prst_geom +from .preset_svg_markup import serialize_preset_layers from .shape_walker import ( CONNECTOR, GRAPHIC, GROUP, PICTURE, SHAPE, ShapeNode, get_background, walk_sp_tree, @@ -349,7 +360,13 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> if is_vertical: # Vertical text: geometry + image in one group, text in separate group geom_inner = (blip_image + "\n" + geom_xml) if blip_image else geom_xml - shape_xml = _wrap_shape_group(geom_inner, node, ctx, top_level=top_level) + shape_xml = _wrap_shape_group( + geom_inner, + node, + ctx, + top_level=top_level, + extra_attrs=_geometry_group_attrs(geom), + ) if not text_result.svg: return shape_xml text_group = ( @@ -365,35 +382,85 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> inner_parts.append(blip_image) if geom_xml: inner_parts.append(geom_xml) + if tx_body is not None and geom is not None: + inner_parts.append( + _txbody_metadata( + tx_body, + text_result.svg, + ) + ) if text_result.svg: inner_parts.append(text_result.svg) inner = "\n".join(inner_parts) if inner_parts else "" - return _wrap_shape_group(inner, node, ctx, top_level=top_level) + return _wrap_shape_group( + inner, + node, + ctx, + top_level=top_level, + extra_attrs=_geometry_group_attrs(geom), + ) + + +def _txbody_metadata( + tx_body: ET.Element, + visible_text_svg: str, +) -> str: + """Preserve the native text body while its visible SVG remains authoritative.""" + if has_relationship_attributes(tx_body): + # Relationship ids are part-local and cannot be copied into a newly + # generated slide without rebuilding the relationship target. + return "" + raw = ET.tostring(tx_body, encoding="utf-8") + encoded = base64.b64encode(raw).decode("ascii") + wrapper = ET.fromstring( + f'{visible_text_svg}' + ) + digest = svg_text_fingerprint(wrapper) + return ( + '{encoded}' + ) def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult | None: """Resolve a DrawingML shape geometry into an absolute SVG geometry model.""" prst_geom = sp_pr.find("a:prstGeom", NS) if sp_pr is not None else None cust_geom = sp_pr.find("a:custGeom", NS) if sp_pr is not None else None + prst = prst_geom.attrib.get("prst", "rect") if prst_geom is not None else None geom: GeomResult | None = None if prst_geom is not None: - prst = prst_geom.attrib.get("prst", "rect") geom = convert_prst_geom(prst, node.xfrm, prst_geom) - if geom is None: - # Unknown prst — fall back to rect bounding box - geom = convert_prst_geom("rect", node.xfrm, None) elif cust_geom is not None: d = convert_custom_geom(cust_geom, node.xfrm) if d: - geom = GeomResult(tag="path", path_d=d) + raw = ET.tostring(cust_geom, encoding="utf-8") + geom = GeomResult( + tag="path", + path_d=d, + attrs={ + "data-pptx-part": "geometry", + "data-pptx-geometry-kind": "custom", + "data-pptx-custgeom": base64.b64encode(raw).decode("ascii"), + "data-pptx-geometry-sha256": hashlib.sha256( + d.strip().encode("utf-8") + ).hexdigest(), + }, + ) else: # No geometry hint at all — render bounding rect geom = convert_prst_geom("rect", node.xfrm, None) if geom is None: return None - if geom.tag != "line" and (node.xfrm.w <= 0 or node.xfrm.h <= 0): + permits_degenerate_axis = ( + node.kind == CONNECTOR + or prst in CONNECTOR_PRESET_TYPES + ) + if ( + not permits_degenerate_axis + and (node.xfrm.w <= 0 or node.xfrm.h <= 0) + ): return None return geom @@ -443,8 +510,21 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None, # Skip emitting stroke="none" to keep markup tight. pass - geom_attrs_xml = _attrs_to_xml({**geom.attrs, **attrs}) - return _geom_to_svg(geom, geom_attrs_xml) + semantic_attrs = { + **geom.attrs, + **_object_metadata(node, ctx), + } + shape_style = node.xml.find("p:style", NS) + if shape_style is not None: + semantic_attrs["data-pptx-shape-style"] = base64.b64encode( + ET.tostring(shape_style, encoding="utf-8") + ).decode("ascii") + if geom.layers: + return _preset_layers_to_svg(geom, semantic_attrs, attrs) + return _geom_to_svg( + geom, + _attrs_to_xml({**semantic_attrs, **attrs}), + ) def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict[str, str]: @@ -490,9 +570,9 @@ def _resolve_ref_color(ref_elem: ET.Element | None, ctx: AssemblyContext) -> str return hex_ -def _geom_to_svg(geom: GeomResult, attrs_xml: str = "") -> str: +def _geom_to_svg(geom: GeomResult, attrs_xml: str | None = None) -> str: """Serialize a resolved geometry with optional SVG attributes.""" - if not attrs_xml: + if attrs_xml is None: attrs_xml = _attrs_to_xml(geom.attrs) if geom.tag == "path": return f'' @@ -501,6 +581,29 @@ def _geom_to_svg(geom: GeomResult, attrs_xml: str = "") -> str: return f"<{geom.tag}{attrs_xml}/>" +def _preset_layers_to_svg( + geom: GeomResult, + semantic_attrs: dict[str, str], + style_attrs: dict[str, str], +) -> str: + """Serialize one semantic carrier plus every visible preset path layer. + + DrawingML applies shape-level fill/line first, then each preset path can + override whether and how that paint is used. A hidden carrier retains the + unmodified shape-level style for native round-trip; visible detail paths + reproduce the preset's independent paint behavior without being exported + as duplicate PowerPoint shapes. + """ + markup = serialize_preset_layers( + geom.layers, + semantic_attrs, + style_attrs, + ) + geom.attrs["data-pptx-preview-sha256"] = markup.preview_hash + semantic_attrs["data-pptx-preview-sha256"] = markup.preview_hash + return markup.markup + + def _clip_blip_image(image_xml: str, geom: GeomResult | None, ctx: AssemblyContext) -> str: """Clip image fills to the owning shape geometry when it is not a plain rect.""" @@ -511,7 +614,7 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None, ctx.clip_seq[0] += 1 clip_id = f"{ctx.group_id_prefix}clip{ctx.clip_seq[0]}" - clip_shape = _geom_to_svg(geom) + clip_shape = _geom_to_svg(geom, "") ctx.defs.append( f'' f'{clip_shape}' @@ -542,7 +645,18 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) if not result.svg: return "" ctx.media.update(result.media) - return _wrap_shape_group(result.svg, node, ctx, top_level=top_level) + picture_svg = _inject_root_svg_attrs(result.svg, _object_metadata(node, ctx)) + return _wrap_shape_group(picture_svg, node, ctx, top_level=top_level) + + +def _inject_root_svg_attrs(markup: str, attrs: dict[str, str]) -> str: + """Attach source-object identity to a picture's root SVG element.""" + attrs_xml = _attrs_to_xml(attrs) + for tag in ("image", "svg"): + prefix = f"<{tag}" + if markup.startswith(prefix): + return markup.replace(prefix, f"{prefix}{attrs_xml}", 1) + return markup # --------------------------------------------------------------------------- @@ -551,8 +665,15 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: sp_pr = node.xml.find("p:spPr", NS) - geom_xml = _build_geometry_xml(node, sp_pr, ctx) - return _wrap_shape_group(geom_xml, node, ctx, top_level=top_level) + geom = _resolve_geometry(node, sp_pr) + geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom) + return _wrap_shape_group( + geom_xml, + node, + ctx, + top_level=top_level, + extra_attrs=_geometry_group_attrs(geom), + ) # --------------------------------------------------------------------------- @@ -663,8 +784,12 @@ def _render_graphic_table( tbl = graphic_data.find("a:tbl", NS) if tbl is None: return "", [] + table_styles_part = ctx.pkg.resolve_table_styles() result = convert_tbl( tbl, node.xfrm, ctx.palette, + table_styles=( + table_styles_part.xml if table_styles_part is not None else None + ), theme_fonts=ctx.theme_fonts, slide_number=ctx.slide_number, id_prefix=f"tbl{ctx.shape_seq[0]}", @@ -705,6 +830,7 @@ def _render_graphic_chart( node.xfrm, ctx.slide_part, ctx.pkg, + ctx.palette, ) native_attrs: list[str] = ['data-pptx-native-source="pptx"'] if result.native_payload: @@ -728,6 +854,16 @@ def _render_graphic_chart( rendered = "" if ctx.render_graphic_previews: rendered = _render_graphic_preview(node, ctx) + if rendered: + native_attrs.append('data-pptx-visual-status="source-preview"') + elif result.normalized_svg: + rendered = result.normalized_svg + native_attrs.append('data-pptx-visual-status="normalized"') + else: + native_attrs.extend([ + 'data-pptx-visual-status="placeholder"', + 'data-pptx-route-status="reconstruction-only"', + ]) return rendered, native_attrs @@ -994,12 +1130,32 @@ def _wrap_shape_group( g_id = f"{ctx.group_id_prefix}shape-{sid}" attrs: list[str] = [f'id="{g_id}"'] + attrs.extend( + f'{key}="{_xml_escape(value)}"' + for key, value in _object_metadata( + node, + ctx, + fallback_shape_id=sid, + ).items() + ) if node.name: attrs.append(f'data-name="{_xml_escape(node.name)}"') if node.placeholder is not None and node.placeholder.type: attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"') if extra_attrs: attrs.extend(extra_attrs) + if any( + attribute.split("=", 1)[0] == "data-pptx-native" + for attribute in extra_attrs + ): + fallback_hash = svg_native_fallback_markup_fingerprint( + inner, + root_transform=transform, + external_markup="".join(ctx.defs), + ) + attrs.append( + f'{NATIVE_FALLBACK_SHA256_ATTR}="{fallback_hash}"' + ) if transform: attrs.append(f'transform="{transform}"') return f"\n{inner}\n" @@ -1008,7 +1164,89 @@ def _wrap_shape_group( def _attrs_to_xml(attrs: dict[str, str]) -> str: if not attrs: return "" - return "".join(f' {k}="{v}"' for k, v in attrs.items()) + return "".join(f' {key}="{_xml_escape(value)}"' for key, value in attrs.items()) + + +def _geometry_group_attrs(geom: GeomResult | None) -> list[str]: + """Mirror native geometry semantics onto the logical shape container.""" + if geom is None: + return [] + keys = ( + "data-pptx-prst", + "data-pptx-geometry-kind", + "data-pptx-geometry-sha256", + "data-pptx-preview-sha256", + "data-pptx-geometry-status", + "data-pptx-geometry-reason", + ) + attrs: list[str] = [] + for key, value in geom.attrs.items(): + if key in keys or key.startswith("data-pptx-av-"): + attrs.append(f'{key}="{_xml_escape(value)}"') + return attrs + + +def _object_metadata( + node: ShapeNode, + ctx: AssemblyContext, + *, + fallback_shape_id: str = "", +) -> dict[str, str]: + """Describe the source object without coupling geometry to its SVG bounds.""" + object_kind = { + SHAPE: "shape", + PICTURE: "picture", + CONNECTOR: "connector", + GROUP: "group", + GRAPHIC: "graphic-frame", + }.get(node.kind, node.kind) + shape_id = node.spid or fallback_shape_id + frame = " ".join(( + fmt_num(node.xfrm.x, 8), + fmt_num(node.xfrm.y, 8), + fmt_num(node.xfrm.w, 8), + fmt_num(node.xfrm.h, 8), + )) + attrs = { + "data-pptx-object": object_kind, + "data-pptx-shape-id": shape_id, + "data-pptx-shape-scope": _shape_scope(ctx), + "data-pptx-frame": frame, + } + if node.name: + attrs["data-pptx-shape-name"] = node.name + if node.kind == CONNECTOR: + attrs.update(_connector_metadata(node, _shape_scope(ctx))) + return attrs + + +def _shape_scope(ctx: AssemblyContext) -> str: + if ctx.group_id_prefix.startswith("master-"): + return "master" + if ctx.group_id_prefix.startswith("layout-"): + return "layout" + return "slide" + + +def _connector_metadata(node: ShapeNode, scope: str) -> dict[str, str]: + """Preserve connector endpoint references when PowerPoint declares them.""" + attrs: dict[str, str] = {} + cnv = node.xml.find("p:nvCxnSpPr/p:cNvCxnSpPr", NS) + if cnv is None: + return attrs + + for endpoint, prefix in (("stCxn", "start"), ("endCxn", "end")): + connection = cnv.find(f"a:{endpoint}", NS) + if connection is None: + continue + shape_id = connection.attrib.get("id") + site = connection.attrib.get("idx") + if shape_id is not None: + attrs[f"data-pptx-{prefix}-shape-id"] = shape_id + attrs[f"data-pptx-{prefix}-shape-scope"] = scope + if site is not None: + attrs[f"data-pptx-{prefix}-site"] = site + return attrs def _xml_escape(text: str) -> str: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/tbl_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/tbl_to_svg.py index 1bfa5636..5ddedb84 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/tbl_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/tbl_to_svg.py @@ -36,6 +36,8 @@ Cell painting order: from __future__ import annotations +import copy +import math from dataclasses import dataclass from typing import Any from xml.etree import ElementTree as ET @@ -49,9 +51,51 @@ from .emu_units import ( hundredths_pt_to_px, ooxml_bool, ) -from .fill_to_svg import resolve_fill +from .fill_to_svg import FillResult, resolve_fill from .ln_to_svg import resolve_stroke -from .txbody_to_svg import convert_txbody +from .txbody_to_svg import _resolve_theme_typeface, convert_txbody + + +BUILTIN_MEDIUM_STYLE_2_ACCENT_1 = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" +_POWERPOINT_COORD_MIN = -(2**31) +_POWERPOINT_COORD_MAX = 2**31 - 1 + +_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML = ET.fromstring( + f''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +) @dataclass @@ -68,6 +112,155 @@ class TableResult: self.defs = [] +@dataclass(frozen=True) +class _TableStyleContext: + """Small, best-effort view of the table style regions we render.""" + + style: ET.Element | None + table_properties: ET.Element | None + + def regions_for_row(self, row_index: int) -> tuple[tuple[str, ET.Element], ...]: + if self.style is None: + return () + + names: list[str] = [] + first_row = bool( + self.table_properties is not None + and ooxml_bool(self.table_properties.get("firstRow")) + ) + if first_row and row_index == 0: + names.append("firstRow") + elif ( + self.table_properties is not None + and ooxml_bool(self.table_properties.get("bandRow")) + ): + band_index = row_index - (1 if first_row else 0) + names.append("band1H" if band_index % 2 == 0 else "band2H") + names.append("wholeTbl") + + regions: list[tuple[str, ET.Element]] = [] + for name in names: + region = self.style.find(f"a:{name}", NS) + if region is not None: + regions.append((name, region)) + return tuple(regions) + + +def _normalize_table_style_id(value: str | None) -> str: + return (value or "").strip().strip("{}").upper() + + +def _resolve_table_style( + tbl: ET.Element, + table_styles: ET.Element | None, +) -> _TableStyleContext: + tbl_pr = tbl.find("a:tblPr", NS) + style_id = ( + tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip() + if tbl_pr is not None else "" + ) + if not style_id and table_styles is not None: + style_id = table_styles.get("def", "").strip() + normalized_id = _normalize_table_style_id(style_id) + supported_id = _normalize_table_style_id(BUILTIN_MEDIUM_STYLE_2_ACCENT_1) + + # P1 deliberately supports one built-in family. Consuming an arbitrary + # custom definition here would be asymmetric: native reconstruction keeps + # only the style id and does not copy custom tableStyles.xml definitions. + if normalized_id != supported_id: + return _TableStyleContext(None, tbl_pr) + + if table_styles is not None and normalized_id: + for candidate in table_styles.findall("a:tblStyle", NS): + if _normalize_table_style_id(candidate.get("styleId")) == normalized_id: + return _TableStyleContext(candidate, tbl_pr) + + return _TableStyleContext(_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML, tbl_pr) + + +def _effective_cell_fill( + tc_pr: ET.Element | None, + table_style: _TableStyleContext, + row_index: int, + palette: ColorPalette | None, + *, + id_prefix: str, + id_seq: list[int], +) -> FillResult: + """Resolve direct cell fill before row-region and whole-table defaults.""" + direct = resolve_fill( + tc_pr, palette, id_prefix=id_prefix, id_seq=id_seq, + ) + if direct.attrs or direct.defs: + return direct + + for _name, region in table_style.regions_for_row(row_index): + fill_parent = region.find("a:tcStyle/a:fill", NS) + if fill_parent is None: + continue + inherited = resolve_fill( + fill_parent, palette, id_prefix=id_prefix, id_seq=id_seq, + ) + if inherited.attrs or inherited.defs: + return inherited + return direct + + +def _table_text_run_props( + table_style: _TableStyleContext, + row_index: int, + theme_fonts: dict[str, str], +) -> tuple[ET.Element, ...]: + """Materialize table text-style regions as lowest-priority run defaults.""" + props: list[ET.Element] = [] + for _name, region in table_style.regions_for_row(row_index): + tx_style = region.find("a:tcTxStyle", NS) + if tx_style is None: + continue + run_props = _table_tx_style_run_props(tx_style, theme_fonts) + if run_props is not None: + props.append(run_props) + return tuple(props) + + +def _table_tx_style_run_props( + tx_style: ET.Element, + theme_fonts: dict[str, str], +) -> ET.Element | None: + run_props = ET.Element(f"{{{NS['a']}}}rPr") + for attr in ("b", "i"): + value = tx_style.get(attr) + if value is not None: + run_props.set(attr, "1" if ooxml_bool(value) else "0") + + font_ref = tx_style.find("a:fontRef", NS) + font_role = font_ref.get("idx") if font_ref is not None else None + if font_role in {"major", "minor"}: + prefix = "major" if font_role == "major" else "minor" + latin = theme_fonts.get(f"{prefix}Latin") + east_asia = theme_fonts.get(f"{prefix}EastAsia") or latin + complex_script = theme_fonts.get(f"{prefix}ComplexScript") or latin + for tag, typeface in ( + ("latin", latin), + ("ea", east_asia), + ("cs", complex_script), + ): + if typeface: + ET.SubElement( + run_props, f"{{{NS['a']}}}{tag}", + {"typeface": typeface}, + ) + + color = find_color_elem(tx_style) + if color is not None: + solid_fill = ET.SubElement(run_props, f"{{{NS['a']}}}solidFill") + solid_fill.append(copy.deepcopy(color)) + + if not run_props.attrib and not list(run_props): + return None + return run_props + + # --------------------------------------------------------------------------- # Public entry # --------------------------------------------------------------------------- @@ -77,6 +270,7 @@ def convert_tbl( xfrm: Xfrm, palette: ColorPalette | None, *, + table_styles: ET.Element | None = None, theme_fonts: dict[str, str] | None = None, slide_number: int | None = None, id_prefix: str = "tbl", @@ -93,7 +287,19 @@ def convert_tbl( rows = tbl.findall("a:tr", NS) if not rows: return TableResult() + table_style = _resolve_table_style(tbl, table_styles) row_heights_px = [_row_height_px(r) for r in rows] + grid_topology_invalid = any( + len(row.findall("a:tc", NS)) != len(col_widths_px) + for row in rows + ) + source_geometry_invalid = ( + grid_topology_invalid + or any(width <= 0 for width in col_widths_px) + or any(height <= 0 for height in row_heights_px) + or sum(col_widths_px) <= 0 + or sum(row_heights_px) <= 0 + ) # PowerPoint's tblGrid widths and tr heights together describe the # *intrinsic* table size. The graphicFrame xfrm width/height may differ; @@ -113,15 +319,16 @@ def convert_tbl( # and dropped cells don't render anything. PowerPoint expresses merges via # gridSpan/rowSpan on the anchor cell + hMerge/vMerge on the dropped cells. cells = _build_cell_grid(rows, len(col_widths)) + merge_status = _canonical_native_merge_status(rows, len(col_widths)) if xfrm.rot or xfrm.flip_h or xfrm.flip_v: native_status = "unsupported-native-transform" - elif _table_has_merges(rows): - native_status = "unsupported-merge" + elif merge_status: + native_status = merge_status elif len(rows) > 1000 or len(col_widths) > 1000: native_status = "unsupported-table-size" - elif ( - any(width < 0 for width in col_widths) - or any(height < 0 for height in row_heights) + elif source_geometry_invalid or ( + any(width <= 0 for width in col_widths) + or any(height <= 0 for height in row_heights) or sum(col_widths) <= 0 or sum(row_heights) <= 0 ): @@ -140,6 +347,7 @@ def convert_tbl( row_heights, cells, palette, + theme_fonts or {}, ) ) @@ -156,8 +364,8 @@ def convert_tbl( rect_w = sum(col_widths[c:c + cell.col_span]) rect_h = sum(row_heights[r:r + cell.row_span]) tcPr = cell.element.find("a:tcPr", NS) - fill = resolve_fill( - tcPr, palette, + fill = _effective_cell_fill( + tcPr, table_style, r, palette, id_prefix=f"{id_prefix}fill", id_seq=grad_seq, ) @@ -186,6 +394,9 @@ def convert_tbl( cell_xfrm = Xfrm(x=cell_x, y=cell_y, w=cell_w, h=cell_h) text_result = _convert_cell_text( tx_body, tcPr, cell_xfrm, palette, theme_fonts, + fallback_run_props=_table_text_run_props( + table_style, r, theme_fonts or {}, + ), slide_number=slide_number, id_prefix=f"{id_prefix}txt", id_seq=grad_seq, @@ -211,7 +422,8 @@ def convert_tbl( ("a:lnL", cell_x, cell_y, cell_x, cell_y + cell_h), ): line_xml = _border_line( - tcPr, tag, x1, y1, x2, y2, palette, + tcPr, table_style, r, c, len(rows), len(col_widths), tag, + x1, y1, x2, y2, palette, id_prefix=f"{id_prefix}stk", id_seq=marker_seq, defs=defs, ) if line_xml: @@ -237,11 +449,10 @@ def _column_widths_px(tbl: ET.Element) -> list[float]: for col in grid.findall("a:gridCol", NS): w_emu = col.attrib.get("w") if w_emu is None: - continue - try: - widths.append(emu_to_px(int(w_emu))) - except ValueError: widths.append(0.0) + continue + value = _safe_emu_integer(w_emu) + widths.append(emu_to_px(value) if value is not None else 0.0) return widths @@ -249,10 +460,25 @@ def _row_height_px(row: ET.Element) -> float: h_emu = row.attrib.get("h") if h_emu is None: return 0.0 - try: - return emu_to_px(int(h_emu)) - except ValueError: - return 0.0 + value = _safe_emu_integer(h_emu) + return emu_to_px(value) if value is not None else 0.0 + + +def _safe_emu_integer(raw_value: str) -> int | None: + """Return a bounded ASCII DrawingML coordinate without float overflow.""" + token = raw_value.strip(" \t\r\n") + digits = token[1:] if token.startswith("-") else token + if ( + not digits + or not digits.isascii() + or not digits.isdigit() + or len(digits) > 10 + ): + return None + value = int(token) + if not _POWERPOINT_COORD_MIN <= value <= _POWERPOINT_COORD_MAX: + return None + return value def _cumulative_starts(origin: float, sizes: list[float]) -> list[float]: @@ -278,6 +504,14 @@ class _CellSlot: is_dropped: bool = False # True for h/vMerge slaves: don't paint anything +@dataclass(frozen=True) +class _CanonicalMergeRegion: + row: int + col: int + row_span: int + col_span: int + + def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellSlot | None]]: """Map each (row, col) to the that owns it. @@ -288,11 +522,21 @@ def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellS grid: list[list[_CellSlot | None]] = [[None] * col_count for _ in rows] for r, row in enumerate(rows): + row_cells = row.findall("a:tc", NS) + # PowerPoint writes one physical for every grid column, including + # explicit hMerge/vMerge continuation cells. In that canonical form the + # physical index is the grid column; advancing by gridSpan would consume + # the continuation cell twice and shift every following cell left. + explicit_grid = len(row_cells) >= col_count c = 0 - for tc in row.findall("a:tc", NS): - # Skip already-occupied slots from a row above's rowSpan anchor. - while c < col_count and grid[r][c] is not None: - c += 1 + for physical_col, tc in enumerate(row_cells): + if explicit_grid: + c = physical_col + else: + # Retain best-effort support for compact/non-canonical rows that + # omit explicit merge continuation cells. + while c < col_count and grid[r][c] is not None: + c += 1 if c >= col_count: break @@ -302,10 +546,8 @@ def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellS v_merge = ooxml_bool(tc.attrib.get("vMerge")) if h_merge or v_merge: - # This cell is a merge slave; leave the slot tied to the anchor - # if one was already placed there, else mark as dropped placeholder. - if grid[r][c] is None: - grid[r][c] = _CellSlot(element=tc, is_dropped=True) + # Merge slaves are physical cells but have no independent paint. + grid[r][c] = _CellSlot(element=tc, is_dropped=True) c += 1 continue @@ -326,7 +568,7 @@ def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellS grid[rr][cc] = _CellSlot( element=tc, is_dropped=True, ) - c += slot.col_span + c += 1 if explicit_grid else slot.col_span return grid @@ -340,17 +582,130 @@ def _safe_int(value: str | None, default: int) -> int: return default -def _table_has_merges(rows: list[ET.Element]) -> bool: - """Return True when the table uses merged cells.""" - for row in rows: - for tc in row.findall("a:tc", NS): - if ooxml_bool(tc.attrib.get("hMerge")) or ooxml_bool(tc.attrib.get("vMerge")): - return True - if _safe_int(tc.attrib.get("gridSpan"), 1) > 1: - return True - if _safe_int(tc.attrib.get("rowSpan"), 1) > 1: - return True - return False +def _strict_merge_bool(value: str | None) -> bool: + if value is None: + return False + normalized = value.strip().lower() + if normalized in {"1", "on", "true"}: + return True + if normalized in {"0", "false", "off"}: + return False + raise ValueError("invalid OOXML boolean") + + +def _strict_merge_span(value: str | None) -> int: + if value is None: + return 1 + normalized = value.strip() + if not normalized.isdigit(): + raise ValueError("invalid OOXML span") + span = int(normalized) + if span <= 0: + raise ValueError("invalid OOXML span") + return span + + +def _canonical_merge_slave_is_empty(tc: ET.Element) -> bool: + tc_pr = tc.find("a:tcPr", NS) + if tc_pr is None or tc_pr.attrib or list(tc_pr): + return False + tx_body = tc.find("a:txBody", NS) + if tx_body is None: + return False + paragraph_count = 0 + for child in tx_body: + name = child.tag.rsplit("}", 1)[-1] + if name in {"bodyPr", "lstStyle"}: + if child.attrib or list(child): + return False + continue + if name == "p" and not child.attrib and not list(child): + paragraph_count += 1 + continue + return False + return paragraph_count > 0 and not (tx_body.text or "").strip() + + +def _canonical_native_merge_status( + rows: list[ET.Element], + col_count: int, +) -> str | None: + """Accept only explicit rectangular merge topology safe for regeneration.""" + physical_rows = [row.findall("a:tc", NS) for row in rows] + merge_attrs = {"gridSpan", "rowSpan", "hMerge", "vMerge"} + if not any( + any(name in tc.attrib for name in merge_attrs) + for row_cells in physical_rows + for tc in row_cells + ): + return None + if col_count <= 0 or any( + len(row_cells) != col_count for row_cells in physical_rows + ): + return "unsupported-merge-topology" + + states: dict[tuple[int, int], tuple[ET.Element, int, int, bool, bool]] = {} + try: + for row_idx, row_cells in enumerate(physical_rows): + for col_idx, tc in enumerate(row_cells): + states[(row_idx, col_idx)] = ( + tc, + _strict_merge_span(tc.get("rowSpan")), + _strict_merge_span(tc.get("gridSpan")), + _strict_merge_bool(tc.get("hMerge")), + _strict_merge_bool(tc.get("vMerge")), + ) + except ValueError: + return "unsupported-merge-topology" + + anchors: list[_CanonicalMergeRegion] = [] + for ( + (row_idx, col_idx), + (_tc, row_span, col_span, h_merge, v_merge), + ) in states.items(): + if not h_merge and not v_merge and (row_span > 1 or col_span > 1): + anchors.append( + _CanonicalMergeRegion(row_idx, col_idx, row_span, col_span) + ) + + owners: dict[tuple[int, int], _CanonicalMergeRegion] = {} + for region in anchors: + if ( + region.row + region.row_span > len(rows) + or region.col + region.col_span > col_count + ): + return "unsupported-merge-topology" + for covered_row in range(region.row, region.row + region.row_span): + for covered_col in range(region.col, region.col + region.col_span): + position = (covered_row, covered_col) + if position in owners: + return "unsupported-merge-topology" + owners[position] = region + + for ( + (row_idx, col_idx), + (tc, row_span, col_span, h_merge, v_merge), + ) in states.items(): + region = owners.get((row_idx, col_idx)) + if region is None: + if h_merge or v_merge or row_span != 1 or col_span != 1: + return "unsupported-merge-topology" + continue + + is_anchor = row_idx == region.row and col_idx == region.col + expected_row_span = region.row_span if row_idx == region.row else 1 + expected_col_span = region.col_span if col_idx == region.col else 1 + if ( + row_span != expected_row_span + or col_span != expected_col_span + or h_merge != (col_idx > region.col) + or v_merge != (row_idx > region.row) + ): + return "unsupported-merge-topology" + if not is_anchor and not _canonical_merge_slave_is_empty(tc): + return "unsupported-merge-topology" + + return None def _table_has_unsupported_style(tbl: ET.Element) -> bool: @@ -374,12 +729,149 @@ def _table_has_unsupported_style(tbl: ET.Element) -> bool: ) +_DIRECT_BORDER_TAGS = { + "lnL": "left", + "lnR": "right", + "lnT": "top", + "lnB": "bottom", +} +_DIRECT_BORDER_WIDTH_MAX = 20116800 +_OPAQUE_COLOR_MODIFIERS = { + "tint", + "shade", + "lumMod", + "lumOff", + "satMod", + "satOff", +} + + +def _validate_opaque_border_color(color_elem: ET.Element | None) -> None: + if color_elem is None: + raise ValueError("missing border color") + name = color_elem.tag.rsplit("}", 1)[-1] + if name not in {"srgbClr", "schemeClr"} or set(color_elem.attrib) != {"val"}: + raise ValueError("unsupported border color") + for modifier in color_elem: + modifier_name = modifier.tag.rsplit("}", 1)[-1] + if ( + modifier_name not in _OPAQUE_COLOR_MODIFIERS + or set(modifier.attrib) != {"val"} + or list(modifier) + ): + raise ValueError("unsupported border color modifier") + value = modifier.get("val", "") + if not value.isdigit() or not 0 <= int(value) <= 100000: + raise ValueError("invalid border color modifier") + + +def _direct_border_payload( + ln: ET.Element, + palette: ColorPalette | None, +) -> dict[str, Any]: + if set(ln.attrib) - {"w", "cap", "cmpd", "algn"}: + raise ValueError("unsupported border line attribute") + if ln.get("cap") not in {None, "flat"}: + raise ValueError("unsupported border cap") + if ln.get("cmpd") not in {None, "sng"}: + raise ValueError("unsupported border compound style") + if ln.get("algn") not in {None, "ctr"}: + raise ValueError("unsupported border alignment") + width_emu: int | None = None + if "w" in ln.attrib: + raw_width = ln.get("w", "") + if not raw_width.isdigit(): + raise ValueError("invalid border width") + width_emu = int(raw_width) + if not 0 < width_emu <= _DIRECT_BORDER_WIDTH_MAX: + raise ValueError("invalid border width") + + children = list(ln) + child_names = [child.tag.rsplit("}", 1)[-1] for child in children] + if child_names == ["noFill"]: + no_fill = children[0] + if no_fill.attrib or list(no_fill): + raise ValueError("invalid noFill border") + return {"style": "none"} + + decoration_names = {"round", "headEnd", "tailEnd"} + if child_names.count("solidFill") != 1 or any( + name not in {"solidFill", "prstDash", *decoration_names} + for name in child_names + ): + raise ValueError("unsupported border paint") + if ( + child_names.count("prstDash") > 1 + or any(child_names.count(name) > 1 for name in decoration_names) + or width_emu is None + ): + raise ValueError("invalid solid border") + dash = next( + (child for child in children if child.tag.rsplit("}", 1)[-1] == "prstDash"), + None, + ) + if dash is not None and ( + dash.attrib != {"val": "solid"} or list(dash) + ): + raise ValueError("unsupported border dash") + line_join = next( + (child for child in children if child.tag.rsplit("}", 1)[-1] == "round"), + None, + ) + if line_join is not None and (line_join.attrib or list(line_join)): + raise ValueError("unsupported border line join") + for endpoint_name in ("headEnd", "tailEnd"): + endpoint = next( + ( + child for child in children + if child.tag.rsplit("}", 1)[-1] == endpoint_name + ), + None, + ) + if endpoint is None: + continue + if ( + set(endpoint.attrib) - {"type", "w", "len"} + or endpoint.get("type") not in {None, "none"} + or endpoint.get("w") not in {None, "med"} + or endpoint.get("len") not in {None, "med"} + or list(endpoint) + ): + raise ValueError("unsupported border endpoint") + + solid_fill = next( + child for child in children + if child.tag.rsplit("}", 1)[-1] == "solidFill" + ) + if solid_fill.attrib or len(list(solid_fill)) != 1: + raise ValueError("invalid solid border fill") + color_elem = find_color_elem(solid_fill) + _validate_opaque_border_color(color_elem) + try: + color, alpha = resolve_color(color_elem, palette) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("invalid solid border color") from exc + if color is None or alpha != 1.0: + raise ValueError("border color must resolve to opaque RGB") + + width = _round_payload_number(emu_to_px(str(width_emu))) + if width <= 0: + raise ValueError("border width is too small") + return { + "style": "solid", + "color": color, + "width": width, + } + + def _table_has_unsupported_direct_formatting( tbl: ET.Element, palette: ColorPalette | None, ) -> bool: """Reject direct cell features the compact native schema cannot retain.""" for tc in tbl.findall(".//a:tc", NS): + if _table_cell_has_unsupported_topology(tc): + return True tc_pr = tc.find("a:tcPr", NS) if tc_pr is not None: allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"} @@ -388,10 +880,29 @@ def _table_has_unsupported_direct_formatting( if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}: return True if any( - child.tag.rsplit("}", 1)[-1] != "solidFill" + child.tag.rsplit("}", 1)[-1] + not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS} for child in tc_pr ): return True + fills = [ + child for child in tc_pr + if child.tag.rsplit("}", 1)[-1] in {"solidFill", "noFill"} + ] + if len(fills) > 1: + return True + no_fill = tc_pr.find("a:noFill", NS) + if no_fill is not None and (no_fill.attrib or list(no_fill)): + return True + for border_tag in _DIRECT_BORDER_TAGS: + borders = tc_pr.findall(f"a:{border_tag}", NS) + if len(borders) > 1: + return True + if borders: + try: + _direct_border_payload(borders[0], palette) + except ValueError: + return True solid_fill = tc_pr.find("a:solidFill", NS) if solid_fill is not None: if solid_fill.find(".//a:alpha", NS) is not None: @@ -401,16 +912,119 @@ def _table_has_unsupported_direct_formatting( tx_body = tc.find("a:txBody", NS) if _text_body_has_unsupported_formatting(tx_body): return True - if ( - tx_body is not None - and tx_body.find(".//a:solidFill", NS) is not None - and _cell_text_color(tx_body, palette) is None - ): - return True return False +def _table_cell_has_unsupported_topology(tc: ET.Element) -> bool: + """Accept only the closed optional txBody -> optional tcPr cell sequence.""" + if any( + name not in {"gridSpan", "rowSpan", "hMerge", "vMerge"} + for name in tc.attrib + ): + return True + tx_body_tag = f"{{{NS['a']}}}txBody" + tc_pr_tag = f"{{{NS['a']}}}tcPr" + child_tags = [child.tag for child in tc] + child_index = 0 + if child_tags[:1] == [tx_body_tag]: + child_index += 1 + if child_tags[child_index:child_index + 1] == [tc_pr_tag]: + child_index += 1 + return child_index != len(child_tags) + + def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool: + if tx_body is None: + return False + if tx_body.attrib: + return True + + body_pr_tag = f"{{{NS['a']}}}bodyPr" + list_style_tag = f"{{{NS['a']}}}lstStyle" + paragraph_tag = f"{{{NS['a']}}}p" + body_tags = [child.tag for child in tx_body] + body_index = 0 + if not body_tags or body_tags[0] != body_pr_tag: + return True + body_index += 1 + if body_index < len(body_tags) and body_tags[body_index] == list_style_tag: + body_index += 1 + if body_index == len(body_tags) or any( + tag != paragraph_tag for tag in body_tags[body_index:] + ): + return True + + relationship_prefix = f"{{{NS['r']}}}" + forbidden_run_children = { + f"{{{NS['a']}}}extLst", + f"{{{NS['a']}}}hlinkClick", + f"{{{NS['a']}}}hlinkMouseOver", + } + if any( + node.tag in forbidden_run_children + or any(name.startswith(relationship_prefix) for name in node.attrib) + for node in tx_body.iter() + ): + return True + + body_pr = tx_body.find("a:bodyPr", NS) + if body_pr is None or body_pr.attrib or list(body_pr): + return True + list_style = tx_body.find("a:lstStyle", NS) + if list_style is not None and (list_style.attrib or list(list_style)): + return True + for paragraph in tx_body.findall("a:p", NS): + if paragraph.attrib: + return True + p_pr_tag = f"{{{NS['a']}}}pPr" + run_tag = f"{{{NS['a']}}}r" + end_r_pr_tag = f"{{{NS['a']}}}endParaRPr" + direct_tags = [child.tag for child in paragraph] + paragraph_index = 0 + if direct_tags[:1] == [p_pr_tag]: + paragraph_index += 1 + while ( + paragraph_index < len(direct_tags) + and direct_tags[paragraph_index] == run_tag + ): + paragraph_index += 1 + if ( + paragraph_index < len(direct_tags) + and direct_tags[paragraph_index] == end_r_pr_tag + ): + paragraph_index += 1 + if paragraph_index != len(direct_tags): + return True + + p_pr = paragraph.find("a:pPr", NS) + if p_pr is not None: + p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr] + if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1: + return True + if any(tag.startswith("bu") and tag != "buNone" for tag in p_pr_tags): + return True + + for run in paragraph.findall("a:r", NS): + if run.attrib: + return True + r_pr_tag = f"{{{NS['a']}}}rPr" + text_tag = f"{{{NS['a']}}}t" + run_tags = [child.tag for child in run] + if run_tags not in ([text_tag], [r_pr_tag, text_tag]): + return True + text_node = run.find("a:t", NS) + if text_node is None or list(text_node): + return True + allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"} + if any(name not in allowed_text_attrs for name in text_node.attrib): + return True + return False + + +def _legacy_text_body_has_unsupported_formatting( + tx_body: ET.Element | None, +) -> bool: + """Return the pre-P2-T4 gate so active plain payloads stay unchanged.""" if tx_body is None: return False body_pr = tx_body.find("a:bodyPr", NS) @@ -426,18 +1040,12 @@ def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool: ): return True - paragraphs = tx_body.findall("a:p", NS) - if len(paragraphs) > 1: - return True - - alignments: set[str | None] = set() run_signatures: set[tuple[str | None, str | None, bytes | None]] = set() - for paragraph in paragraphs: + for paragraph in tx_body.findall("a:p", NS): p_pr = paragraph.find("a:pPr", NS) alignment = p_pr.get("algn") if p_pr is not None else None if alignment not in {None, "l", "ctr", "r"}: return True - alignments.add(alignment) if p_pr is not None: if any(name != "algn" for name in p_pr.attrib): return True @@ -448,23 +1056,29 @@ def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool: return True default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None - if _run_props_have_unsupported_formatting(default_r_pr): + if _legacy_run_props_have_unsupported_formatting(default_r_pr): return True for run in paragraph.findall("a:r", NS): r_pr = run.find("a:rPr", NS) - if _run_props_have_unsupported_formatting(r_pr): + if _legacy_run_props_have_unsupported_formatting(r_pr): return True - run_signatures.add(_effective_run_signature(r_pr, default_r_pr)) + run_signatures.add( + _legacy_effective_run_signature(r_pr, default_r_pr) + ) end_r_pr = paragraph.find("a:endParaRPr", NS) - if _run_props_have_unsupported_formatting(end_r_pr): + if _legacy_run_props_have_unsupported_formatting(end_r_pr): return True if not paragraph.findall("a:r", NS) and end_r_pr is not None: - run_signatures.add(_effective_run_signature(end_r_pr, default_r_pr)) + run_signatures.add( + _legacy_effective_run_signature(end_r_pr, default_r_pr) + ) - return len(alignments) > 1 or len(run_signatures) > 1 + return len(run_signatures) > 1 -def _run_props_have_unsupported_formatting(r_pr: ET.Element | None) -> bool: +def _legacy_run_props_have_unsupported_formatting( + r_pr: ET.Element | None, +) -> bool: if r_pr is None: return False if ooxml_bool(r_pr.get("i")): @@ -494,7 +1108,7 @@ def _run_props_have_unsupported_formatting(r_pr: ET.Element | None) -> bool: ) -def _effective_run_signature( +def _legacy_effective_run_signature( r_pr: ET.Element | None, default_r_pr: ET.Element | None, ) -> tuple[str | None, str | None, bytes | None]: @@ -525,6 +1139,7 @@ def _native_table_payload( row_heights: list[float], cells: list[list[_CellSlot | None]], palette: ColorPalette | None, + theme_fonts: dict[str, str], ) -> dict[str, Any]: """Build the SVG data-pptx-native payload for an unmerged table.""" tbl_pr = tbl.find("a:tblPr", NS) @@ -557,35 +1172,65 @@ def _native_table_payload( if slot is None or slot.is_dropped: row_payload.append("") continue - row_payload.append(_native_cell_payload(slot.element, palette)) + cell_payload = _native_cell_payload( + slot.element, + palette, + theme_fonts, + ) + if slot.row_span > 1: + cell_payload["row_span"] = slot.row_span + if slot.col_span > 1: + cell_payload["col_span"] = slot.col_span + row_payload.append(cell_payload) rows_payload.append(row_payload) payload["rows"] = rows_payload return payload -def _native_cell_payload(tc: ET.Element, palette: ColorPalette | None) -> dict[str, Any]: +def _native_cell_payload( + tc: ET.Element, + palette: ColorPalette | None, + theme_fonts: dict[str, str], +) -> dict[str, Any]: tx_body = tc.find("a:txBody", NS) tc_pr = tc.find("a:tcPr", NS) - cell: dict[str, Any] = {"text": _cell_plain_text(tx_body)} + paragraph_payloads = _cell_paragraph_payloads(tx_body) + rich_paragraphs = _cell_rich_paragraph_payloads( + tx_body, + palette, + theme_fonts, + ) + if rich_paragraphs is not None: + cell: dict[str, Any] = {"paragraphs": rich_paragraphs} + elif len(paragraph_payloads) > 1: + cell: dict[str, Any] = {"paragraphs": paragraph_payloads} + else: + cell = {"text": _cell_plain_text(tx_body)} fill = _cell_fill_hex(tc_pr, palette) if fill: cell["fill"] = fill - color = _cell_text_color(tx_body, palette) - if color: - cell["color"] = color - font_size = _cell_font_size_px(tx_body) - if font_size: - cell["font_size"] = font_size - align = _cell_align(tx_body) - if align: - cell["align"] = align + if rich_paragraphs is None: + color = _cell_text_color(tx_body, palette) + if color: + cell["color"] = color + font_size = _cell_font_size_px(tx_body) + if font_size: + cell["font_size"] = font_size + if len(paragraph_payloads) <= 1: + align = _cell_align(tx_body) + if align: + cell["align"] = align valign = _cell_valign(tc_pr) if valign: cell["valign"] = valign - bold = _cell_bold(tx_body) - if bold is not None: - cell["bold"] = bold + if rich_paragraphs is None: + bold = _cell_bold(tx_body) + if bold is not None: + cell["bold"] = bold + borders = _cell_borders_payload(tc_pr, palette) + if borders: + cell["borders"] = borders _copy_cell_margins(tc_pr, cell) return cell @@ -601,6 +1246,164 @@ def _cell_plain_text(tx_body: ET.Element | None) -> str: return "\n".join(paragraphs) +def _cell_paragraph_payloads( + tx_body: ET.Element | None, +) -> list[str | dict[str, str]]: + if tx_body is None: + return [] + payloads: list[str | dict[str, str]] = [] + for paragraph in tx_body.findall("a:p", NS): + text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)) + p_pr = paragraph.find("a:pPr", NS) + align = p_pr.get("algn") if p_pr is not None else None + if align in {"l", "ctr", "r"}: + payloads.append({"text": text, "align": align}) + else: + payloads.append(text) + return payloads + + +def _effective_run_attr( + r_pr: ET.Element | None, + default_r_pr: ET.Element | None, + name: str, +) -> str | None: + if r_pr is not None and r_pr.get(name) is not None: + return r_pr.get(name) + return default_r_pr.get(name) if default_r_pr is not None else None + + +def _effective_run_child( + r_pr: ET.Element | None, + default_r_pr: ET.Element | None, + name: str, +) -> ET.Element | None: + child = r_pr.find(f"a:{name}", NS) if r_pr is not None else None + if child is not None: + return child + return default_r_pr.find(f"a:{name}", NS) if default_r_pr is not None else None + + +def _native_run_font_family( + r_pr: ET.Element | None, + default_r_pr: ET.Element | None, + theme_fonts: dict[str, str], +) -> str | None: + faces: list[str] = [] + for tag in ("latin", "ea"): + node = _effective_run_child(r_pr, default_r_pr, tag) + raw_face = node.get("typeface") if node is not None else None + face = _resolve_theme_typeface(raw_face, theme_fonts) + if face and face not in faces: + faces.append(face) + if len(faces) != 1: + return None + face = faces[0].strip() + return face if face and "," not in face else None + + +def _native_run_payload( + run: ET.Element, + default_r_pr: ET.Element | None, + palette: ColorPalette | None, + theme_fonts: dict[str, str], +) -> dict[str, Any]: + r_pr = run.find("a:rPr", NS) + text = run.findtext("a:t", default="", namespaces=NS) + payload: dict[str, Any] = {"text": text} + + for source, target in (("b", "bold"), ("i", "italic")): + raw = _effective_run_attr(r_pr, default_r_pr, source) + if raw is not None: + payload[target] = ooxml_bool(raw) + + underline = _effective_run_attr(r_pr, default_r_pr, "u") + if underline is not None: + payload["underline"] = underline != "none" + strike = _effective_run_attr(r_pr, default_r_pr, "strike") + if strike is not None: + payload["strike"] = strike != "noStrike" + + font_size = _canonical_source_font_size_px( + _effective_run_attr(r_pr, default_r_pr, "sz") + ) + if font_size is not None: + payload["font_size"] = font_size + + solid_fill = _effective_run_child(r_pr, default_r_pr, "solidFill") + if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette): + try: + color, _alpha = resolve_color(find_color_elem(solid_fill), palette) + except (AttributeError, OverflowError, TypeError, ValueError): + color = None + if color: + payload["color"] = color + + font_family = _native_run_font_family( + r_pr, + default_r_pr, + theme_fonts, + ) + if font_family: + payload["font_family"] = font_family + + for source, target in (("lang", "lang"), ("altLang", "alt_lang")): + language = _effective_run_attr(r_pr, default_r_pr, source) + if language and language.strip(): + payload[target] = language.strip() + return payload + + +def _cell_rich_paragraph_payloads( + tx_body: ET.Element | None, + palette: ColorPalette | None, + theme_fonts: dict[str, str], +) -> list[dict[str, Any]] | None: + """Materialize runs only when the legacy plain contract was insufficient.""" + if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body): + return None + + paragraphs: list[tuple[str | None, list[dict[str, Any]]]] = [] + style_signatures: set[tuple[tuple[str, Any], ...]] = set() + needs_runs = False + run_only_fields = { + "italic", "underline", "strike", "font_family", "lang", "alt_lang", + } + for paragraph in tx_body.findall("a:p", NS): + p_pr = paragraph.find("a:pPr", NS) + align = p_pr.get("algn") if p_pr is not None else None + if align not in {"l", "ctr", "r"}: + align = None + default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None + runs = [ + _native_run_payload(run, default_r_pr, palette, theme_fonts) + for run in paragraph.findall("a:r", NS) + ] + paragraphs.append((align, runs)) + for run in runs: + style = tuple(sorted((key, value) for key, value in run.items() if key != "text")) + style_signatures.add(style) + if run_only_fields.intersection(run): + needs_runs = True + + if len(style_signatures) > 1: + needs_runs = True + if not needs_runs: + return None + + payloads: list[dict[str, Any]] = [] + for align, runs in paragraphs: + paragraph_payload: dict[str, Any] + if runs: + paragraph_payload = {"runs": runs} + else: + paragraph_payload = {"text": ""} + if align is not None: + paragraph_payload["align"] = align + payloads.append(paragraph_payload) + return payloads + + def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None: fill = resolve_fill(tc_pr, palette) color = fill.attrs.get("fill") if fill.attrs else None @@ -612,22 +1415,42 @@ def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> st def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None: for r_pr in _text_run_props_in_priority(tx_body): solid_fill = r_pr.find("a:solidFill", NS) - if solid_fill is not None: - color, _alpha = resolve_color(find_color_elem(solid_fill), palette) - return color + if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette): + try: + color, _alpha = resolve_color(find_color_elem(solid_fill), palette) + except (AttributeError, OverflowError, TypeError, ValueError): + continue + if color: + return color return None def _cell_font_size_px(tx_body: ET.Element | None) -> int | float | None: for r_pr in _text_run_props_in_priority(tx_body): - if not r_pr.get("sz"): - continue - size = hundredths_pt_to_px(r_pr.get("sz")) - if size > 0: - return _round_payload_number(size) + size = _canonical_source_font_size_px(r_pr.get("sz")) + if size is not None: + return size return None +def _canonical_source_font_size_px(raw_size: str | None) -> int | float | None: + """Return a writer-stable font size for one bounded DrawingML token.""" + if ( + raw_size is None + or not raw_size.isascii() + or not raw_size.isdigit() + or len(raw_size) > 6 + ): + return None + size_hpt = int(raw_size) + if not 100 <= size_hpt <= 400000: + return None + # The writer emits sizes at 0.1pt precision; canonicalize on first import + # so source and native reimport payloads stay stable. + canonical_hpt = round(size_hpt / 10) * 10 + return _round_payload_number(hundredths_pt_to_px(canonical_hpt)) + + def _cell_align(tx_body: ET.Element | None) -> str | None: if tx_body is None: return None @@ -654,6 +1477,20 @@ def _cell_bold(tx_body: ET.Element | None) -> bool | None: return None +def _cell_borders_payload( + tc_pr: ET.Element | None, + palette: ColorPalette | None, +) -> dict[str, dict[str, Any]]: + if tc_pr is None: + return {} + borders: dict[str, dict[str, Any]] = {} + for border_tag, side in _DIRECT_BORDER_TAGS.items(): + ln = tc_pr.find(f"a:{border_tag}", NS) + if ln is not None: + borders[side] = _direct_border_payload(ln, palette) + return borders + + def _text_run_props_in_priority(tx_body: ET.Element | None) -> list[ET.Element]: if tx_body is None: return [] @@ -676,7 +1513,10 @@ def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None: ): if source not in tc_pr.attrib: continue - cell[target] = _round_payload_number(emu_to_px(tc_pr.attrib[source])) + value = _safe_emu_integer(tc_pr.attrib[source]) + if value is None or value < 0: + continue + cell[target] = _round_payload_number(emu_to_px(value)) # --------------------------------------------------------------------------- @@ -690,16 +1530,49 @@ def _convert_cell_text( palette: ColorPalette | None, theme_fonts: dict[str, str] | None, *, + fallback_run_props: tuple[ET.Element, ...], slide_number: int | None, id_prefix: str, id_seq: list[int] | None, ): """Render cell text. PowerPoint's can override txBody insets via its own marL/marR/marT/marB attrs; convert_txbody reads from , - so we materialise a synthetic bodyPr by mutating the txBody in place when - tcPr has its own insets. (We undo the mutation afterwards to keep the - slide tree pristine for any subsequent passes.)""" - body_pr = tx_body.find("a:bodyPr", NS) + so we materialise a synthetic bodyPr when tcPr has its own insets. Invalid + malformed source font-size and run-color values are removed from a private + render copy; they have already been omitted from the native payload and + must not crash fallback SVG generation.""" + render_tx_body = tx_body + run_props = [ + node + for node in tx_body.iter() + if node.tag in { + f"{{{NS['a']}}}defRPr", + f"{{{NS['a']}}}endParaRPr", + f"{{{NS['a']}}}rPr", + } + ] + if any( + _run_props_need_render_normalization(node, palette) + for node in run_props + ): + render_tx_body = copy.deepcopy(tx_body) + for node in render_tx_body.iter(): + if node.tag not in { + f"{{{NS['a']}}}defRPr", + f"{{{NS['a']}}}endParaRPr", + f"{{{NS['a']}}}rPr", + }: + continue + if ( + node.get("sz") is not None + and _canonical_source_font_size_px(node.get("sz")) is None + ): + node.attrib.pop("sz", None) + solid_fill = node.find("a:solidFill", NS) + if solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette): + node.remove(solid_fill) + + body_pr = render_tx_body.find("a:bodyPr", NS) overrides = _tcPr_inset_overrides(tcPr) saved: dict[str, str | None] = {} if overrides and body_pr is not None: @@ -707,12 +1580,23 @@ def _convert_cell_text( saved[key] = body_pr.attrib.get(key) body_pr.set(key, val) try: - return convert_txbody( - tx_body, cell_xfrm, palette, theme_fonts=theme_fonts, - slide_number=slide_number, - id_prefix=id_prefix, - id_seq=id_seq, - ) + try: + return convert_txbody( + render_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts, + fallback_run_props=fallback_run_props, + slide_number=slide_number, + id_prefix=id_prefix, + id_seq=id_seq, + ) + except (AttributeError, OverflowError, TypeError, ValueError): + plain_tx_body = _plain_table_text_body(render_tx_body, overrides) + return convert_txbody( + plain_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts, + fallback_run_props=(), + slide_number=slide_number, + id_prefix=id_prefix, + id_seq=id_seq, + ) finally: if overrides and body_pr is not None: for key, prior in saved.items(): @@ -722,19 +1606,109 @@ def _convert_cell_text( body_pr.set(key, prior) +def _run_props_need_render_normalization( + run_props: ET.Element, + palette: ColorPalette | None, +) -> bool: + size = run_props.get("sz") + if size is not None and _canonical_source_font_size_px(size) is None: + return True + solid_fill = run_props.find("a:solidFill", NS) + return solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette) + + +def _solid_fill_is_unsafe( + solid_fill: ET.Element, + palette: ColorPalette | None, +) -> bool: + color = find_color_elem(solid_fill) + if color is not None and not _color_numeric_tokens_are_finite(color): + return True + try: + resolve_color(color, palette) + except (AttributeError, OverflowError, TypeError, ValueError): + return True + return False + + +def _color_numeric_tokens_are_finite(color: ET.Element) -> bool: + """Reject non-finite numeric tokens before the resolver can clamp them.""" + base_numeric_attrs = { + "hslClr": ("hue", "sat", "lum"), + "scrgbClr": ("r", "g", "b"), + } + modifier_tags = { + "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff", + "lumMod", "lumOff", "satMod", "satOff", "shade", "tint", + } + for node in color.iter(): + name = node.tag.rsplit("}", 1)[-1] + attrs = base_numeric_attrs.get(name, ()) + if name in modifier_tags: + attrs = ("val",) + for attr in attrs: + raw = node.get(attr) + if raw is None: + return False + try: + value = float(raw) + except (OverflowError, TypeError, ValueError): + return False + if not math.isfinite(value): + return False + return True + + +def _plain_table_text_body( + tx_body: ET.Element, + overrides: dict[str, str], +) -> ET.Element: + """Return a text-preserving style-free fallback after malformed styling.""" + plain = copy.deepcopy(tx_body) + body_pr = plain.find("a:bodyPr", NS) + if body_pr is None: + body_pr = ET.Element(f"{{{NS['a']}}}bodyPr") + plain.insert(0, body_pr) + body_pr.clear() + for key, value in overrides.items(): + body_pr.set(key, value) + list_style = plain.find("a:lstStyle", NS) + if list_style is not None: + list_style.clear() + for paragraph in plain.findall("a:p", NS): + p_pr = paragraph.find("a:pPr", NS) + if p_pr is not None: + align = p_pr.get("algn") + p_pr.clear() + if align in {"l", "ctr", "r"}: + p_pr.set("algn", align) + for path in (".//a:rPr", ".//a:defRPr", ".//a:endParaRPr"): + for run_props in plain.findall(path, NS): + run_props.clear() + return plain + + def _tcPr_inset_overrides(tcPr: ET.Element | None) -> dict[str, str]: if tcPr is None: return {} out: dict[str, str] = {} for src, dst in (("marL", "lIns"), ("marR", "rIns"), ("marT", "tIns"), ("marB", "bIns")): - if src in tcPr.attrib: - out[dst] = tcPr.attrib[src] + if src not in tcPr.attrib: + continue + value = _safe_emu_integer(tcPr.attrib[src]) + if value is not None and value >= 0: + out[dst] = str(value) return out def _border_line( tcPr: ET.Element | None, + table_style: _TableStyleContext, + row_index: int, + col_index: int, + row_count: int, + col_count: int, tag: str, x1: float, y1: float, x2: float, y2: float, palette: ColorPalette | None, @@ -745,11 +1719,69 @@ def _border_line( ) -> str: """Emit a single border for a given cell side, or empty string when that side is explicitly noFill / not specified.""" - if tcPr is None: - return "" - ln = tcPr.find(tag, NS) - if ln is None: + ln = tcPr.find(tag, NS) if tcPr is not None else None + if ln is not None: + return _line_element_to_svg( + ln, x1, y1, x2, y2, palette, + id_prefix=id_prefix, id_seq=id_seq, defs=defs, + ) + + # Draw inherited shared edges once, from the upper/left cell. This keeps + # a specific firstRow bottom border from being painted over by the next + # row's whole-table top border. A direct border above still wins because + # it is handled before this de-duplication gate. + if (tag == "a:lnT" and row_index > 0) or ( + tag == "a:lnL" and col_index > 0 + ): return "" + + for region_name, region in table_style.regions_for_row(row_index): + for border_name in _table_style_border_names( + region_name, row_index, col_index, row_count, col_count, tag, + ): + ln = region.find( + f"a:tcStyle/a:tcBdr/a:{border_name}/a:ln", NS, + ) + if ln is not None: + return _line_element_to_svg( + ln, x1, y1, x2, y2, palette, + id_prefix=id_prefix, id_seq=id_seq, defs=defs, + ) + return "" + + +def _table_style_border_names( + region_name: str, + row_index: int, + col_index: int, + row_count: int, + col_count: int, + tag: str, +) -> tuple[str, ...]: + side_names = { + "a:lnT": ("top", "insideH", row_index > 0), + "a:lnR": ("right", "insideV", col_index < col_count - 1), + "a:lnB": ("bottom", "insideH", row_index < row_count - 1), + "a:lnL": ("left", "insideV", col_index > 0), + } + side, inside, is_internal = side_names[tag] + if region_name == "wholeTbl" and is_internal: + return inside, side + return side, inside + + +def _line_element_to_svg( + ln: ET.Element, + x1: float, + y1: float, + x2: float, + y2: float, + palette: ColorPalette | None, + *, + id_prefix: str, + id_seq: list[int], + defs: list[str], +) -> str: # Skip explicit no-line. if ln.find("a:noFill", NS) is not None: return "" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py index f342deef..16530e16 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py @@ -107,6 +107,7 @@ def convert_txbody( default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), + fallback_run_props: tuple[ET.Element, ...] = (), id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextResult: @@ -119,6 +120,7 @@ def convert_txbody( tx_body, palette, theme_fonts or {}, default_fill=default_fill, default_font_size_px=default_font_size_px, fallback_lst_styles=fallback_lst_styles, + fallback_run_props=fallback_run_props, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) if not paragraphs or not _has_visible_text(paragraphs): @@ -193,6 +195,7 @@ def convert_vertical_txbody( default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), + fallback_run_props: tuple[ET.Element, ...] = (), id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextResult: @@ -210,6 +213,7 @@ def convert_vertical_txbody( tx_body, palette, theme_fonts or {}, default_fill=default_fill, default_font_size_px=default_font_size_px, fallback_lst_styles=fallback_lst_styles, + fallback_run_props=fallback_run_props, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) runs = [ @@ -344,6 +348,7 @@ def _parse_paragraphs( default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, fallback_lst_styles: tuple[ET.Element, ...] = (), + fallback_run_props: tuple[ET.Element, ...] = (), slide_number: int | None = None, id_prefix: str = "txt", id_seq: list[int] | None = None, @@ -361,6 +366,7 @@ def _parse_paragraphs( para = _parse_paragraph( p_elem, palette, theme_fonts, autonum_state, lst_styles=lst_styles, + fallback_run_props=fallback_run_props, default_fill=default_fill, default_font_size_px=default_font_size_px, slide_number=slide_number, @@ -378,6 +384,7 @@ def _parse_paragraph( autonum_state: dict[int, int], *, lst_styles: tuple[ET.Element, ...] = (), + fallback_run_props: tuple[ET.Element, ...] = (), default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, slide_number: int | None = None, @@ -422,6 +429,7 @@ def _parse_paragraph( text, rpr, end_rpr, palette, theme_fonts, def_rpr=def_rpr, list_def_rpr=list_def_rpr, + fallback_run_props=fallback_run_props, default_fill=default_fill, default_font_size_px=default_font_size_px, id_prefix=id_prefix, id_seq=id_seq, @@ -449,6 +457,7 @@ def _parse_paragraph( text, rpr, end_rpr, palette, theme_fonts, def_rpr=def_rpr, list_def_rpr=list_def_rpr, + fallback_run_props=fallback_run_props, default_fill=default_fill, default_font_size_px=default_font_size_px, id_prefix=id_prefix, id_seq=id_seq, @@ -467,13 +476,16 @@ def _build_run( *, def_rpr: ET.Element | None = None, list_def_rpr: ET.Element | None = None, + fallback_run_props: tuple[ET.Element, ...] = (), default_fill: str = DEFAULT_FILL_HEX, default_font_size_px: float = DEFAULT_FONT_SIZE_PX, id_prefix: str = "txt", id_seq: list[int] | None = None, ) -> TextRun: """Resolve a single run from its rPr and fallback run properties.""" - style_chain = (rpr, def_rpr, list_def_rpr, end_rpr) + style_chain = ( + rpr, def_rpr, list_def_rpr, end_rpr, + ) + fallback_run_props # font-size: rPr > pPr/defRPr > lstStyle/lvlNpPr/defRPr > endParaRPr > default sz = _attr_chain(style_chain, "sz") font_size_px = hundredths_pt_to_px(sz, default_font_size_px) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/preset_shape_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/preset_shape_svg.py new file mode 100644 index 00000000..9969d154 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/preset_shape_svg.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +PPT Master - Preset Shape SVG Fragment Tool + +List DrawingML presets or print one canonical native-preset SVG fragment to +stdout for manual insertion into a hand-authored slide. + +Usage: + python3 scripts/preset_shape_svg.py list [--search QUERY] + python3 scripts/preset_shape_svg.py describe PRESET + python3 scripts/preset_shape_svg.py render PRESET --id ID --frame X Y W H + +Examples: + python3 scripts/preset_shape_svg.py list --search arrow + python3 scripts/preset_shape_svg.py describe rightArrow + python3 scripts/preset_shape_svg.py render rightArrow --id next-step \ + --frame 160 210 320 112 --fill "#2563EB" --stroke none + +Dependencies: + None (only uses standard library and local PPT Master modules) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Sequence + +from console_encoding import configure_utf8_stdio +from pptx_shapes import CONNECTOR_PRESET_TYPES, get_preset_registry +from pptx_to_svg.preset_authoring import render_preset_shape_fragment + + +configure_utf8_stdio() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Print one canonical DrawingML preset SVG fragment. " + "This tool never writes SVG files or page layouts." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser( + "list", + help="List preset names, optionally filtered by substring.", + ) + list_parser.add_argument( + "--search", + default="", + help="Case-insensitive preset-name substring.", + ) + + describe_parser = subparsers.add_parser( + "describe", + help="Print preset adjustment and path metadata as JSON.", + ) + describe_parser.add_argument("preset", help="DrawingML preset name.") + + render_parser = subparsers.add_parser( + "render", + help="Print one canonical authored-preset fragment to stdout.", + ) + render_parser.add_argument("preset", help="DrawingML preset name.") + render_parser.add_argument( + "--id", + required=True, + dest="element_id", + help="Stable unique SVG group id.", + ) + render_parser.add_argument( + "--frame", + required=True, + nargs=4, + type=float, + metavar=("X", "Y", "WIDTH", "HEIGHT"), + help="Absolute SVG frame in page coordinates.", + ) + render_parser.add_argument( + "--object-kind", + choices=("shape", "connector"), + default="shape", + help="Emit a normal shape or a native PowerPoint connector.", + ) + render_parser.add_argument( + "--name", + help="Optional PowerPoint object name.", + ) + render_parser.add_argument( + "--fill", + default="none", + help="Solid SVG fill from spec_lock, or none.", + ) + render_parser.add_argument( + "--fill-opacity", + type=float, + help="Fill opacity from 0 to 1.", + ) + render_parser.add_argument( + "--stroke", + default="none", + help="Solid SVG stroke from spec_lock, or none.", + ) + render_parser.add_argument( + "--stroke-width", + type=float, + help="Stroke width in SVG page units; defaults to 1 when stroked.", + ) + render_parser.add_argument( + "--stroke-opacity", + type=float, + help="Stroke opacity from 0 to 1.", + ) + render_parser.add_argument( + "--stroke-linecap", + choices=("butt", "round", "square"), + ) + render_parser.add_argument( + "--stroke-linejoin", + choices=("miter", "round", "bevel"), + ) + render_parser.add_argument( + "--adjust", + action="append", + default=[], + metavar="NAME=FORMULA", + help=( + "DrawingML adjustment formula; repeat for multiple guides, " + "for example --adjust 'adj1=val 50000'." + ), + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + registry = get_preset_registry() + + if args.command == "list": + query = args.search.casefold().strip() + names = [ + name for name in registry.names + if not query or query in name.casefold() + ] + if not names: + print(f"No preset names match {args.search!r}", file=sys.stderr) + return 1 + print("\n".join(names)) + return 0 + + if args.command == "describe": + if args.preset not in registry: + print(f"Unknown DrawingML preset: {args.preset!r}", file=sys.stderr) + return 1 + definition = registry.get(args.preset) + payload = { + "preset": definition.name, + "connector_preset": definition.name in CONNECTOR_PRESET_TYPES, + "adjustments": [ + {"name": guide.name, "formula": guide.formula} + for guide in definition.adjustments + ], + "path_count": len(definition.paths), + "connection_site_count": len(definition.connections), + "has_text_rectangle": definition.text_rectangle is not None, + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + try: + adjustments = _parse_adjustments(args.adjust) + style = _style_from_args(args) + fragment = render_preset_shape_fragment( + args.preset, + tuple(args.frame), + adjustments=adjustments, + object_kind=args.object_kind, + element_id=args.element_id, + name=args.name, + style=style, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print(fragment) + return 0 + + +def _parse_adjustments(values: Sequence[str]) -> dict[str, str]: + adjustments: dict[str, str] = {} + for value in values: + name, separator, formula = value.partition("=") + name = name.strip() + formula = formula.strip() + if not separator or not name or not formula: + raise ValueError( + f"Invalid adjustment {value!r}; expected NAME=FORMULA" + ) + if name in adjustments: + raise ValueError(f"Duplicate adjustment guide: {name!r}") + adjustments[name] = formula + return adjustments + + +def _style_from_args(args: argparse.Namespace) -> dict[str, str]: + style = { + "fill": args.fill, + "stroke": args.stroke, + } + if args.fill_opacity is not None: + style["fill-opacity"] = str(args.fill_opacity) + if args.stroke != "none": + style["stroke-width"] = str( + 1.0 if args.stroke_width is None else args.stroke_width + ) + elif args.stroke_width is not None: + raise ValueError("--stroke-width requires a non-none --stroke") + if args.stroke_opacity is not None: + style["stroke-opacity"] = str(args.stroke_opacity) + if args.stroke_linecap is not None: + style["stroke-linecap"] = args.stroke_linecap + if args.stroke_linejoin is not None: + style["stroke-linejoin"] = args.stroke_linejoin + return style + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/register_template.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/register_template.py index 0431f116..98c3caa6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/register_template.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/register_template.py @@ -1,15 +1,20 @@ #!/usr/bin/env python3 """Register a brand / layout / deck template into the global template index. -Three kinds, three physical directories, three index files (see +Three kinds, three workspace roots, three index files (see ``docs/zh/templates-architecture.md`` for the data model): -| --kind | Source dir | Index file | +| --kind | Workspace roots | Index file | |---------|-------------------------|-------------------------------| | brand | ``templates/brands/`` | ``brands_index.json`` | | layout | ``templates/layouts/`` | ``layouts_index.json`` | | deck | ``templates/decks/`` | ``decks_index.json`` | +Current workspaces keep ``design_spec.md`` and any SVG roster under +``/templates/``. Assets live in optional ``images/`` / ``icons/`` +directories. Explicitly generated review artifacts go to the optional, ignored +``exports/`` directory. Legacy flat roots remain readable. + Index entry schemas (the JSON file is the single source of truth — README files describe the kind and usage in prose but do **not** enumerate templates; discovery happens exclusively against the index file): @@ -166,6 +171,18 @@ def _summary_from_use_cases(use_cases: str | None) -> str | None: return f"{cleaned}." +def _template_content_dir(template_root: Path) -> Path: + """Resolve the canonical source directory, with legacy-flat compatibility.""" + nested = template_root / "templates" + if (nested / "design_spec.md").is_file(): + return nested + if (template_root / "design_spec.md").is_file(): + return template_root + raise SpecParseError( + f"missing templates/design_spec.md or legacy design_spec.md in {template_root}" + ) + + def _list_pages(template_dir: Path) -> list[str]: return sorted(p.stem for p in template_dir.glob("*.svg")) @@ -189,9 +206,9 @@ def _derive_page_types(pages: list[str]) -> list[str]: def _extract_entry(kind: str, template_id: str, template_dir: Path) -> dict: """Build the index entry + extras for a single template.""" + template_root = template_dir + template_dir = _template_content_dir(template_root) spec_path = template_dir / "design_spec.md" - if not spec_path.exists(): - raise SpecParseError(f"missing design_spec.md in {template_dir}") frontmatter, body = _read_spec(spec_path) fm = frontmatter or {} @@ -243,6 +260,12 @@ def _extract_entry(kind: str, template_id: str, template_dir: Path) -> dict: extras = OrderedDict( pages=pages, primary_color=str(primary_color), + page_prefix="templates/" if template_dir != template_root else "", + preview=( + f"exports/{template_id}_template_preview.pptx" + if (template_root / "exports" / f"{template_id}_template_preview.pptx").is_file() + else "" + ), ) return {"entry": entry, "extras": extras} @@ -275,7 +298,11 @@ def _enumerate_ids(kind: str) -> list[str]: return [] return sorted( p.name for p in base.iterdir() - if p.is_dir() and (p / "design_spec.md").exists() + if p.is_dir() + and ( + (p / "templates" / "design_spec.md").is_file() + or (p / "design_spec.md").is_file() + ) ) @@ -300,13 +327,20 @@ def _print_completion_card(kind: str, template_id: str, entry: dict, extras: dic print() if kind != "brand": pages = extras.get("pages") or [] + page_prefix = extras.get("page_prefix") or "" + preview = extras.get("preview") or "" + if preview: + print(f"**Review PPTX**: `{preview}`") + print() if pages: print("### Files Included") print() print("| File | Status |") print("|------|--------|") for page in pages: - print(f"| `{page}.svg` | Done |") + print(f"| `{page_prefix}{page}.svg` | Done |") + if preview: + print(f"| `{preview}` | Verified |") print() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py index 50b174fd..b1931aa2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py @@ -17,8 +17,10 @@ Dependency: API stability note: Detecting slide-internal jumps (``ppaction://hlinksldjump``) reads ``run._r`` (the CT_TextRun lxml element) because python-pptx exposes no - public API to distinguish an internal jump from an external URL. This - access pattern is stable across python-pptx 0.6.x; pin python-pptx<0.7. + public API to distinguish an internal jump from an external URL. XY chart + extraction likewise reads ``series._element`` for X values and bubble sizes, + which the public chart API does not expose. Keep these private accesses + localized here and covered by conversion smoke tests. """ from __future__ import annotations @@ -57,6 +59,10 @@ configure_utf8_stdio() EMU_PER_INCH = 914400 +DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" +PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" +RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex" OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"} IMAGE_EXT_BY_CONTENT_TYPE = { "image/bmp": "bmp", @@ -133,7 +139,10 @@ def sanitize_filename(value: str) -> str: def escape_table_cell(value: str) -> str: """Escape Markdown table syntax inside a cell.""" - return normalize_text(value).replace("|", r"\|") or " " + normalized = value.replace("\r\n", "\n").replace("\r", "\n") + lines = [re.sub(r"\s+", " ", line).strip() for line in normalized.split("\n")] + with_breaks = "
".join(lines) + return with_breaks.replace("|", r"\|") or " " def _safe_position(shape: object, attr: str) -> int: @@ -384,55 +393,260 @@ def _format_chart_value(value: object) -> str: return str(value) -def chart_to_markdown(chart: object, name: str) -> str: - """Render a chart's data as a Markdown table so the numbers survive conversion. - - A native PowerPoint chart stores its data in embedded XML, not in any text - frame — emitting only a `[Chart]` placeholder drops every value. The markdown - is the content contract for downstream generation, so transcribe categories × - series here. `scripts/pptx_intake.py` writes the same data in structured JSON - form for tooling; this is the human- and content-readable mirror. - """ +def _chart_header(chart: object, name: str) -> tuple[str, str]: + """Return the Markdown chart header and its best-effort type label.""" try: chart_type = str(chart.chart_type) - except (ValueError, AttributeError): + except (ValueError, AttributeError, KeyError): chart_type = "" + raw_name = "" if name is None else str(name) + chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart" + header = f"> [Chart] {chart_name}" + (f" — {chart_type}" if chart_type else "") + return header, chart_type + +def _chart_warning_lines(warnings: list[str]) -> list[str]: + """Return stable, de-duplicated Markdown warning blocks.""" + lines: list[str] = [] + seen: set[str] = set() + for warning in warnings: + normalized = normalize_text(warning).replace("\n", " ") or "unknown warning" + if normalized in seen: + continue + seen.add(normalized) + lines.append(f"> [Chart data warning: {normalized}]") + return lines + + +def _chart_data_unavailable( + header: str, + reason: str, + *, + warnings: list[str] | None = None, +) -> str: + """Attach an explicit data-read failure to a chart heading.""" + normalized_reason = normalize_text(reason).replace("\n", " ") or "unknown reason" + lines = [header] + lines.extend(_chart_warning_lines(warnings or [])) + lines.append(f"> [Chart data unavailable: {normalized_reason}]") + return "\n".join(lines) + + +def _chart_value_cell(value: object) -> str: + """Return one chart table cell while keeping missing values visibly empty.""" + rendered = _format_chart_value(value) + return escape_table_cell(rendered) if rendered else "" + + +def _chart_series_element(series: object) -> object | None: + """Return python-pptx's series XML carrier when its public API is insufficient.""" + element = getattr(series, "_element", None) + if element is not None: + return element + return getattr(series, "_ser", None) + + +def _chart_series_name(series: object, index: int) -> str: + """Return a stable, Markdown-safe series name.""" + try: + raw_name = series.name + except (ValueError, AttributeError, KeyError): + raw_name = None + label = str(raw_name) if raw_name not in (None, "") else f"Series {index}" + return escape_table_cell(label) + + +def _chart_numeric_cache_values( + parent: object | None, +) -> tuple[list[object | None] | None, str | None]: + """Read one XY display cache through python-pptx's OOXML value helpers.""" + if parent is None: + return None, "missing numeric value container" + try: + point_count = int(parent.ptCount_val) + values = [parent.pt_v(index) for index in range(point_count)] + except (AttributeError, IndexError, TypeError, ValueError): + return None, "invalid or unavailable numeric display cache" + if point_count <= 0 or all(value is None for value in values): + return None, "numeric display cache contains no values" + return values, None + + +def _chart_family(chart_type: str, series: list[object]) -> str: + """Classify category, scatter, and bubble charts without misreading XY as category.""" + type_key = chart_type.upper() + if "BUBBLE" in type_key: + return "bubble" + if "SCATTER" in type_key: + return "scatter" + for item in series: + element = _chart_series_element(item) + if element is None: + continue + if element.find(qn("c:bubbleSize")) is not None: + return "bubble" + if element.find(qn("c:xVal")) is not None or element.find(qn("c:yVal")) is not None: + return "scatter" + return "category" + + +def _xy_chart_to_markdown( + series: list[object], + *, + family: str, + header: str, +) -> str: + """Render scatter/bubble series as typed per-point X/Y[/size] rows.""" + table_header = ["Series", "Point", "X", "Y"] + if family == "bubble": + table_header.append("Size") + rows: list[list[str]] = [] + warnings: list[str] = [] + + for series_index, item in enumerate(series, start=1): + series_name = _chart_series_name(item, series_index) + element = _chart_series_element(item) + if element is None: + x_values = None + warnings.append(f"{series_name}: series XML is unavailable for X data") + else: + x_values, x_error = _chart_numeric_cache_values( + element.find(qn("c:xVal")) + ) + if x_error: + warnings.append(f"{series_name}: X data {x_error}") + try: + y_values = list(item.values) + except (ValueError, TypeError, AttributeError, KeyError): + y_values = [] + warnings.append(f"{series_name}: Y values are unavailable") + + size_values: list[object | None] | None = None + if family == "bubble": + if element is None: + size_error = "missing series XML" + else: + size_values, size_error = _chart_numeric_cache_values( + element.find(qn("c:bubbleSize")) + ) + if size_error: + warnings.append(f"{series_name}: bubble sizes {size_error}") + + x_values = x_values or [] + point_count = max( + len(x_values), + len(y_values), + len(size_values or []), + ) + if point_count == 0: + warnings.append(f"{series_name}: no readable points") + continue + + point_counts = {len(x_values), len(y_values)} + if family == "bubble": + point_counts.add(len(size_values or [])) + if len(point_counts) > 1: + dimensions = "X/Y/size" if family == "bubble" else "X/Y" + warnings.append( + f"{series_name}: {dimensions} point counts differ; " + "missing cells are blank" + ) + + for point_index in range(point_count): + x_value = x_values[point_index] if point_index < len(x_values) else None + y_value = y_values[point_index] if point_index < len(y_values) else None + row = [ + series_name, + str(point_index + 1), + _chart_value_cell(x_value), + _chart_value_cell(y_value), + ] + if family == "bubble": + size_value = ( + size_values[point_index] + if size_values is not None and point_index < len(size_values) + else None + ) + row.append(_chart_value_cell(size_value)) + rows.append(row) + + if not rows: + return _chart_data_unavailable( + header, + "chart has no readable XY points", + warnings=warnings, + ) + lines = [header] + lines.extend(_chart_warning_lines(warnings)) + lines.extend([ + "", + "| " + " | ".join(table_header) + " |", + "| " + " | ".join(["---"] * len(table_header)) + " |", + ]) + lines.extend("| " + " | ".join(row) + " |" for row in rows) + return "\n".join(lines) + + +def _category_chart_to_markdown(chart: object, series: list[object], header: str) -> str: + """Render a conventional category chart through python-pptx's public API.""" categories: list[str] = [] + warnings: list[str] = [] + has_category_xml = any( + (element := _chart_series_element(item)) is not None + and element.find(qn("c:cat")) is not None + for item in series + ) try: plots = list(chart.plots) if plots: categories = [ - escape_table_cell(str(cat)) if cat is not None else "" - for cat in plots[0].categories + escape_table_cell(str(category)) if category is not None else "" + for category in plots[0].categories ] - except (ValueError, IndexError, AttributeError): - categories = [] + except (ValueError, TypeError, IndexError, AttributeError, KeyError): + if has_category_xml: + warnings.append("chart categories are unavailable; using point numbers") + if not has_category_xml and not categories: + warnings.append("chart categories are missing; using point numbers") + elif has_category_xml and not categories: + warnings.append("chart categories are empty; using point numbers") series_data: list[tuple[str, list[object]]] = [] - try: - for index, series in enumerate(chart.series, start=1): - try: - values = list(series.values) - except (ValueError, TypeError, AttributeError): - values = [] - label = str(series.name) if getattr(series, "name", None) else f"Series {index}" - series_data.append((escape_table_cell(label), values)) - except (ValueError, AttributeError): - series_data = [] + for index, item in enumerate(series, start=1): + series_name = _chart_series_name(item, index) + try: + values = list(item.values) + except (ValueError, TypeError, AttributeError, KeyError): + warnings.append(f"{series_name}: series values are unavailable") + continue + series_data.append((series_name, values)) - header = f"> [Chart] {name}" + (f" — {chart_type}" if chart_type else "") - row_count = len(categories) if categories else max((len(v) for _, v in series_data), default=0) + row_count = max( + len(categories), + max((len(values) for _, values in series_data), default=0), + ) if not series_data or row_count == 0: - return header + return _chart_data_unavailable( + header, + "chart has no readable category-series data", + warnings=warnings, + ) + point_counts = {len(values) for _, values in series_data} + if categories: + point_counts.add(len(categories)) + if len(point_counts) > 1: + warnings.append("category/series point counts differ; missing cells are blank") - table_header = (["Category"] if categories else ["#"]) + [sname for sname, _ in series_data] - lines = [ - header, + table_header = (["Category"] if categories else ["#"]) + [ + series_name for series_name, _ in series_data + ] + lines = [header] + lines.extend(_chart_warning_lines(warnings)) + lines.extend([ "", "| " + " | ".join(table_header) + " |", "| " + " | ".join(["---"] * len(table_header)) + " |", - ] + ]) for row_index in range(row_count): if categories: label = categories[row_index] if row_index < len(categories) else "" @@ -440,11 +654,85 @@ def chart_to_markdown(chart: object, name: str) -> str: label = str(row_index + 1) cells = [label] for _, values in series_data: - cells.append(_format_chart_value(values[row_index]) if row_index < len(values) else "") + value = values[row_index] if row_index < len(values) else None + cells.append(_chart_value_cell(value)) lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines) +def chart_to_markdown(chart: object, name: str) -> str: + """Render category, scatter, and bubble data without flattening chart semantics. + + A native PowerPoint chart stores its data in embedded XML, not in any text + frame. Public python-pptx APIs cover category values and scatter/bubble Y + values, but not XY X coordinates or bubble sizes. Read only those missing + display caches from the series XML. Preserve every readable value and emit + explicit warnings for missing dimensions or series rather than discarding + the chart's remaining content. + """ + header, chart_type = _chart_header(chart, name) + try: + series = list(chart.series) + except (ValueError, TypeError, AttributeError, KeyError): + return _chart_data_unavailable(header, "chart series are unavailable") + if not series: + return _chart_data_unavailable(header, "chart has no readable series") + + family = _chart_family(chart_type, series) + if family in {"scatter", "bubble"}: + return _xy_chart_to_markdown(series, family=family, header=header) + return _category_chart_to_markdown(chart, series, header) + + +def _chart_reference_id(element: object) -> str | None: + """Return the first chart relationship id carried by an OOXML shape subtree.""" + for descendant in element.iter(): + if descendant.tag.rsplit("}", 1)[-1] != "chart": + continue + relationship_id = descendant.get(f"{{{RELATIONSHIP_NS}}}id") + if relationship_id: + return relationship_id + return None + + +def _unexposed_chartex_markdown( + slide: object, + emitted_relationship_ids: set[str], +) -> list[str]: + """Report ChartEx objects omitted from ``slide.shapes`` by python-pptx.""" + blocks: list[str] = [] + seen_relationship_ids: set[str] = set() + slide_element = getattr(slide, "element", None) + if slide_element is None: + slide_element = getattr(slide, "_element", None) + if slide_element is None: + return blocks + for graphic_data in slide_element.iter(f"{{{DRAWINGML_NS}}}graphicData"): + if graphic_data.get("uri") != CHARTEX_URI: + continue + relationship_id = _chart_reference_id(graphic_data) + if relationship_id and ( + relationship_id in emitted_relationship_ids + or relationship_id in seen_relationship_ids + ): + continue + if relationship_id: + seen_relationship_ids.add(relationship_id) + + name = "ChartEx chart" + current = graphic_data + while current is not None: + name_element = current.find(f".//{{{PRESENTATIONML_NS}}}cNvPr") + if name_element is not None and name_element.get("name"): + name = name_element.get("name") + break + current = current.getparent() if hasattr(current, "getparent") else None + chart_name = normalize_text(str(name)).replace("\n", " ") or "ChartEx chart" + header = f"> [Chart] {chart_name} — ChartEx" + blocks.append(_chart_data_unavailable(header, "unsupported ChartEx data model")) + return blocks + + def _image_part_for_shape(shape: object) -> object | None: """Return the first embedded image part referenced by a shape.""" element = getattr(shape, "element", None) @@ -787,6 +1075,7 @@ def convert_presentation_to_markdown( if diagram.get("shape_id") is not None } emitted_diagram_ids: set[str] = set() + emitted_chart_relationship_ids: set[str] = set() for item in iter_leaf_shapes(slide.shapes): shape = item.shape @@ -843,10 +1132,31 @@ def convert_presentation_to_markdown( continue if getattr(shape, "has_chart", False): + shape_element = getattr(shape, "element", None) + if shape_element is None: + shape_element = getattr(shape, "_element", None) + relationship_id = ( + _chart_reference_id(shape_element) + if shape_element is not None + else None + ) + if relationship_id: + emitted_chart_relationship_ids.add(relationship_id) try: blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart"))) - except (ValueError, AttributeError, KeyError): - blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}") + except (ValueError, TypeError, AttributeError, KeyError) as exc: + raw_name = getattr(shape, "name", "Chart") + name = normalize_text("" if raw_name is None else str(raw_name)) or "Chart" + blocks.append( + _chart_data_unavailable( + f"> [Chart] {name}", + f"chart read failed ({type(exc).__name__})", + ) + ) + + blocks.extend( + _unexposed_chartex_markdown(slide, emitted_chart_relationship_ids) + ) for diagram in slide_diagrams: if str(diagram.get("diagram_id")) in emitted_diagram_ids: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_authoring_view.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_authoring_view.py new file mode 100644 index 00000000..c3030963 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_authoring_view.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +""" +PPT Master - SVG Authoring View + +Create a lightweight, non-destructive view of PPTX-imported SVG files for +human or model inspection. The source SVG remains the round-trip authority; +the output copy keeps visible SVG content and compact shape intent while +hiding bulky import-only payloads and duplicate hidden geometry carriers. + +Usage: + python3 scripts/svg_authoring_view.py -o + +Examples: + python3 scripts/svg_authoring_view.py analysis/source_svg_import/svg-flat -o analysis/authoring-svg + python3 scripts/svg_authoring_view.py imported/slide_06.svg -o /tmp/slide-authoring-view + +Dependencies: + None (standard library only). + +This tool produces an inspection/authoring projection, not a final template or +release export source. Keep the complete imported SVG for native restoration. +Directory runs prepare and stage the complete batch before publishing it, so a +failed page leaves the existing destination set unchanged. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import urlsplit, urlunsplit +from xml.etree import ElementTree as ET + +from console_encoding import configure_utf8_stdio + +configure_utf8_stdio() + +SVG_NS = "http://www.w3.org/2000/svg" +XLINK_NS = "http://www.w3.org/1999/xlink" + +ET.register_namespace("", SVG_NS) +ET.register_namespace("xlink", XLINK_NS) + +# These fields identify the source OOXML object or guard its exact imported +# fallback. They belong in the complete import SVG, not its lightweight view. +IMPORT_SOURCE_ATTRIBUTES = { + "data-name", + "data-pptx-preview-sha256", + "data-pptx-shape-id", + "data-pptx-shape-name", + "data-pptx-shape-scope", + "data-pptx-shape-style", +} + +# Compact native-shape intent is intentionally not in the removal set: +# data-pptx-object, data-pptx-prst, and data-pptx-frame remain useful while +# reviewing the visible fallback. Structural markers also pass through +# unchanged; this projection never defines restoration policy. + + +def _local_name(name: object) -> str: + return name.rsplit("}", 1)[-1] if isinstance(name, str) else "" + + +@dataclass +class ProjectionStats: + txbody_metadata: int = 0 + hidden_geometry_carriers: int = 0 + geometry_preview_wrappers: int = 0 + geometry_detail_markers: int = 0 + asset_references_rewritten: int = 0 + source_attributes: Counter[str] = field(default_factory=Counter) + + def as_dict(self) -> dict[str, object]: + return { + "txbody_metadata": self.txbody_metadata, + "hidden_geometry_carriers": self.hidden_geometry_carriers, + "geometry_preview_wrappers": self.geometry_preview_wrappers, + "geometry_detail_markers": self.geometry_detail_markers, + "source_attributes": dict(sorted(self.source_attributes.items())), + "asset_references_rewritten": self.asset_references_rewritten, + } + + def merge(self, other: "ProjectionStats") -> None: + self.txbody_metadata += other.txbody_metadata + self.hidden_geometry_carriers += other.hidden_geometry_carriers + self.geometry_preview_wrappers += other.geometry_preview_wrappers + self.geometry_detail_markers += other.geometry_detail_markers + self.asset_references_rewritten += other.asset_references_rewritten + self.source_attributes.update(other.source_attributes) + + +@dataclass +class ProjectionReport: + source: Path + output: Path + original_bytes: int + projected_bytes: int + stats: ProjectionStats + + def as_dict(self) -> dict[str, object]: + saved = self.original_bytes - self.projected_bytes + reduction = (saved / self.original_bytes * 100) if self.original_bytes else 0.0 + return { + "source": str(self.source), + "output": str(self.output), + "original_bytes": self.original_bytes, + "projected_bytes": self.projected_bytes, + "bytes_saved": saved, + "reduction_percent": round(reduction, 2), + "removed": self.stats.as_dict(), + } + + +def _is_hidden_geometry_carrier(element: ET.Element) -> bool: + if element.get("data-pptx-part") != "geometry": + return False + visibility = (element.get("visibility") or "").strip().lower() + display = (element.get("display") or "").strip().lower() + style = (element.get("style") or "").replace(" ", "").lower() + return ( + visibility == "hidden" + or display == "none" + or "visibility:hidden" in style + or "display:none" in style + ) + + +def _append_tail(parent: ET.Element, index: int, tail: str | None) -> None: + if not tail: + return + if index > 0: + previous = list(parent)[index - 1] + previous.tail = (previous.tail or "") + tail + else: + parent.text = (parent.text or "") + tail + + +def _remove_child(parent: ET.Element, child: ET.Element) -> None: + children = list(parent) + index = children.index(child) + tail = child.tail + parent.remove(child) + _append_tail(parent, index, tail) + + +def _unwrap_preview(parent: ET.Element, wrapper: ET.Element) -> bool: + """Promote a marker-only preview wrapper without changing its geometry.""" + if wrapper.attrib or (wrapper.text and wrapper.text.strip()): + return False + + siblings = list(parent) + index = siblings.index(wrapper) + promoted = list(wrapper) + wrapper_tail = wrapper.tail + for child in promoted: + wrapper.remove(child) + parent.remove(wrapper) + + for offset, child in enumerate(promoted): + parent.insert(index + offset, child) + + if promoted: + promoted[-1].tail = (promoted[-1].tail or "") + (wrapper_tail or "") + else: + _append_tail(parent, index, wrapper_tail) + return True + + +def _strip_import_attributes(element: ET.Element, stats: ProjectionStats) -> None: + for name in list(element.attrib): + if name not in IMPORT_SOURCE_ATTRIBUTES: + continue + stats.source_attributes[name] += 1 + del element.attrib[name] + + +def _project_subtree(parent: ET.Element, stats: ProjectionStats) -> None: + for child in list(parent): + part = child.get("data-pptx-part") + tag = _local_name(child.tag) + + if tag == "metadata" and part == "txbody": + stats.txbody_metadata += 1 + _remove_child(parent, child) + continue + + if _is_hidden_geometry_carrier(child): + stats.hidden_geometry_carriers += 1 + _remove_child(parent, child) + continue + + _project_subtree(child, stats) + _strip_import_attributes(child, stats) + + if part == "geometry-preview": + child.attrib.pop("data-pptx-part", None) + if _unwrap_preview(parent, child): + stats.geometry_preview_wrappers += 1 + elif part == "geometry-detail": + child.attrib.pop("data-pptx-part", None) + stats.geometry_detail_markers += 1 + + +def _rewrite_asset_reference(value: str, source_dir: Path, output_dir: Path) -> str: + if not value or value.startswith("#"): + return value + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or not parsed.path: + return value + + resolved = (source_dir / parsed.path).resolve() + try: + relative = os.path.relpath(resolved, output_dir).replace(os.sep, "/") + except ValueError: + relative = resolved.as_uri() + return urlunsplit(("", "", relative, parsed.query, parsed.fragment)) + + +def _rewrite_asset_references( + root: ET.Element, + source_dir: Path, + output_dir: Path, + stats: ProjectionStats, +) -> None: + for element in root.iter(): + for name in ("href", f"{{{XLINK_NS}}}href"): + current = element.get(name) + if current is None: + continue + rewritten = _rewrite_asset_reference(current, source_dir, output_dir) + if rewritten != current: + element.set(name, rewritten) + stats.asset_references_rewritten += 1 + + +def _render_projection(source: Path, output: Path) -> tuple[ProjectionReport, bytes]: + """Build one projection in memory without changing source or destination.""" + original = source.read_bytes() + parser = ET.XMLParser( + target=ET.TreeBuilder(insert_comments=True, insert_pis=True), + ) + root = ET.fromstring(original, parser=parser) + if _local_name(root.tag) != "svg": + raise ValueError(f"Root element is not : {source}") + + stats = ProjectionStats() + _project_subtree(root, stats) + _strip_import_attributes(root, stats) + _rewrite_asset_references(root, source.parent, output.parent, stats) + + projected = ET.tostring(root, encoding="utf-8", xml_declaration=False) + if not projected.endswith(b"\n"): + projected += b"\n" + + report = ProjectionReport( + source=source, + output=output, + original_bytes=len(original), + projected_bytes=len(projected), + stats=stats, + ) + return report, projected + + +def project_svg(source: Path, output: Path) -> ProjectionReport: + """Write one lightweight authoring projection without changing the source.""" + report, projected = _render_projection(source, output) + output.parent.mkdir(parents=True, exist_ok=True) + + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{output.name}.", + suffix=".tmp", + dir=output.parent, + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(projected) + temporary.replace(output) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return report + + +def _nearest_existing_directory(path: Path) -> Path: + candidate = path + while not os.path.lexists(candidate): + parent = candidate.parent + if parent == candidate: + break + candidate = parent + if not candidate.is_dir(): + raise NotADirectoryError(f"Output parent is not a directory: {candidate}") + return candidate + + +def _ensure_directory(path: Path, created: list[Path]) -> None: + missing: list[Path] = [] + candidate = path + while not candidate.exists(): + if os.path.lexists(candidate): + raise NotADirectoryError(f"Output parent is not a directory: {candidate}") + missing.append(candidate) + parent = candidate.parent + if parent == candidate: + raise NotADirectoryError(f"Cannot resolve output parent: {path}") + candidate = parent + if not candidate.is_dir(): + raise NotADirectoryError(f"Output parent is not a directory: {candidate}") + + for directory in reversed(missing): + directory.mkdir() + created.append(directory) + + +def _remove_created_directories(created: list[Path]) -> list[str]: + errors: list[str] = [] + for directory in reversed(created): + try: + directory.rmdir() + except FileNotFoundError: + continue + except OSError as exc: + errors.append(f"could not remove {directory}: {exc}") + return errors + + +def _rollback_published_files( + published: list[tuple[Path, Path | None]], + created: list[Path], +) -> list[str]: + errors: list[str] = [] + for target, backup in reversed(published): + try: + if backup is None: + target.unlink(missing_ok=True) + else: + backup.replace(target) + except OSError as exc: + errors.append(f"could not restore {target}: {exc}") + errors.extend(_remove_created_directories(created)) + return errors + + +def _publish_existing_directory( + staged: list[tuple[ProjectionReport, Path]], + staging_root: Path, + *, + force: bool, +) -> None: + backup_root = staging_root / "previous" + backups: dict[Path, Path | None] = {} + + for index, (report, _) in enumerate(staged): + target = report.output + if not os.path.lexists(target): + backups[target] = None + continue + if not force: + raise FileExistsError(f"Output file already exists: {target}") + if target.is_dir() and not target.is_symlink(): + raise IsADirectoryError(f"Output target is a directory: {target}") + + backup = backup_root / f"{index:06d}.svg" + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, backup, follow_symlinks=False) + backups[target] = backup + + created: list[Path] = [] + published: list[tuple[Path, Path | None]] = [] + try: + for report, _ in staged: + _ensure_directory(report.output.parent, created) + + staging_device = staging_root.stat().st_dev + for report, _ in staged: + if report.output.parent.stat().st_dev != staging_device: + raise OSError( + f"Cannot atomically publish across filesystems: {report.output}" + ) + if backups[report.output] is None and os.path.lexists(report.output): + raise FileExistsError( + f"Output appeared while projections were staged: {report.output}" + ) + + for report, staged_file in staged: + target = report.output + staged_file.replace(target) + published.append((target, backups[target])) + except OSError as exc: + rollback_errors = _rollback_published_files(published, created) + if rollback_errors: + details = "; ".join(rollback_errors) + raise RuntimeError( + f"Batch publish failed ({exc}); rollback was incomplete: {details}" + ) from exc + raise + + +def project_svg_batch( + mapping: list[tuple[Path, Path]], + output_dir: Path, + *, + force: bool, +) -> list[ProjectionReport]: + """Project and publish a directory mapping as one recoverable transaction.""" + rendered = [_render_projection(source, output) for source, output in mapping] + staging_parent = _nearest_existing_directory(output_dir.parent) + + with tempfile.TemporaryDirectory( + prefix=".svg-authoring-view-", + dir=staging_parent, + ) as temporary: + staging_root = Path(temporary) + new_root = staging_root / "projected" + staged: list[tuple[ProjectionReport, Path]] = [] + + for report, projected in rendered: + relative = report.output.relative_to(output_dir) + staged_file = new_root / relative + staged_file.parent.mkdir(parents=True, exist_ok=True) + staged_file.write_bytes(projected) + staged.append((report, staged_file)) + + if not output_dir.exists(): + created: list[Path] = [] + try: + _ensure_directory(output_dir.parent, created) + if os.path.lexists(output_dir): + raise FileExistsError( + f"Output directory appeared while projections were staged: {output_dir}" + ) + if output_dir.parent.stat().st_dev != staging_root.stat().st_dev: + raise OSError( + f"Cannot atomically publish across filesystems: {output_dir}" + ) + new_root.replace(output_dir) + except OSError as exc: + cleanup_errors = _remove_created_directories(created) + if cleanup_errors: + details = "; ".join(cleanup_errors) + raise RuntimeError( + f"Batch publish failed ({exc}); cleanup was incomplete: {details}" + ) from exc + raise + else: + _publish_existing_directory( + staged, + staging_root, + force=force, + ) + + return [report for report, _ in rendered] + + +def _is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _source_mapping(input_path: Path, output_dir: Path) -> list[tuple[Path, Path]]: + if input_path.is_file(): + if input_path.suffix.lower() != ".svg": + raise ValueError(f"Input file must use the .svg extension: {input_path}") + return [(input_path, output_dir / input_path.name)] + + sources = sorted( + path for path in input_path.rglob("*") + if path.is_file() and path.suffix.lower() == ".svg" + ) + if not sources: + raise ValueError(f"No SVG files found under: {input_path}") + return [(source, output_dir / source.relative_to(input_path)) for source in sources] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Create lightweight, non-destructive authoring views of PPTX-imported SVG files." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("input", type=Path, help="SVG file or directory to project") + parser.add_argument( + "-o", + "--output-dir", + type=Path, + required=True, + help="Explicit destination directory for projected SVG copies", + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace projected files that already exist (never changes source files)", + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_parser().parse_args(argv) + input_path = args.input.resolve() + output_dir = args.output_dir.resolve() + + if not input_path.exists(): + print(f"Error: input does not exist: {input_path}", file=sys.stderr) + return 1 + if output_dir.exists() and not output_dir.is_dir(): + print(f"Error: output path is not a directory: {output_dir}", file=sys.stderr) + return 1 + if input_path.is_dir() and _is_within(output_dir, input_path): + print("Error: output directory must not be inside the input directory", file=sys.stderr) + return 1 + + try: + mapping = _source_mapping(input_path, output_dir) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + same_file = [source for source, target in mapping if source.resolve() == target.resolve()] + if same_file: + print(f"Error: output would overwrite source SVG: {same_file[0]}", file=sys.stderr) + return 1 + + collisions = [target for _, target in mapping if os.path.lexists(target)] + if collisions and not args.force: + print( + f"Error: {len(collisions)} output file(s) already exist; use --force to replace projections. " + f"First collision: {collisions[0]}", + file=sys.stderr, + ) + return 1 + + reports: list[ProjectionReport] = [] + try: + if input_path.is_file(): + source, output = mapping[0] + reports.append(project_svg(source, output)) + else: + reports = project_svg_batch( + mapping, + output_dir, + force=args.force, + ) + except (ET.ParseError, OSError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + total_stats = ProjectionStats() + original_bytes = 0 + projected_bytes = 0 + for report in reports: + original_bytes += report.original_bytes + projected_bytes += report.projected_bytes + total_stats.merge(report.stats) + + bytes_saved = original_bytes - projected_bytes + reduction = (bytes_saved / original_bytes * 100) if original_bytes else 0.0 + result = { + "input": str(input_path), + "output_dir": str(output_dir), + "file_count": len(reports), + "files": [report.as_dict() for report in reports], + "totals": { + "original_bytes": original_bytes, + "projected_bytes": projected_bytes, + "bytes_saved": bytes_saved, + "reduction_percent": round(reduction, 2), + "removed": total_stats.as_dict(), + }, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py index a595880b..d1d74a74 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py @@ -1048,6 +1048,35 @@ def _open_browser(url: str) -> bool: return False +def _reuse_running_server(existing: dict, *, open_browser: bool) -> int: + """Idempotent relaunch: point at the already-running preview instead of failing. + + A relaunch while the server is alive is the normal second-preview flow + (the first server keeps serving after the browser tab is closed), so it + must re-open the browser and exit 0 — not error out. + """ + pid = existing.get('pid', '?') + try: + port = int(existing.get('port', 0) or 0) + except (TypeError, ValueError): + port = 0 + if not port: + logger.error( + 'live preview is already running for this project (pid=%s) but its ' + 'lock records no usable port; run --shutdown, then start again', + pid, + ) + return 1 + url = _server_url(port) + logger.info( + 'live preview already running for this project (pid=%s), reusing: %s', + pid, url, + ) + if open_browser and not _open_browser(url): + logger.info('browser did not auto-open; open %s manually', url) + return 0 + + def _open_browser_async(url: str, delay: float = 0.4) -> None: """Open the browser shortly after Flask starts binding its socket.""" def _open() -> None: @@ -1121,15 +1150,7 @@ def main(argv: Optional[list[str]] = None) -> int: legacy_existing = _legacy_live_lock(project_path) if legacy_existing: - existing_pid = legacy_existing.get('pid', '?') - existing_port = legacy_existing.get('port', '?') - logger.error( - 'live preview is already running for this project via legacy lock ' - '(pid=%s, port=%s). Open http://%s:%s, click ' - 'Exit preview in the browser, or stop pid %s', - existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid, - ) - return 1 + return _reuse_running_server(legacy_existing, open_browser=not args.no_browser) runtime_dir = _runtime_dir(project_path) lock_file = _lock_file(project_path) @@ -1137,14 +1158,7 @@ def main(argv: Optional[list[str]] = None) -> int: if args.daemon: existing = _read_lock(lock_file) if existing and _process_alive(_lock_pid(existing)): - existing_pid = existing.get('pid', '?') - existing_port = existing.get('port', '?') - logger.error( - 'live preview is already running for this project ' - '(pid=%s, port=%s). Open http://%s:%s', - existing_pid, existing_port, PUBLIC_HOST, existing_port, - ) - return 1 + return _reuse_running_server(existing, open_browser=not args.no_browser) try: runtime_dir.mkdir(parents=True, exist_ok=True) @@ -1197,8 +1211,9 @@ def main(argv: Optional[list[str]] = None) -> int: # Per-project mutual exclusion. The major driver of orphaned servers is # --live mode (which used to disable idle timeout entirely) combined with - # silent restarts; refusing duplicate launches catches the accumulation - # at its source. Stale locks (dead pid) are overwritten by _claim_lock. + # silent restarts; reusing the running server on duplicate launches catches + # the accumulation at its source. Stale locks (dead pid) are overwritten + # by _claim_lock. try: runtime_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: @@ -1206,15 +1221,7 @@ def main(argv: Optional[list[str]] = None) -> int: return 1 existing = _claim_lock(lock_file, port) if existing: - existing_pid = existing.get('pid', '?') - existing_port = existing.get('port', '?') - logger.error( - 'live preview is already running for this project ' - '(pid=%s, port=%s). Open http://%s:%s, click ' - 'Exit preview in the browser, or run: kill %s', - existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid, - ) - return 1 + return _reuse_running_server(existing, open_browser=not args.no_browser) # atexit covers normal interpreter shutdown (Ctrl+C / SystemExit); # /api/shutdown and idle timeout call _release_lock directly before # os._exit since atexit handlers do not run on os._exit. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py index 1a09efc1..bfad80b4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/flatten_tspan.py @@ -260,6 +260,18 @@ def _get_font_size_px(elem: ET.Element) -> float | None: return parse_first_number(style_size) +def _effective_line_font_size_px( + text_el: ET.Element, + line_group: list[ET.Element], +) -> float: + """Return the positioned line starter's effective font size.""" + line_size = _get_font_size_px(line_group[0]) + if line_size is not None: + return line_size + parent_size = _get_font_size_px(text_el) + return parent_size if parent_size is not None else 16.0 + + def _classify_paragraph_block( text_el: ET.Element, is_svg_tag, @@ -292,6 +304,7 @@ def _classify_paragraph_block( is treated as a section break and rejected. - Every line-break tspan that sets x repeats the parent 's x. - No nested tspan inside any line carries x/y/dy. + - Adjacent lines with different effective font sizes start new paragraphs. """ base_x = parse_first_number(get_attr(text_el, "x")) child_view = _build_paragraph_child_view(text_el, is_svg_tag) @@ -358,6 +371,10 @@ def _classify_paragraph_block( extras: list[float] = [0.0] # first line never has space-before soft_breaks: list[bool] = [False] # first line starts a paragraph + line_font_sizes = [ + _effective_line_font_size_px(text_el, group) + for group in line_groups + ] for idx, d in enumerate(dy_values[1:], start=1): if d + DY_TOLERANCE_PX < base: return None # below base — line overlap, not a paragraph @@ -368,11 +385,12 @@ def _classify_paragraph_block( extra = 0.0 # dy at the base line-height = soft break (SVG was simulating wrap); # dy strictly greater than base = hard paragraph break. List markers - # also start a fresh paragraph so bullet/ordered items do not merge - # into the previous item when exported to PowerPoint. + # and font-size changes also start a fresh paragraph so semantically + # distinct visual lines do not merge into one PowerPoint line. is_soft = ( abs(extra) <= DY_TOLERANCE_PX and not _starts_with_list_marker(line_groups[idx]) + and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6 ) extras.append(0.0 if is_soft else extra) soft_breaks.append(is_soft) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py index 89498823..1e40a183 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py @@ -15,6 +15,7 @@ import sys import re import json import html +import math from pathlib import Path from typing import List, Dict, Tuple from collections import Counter, defaultdict @@ -53,14 +54,24 @@ else: try: from svg_to_pptx.drawingml.utils import ( + IDENTITY_MATRIX as _IDENTITY_MATRIX, + matrix_multiply as _matrix_multiply, + parse_transform_matrix as _parse_transform_matrix, parse_font_family as _parse_export_font_family, parse_inline_style as _parse_inline_style, parse_svg_color as _parse_export_color, + rect_to_dml_xfrm as _rect_to_dml_xfrm, + validate_dml_shape_matrix as _validate_dml_shape_matrix, ) except ImportError: + _IDENTITY_MATRIX = None + _matrix_multiply = None + _parse_transform_matrix = None _parse_export_font_family = None _parse_inline_style = None _parse_export_color = None + _rect_to_dml_xfrm = None + _validate_dml_shape_matrix = None try: from svg_to_pptx.drawingml.converter import ( @@ -69,6 +80,33 @@ try: except ImportError: _collect_unsupported_visuals = None +try: + from svg_to_pptx.drawingml.elements import ( + validate_preset_geometry_metadata as _validate_preset_geometry_metadata, + ) +except ImportError: + _validate_preset_geometry_metadata = None + +try: + from pptx_to_svg.preset_authoring import ( + AUTHORING_ATTR as _AUTHORING_ATTR, + validate_authored_preset_tree as _validate_authored_preset_tree, + ) +except ImportError: + _AUTHORING_ATTR = 'data-pptx-authoring' + _validate_authored_preset_tree = None + +try: + from pptx_shapes import ( + CONNECTOR_PRESET_TYPES as _CONNECTOR_PRESET_TYPES, + resolve_preset_preview_hash as _resolve_preset_preview_hash, + svg_preset_preview_fingerprint as _svg_preset_preview_fingerprint, + ) +except ImportError: + _CONNECTOR_PRESET_TYPES = frozenset() + _resolve_preset_preview_hash = None + _svg_preset_preview_fingerprint = None + try: from svg_to_pptx.native_objects import ( validate_native_object_marker as _validate_native_object_marker, @@ -90,6 +128,15 @@ try: except ImportError: _native_object_marker_warnings = None +try: + from svg_to_pptx.native_objects.marker_status import ( + native_marker_release_block_reason as _native_marker_release_block_reason, + native_marker_status_errors as _native_marker_status_errors, + ) +except ImportError: + _native_marker_release_block_reason = None + _native_marker_status_errors = None + try: from svg_to_pptx.semantic_markers import ( SEMANTIC_ATTRS as _SEMANTIC_ATTRS, @@ -125,26 +172,22 @@ except ImportError: try: from svg_to_pptx.pptx_package.template_structure import ( TemplateStructureError as _TemplateStructureError, - PptxLayoutReference as _PptxLayoutReference, - PptxStructureLock as _PptxStructureLock, - load_native_structure_contract as _load_native_structure_contract, load_pptx_structure_lock as _load_pptx_structure_lock, - native_structure_lock_errors as _native_structure_lock_errors, - parse_preserve_slides as _parse_preserve_structure_slides, + parse_template_slide as _parse_template_structure_slide, parse_template_slides as _parse_template_structure_slides, + _structure_subtree_signature as _structure_subtree_signature, template_lock_errors as _template_lock_errors, + template_prototype_errors as _template_prototype_errors, validate_template_svg as _validate_template_structure_svg, ) except ImportError: _TemplateStructureError = None - _PptxLayoutReference = None - _PptxStructureLock = None - _load_native_structure_contract = None _load_pptx_structure_lock = None - _native_structure_lock_errors = None - _parse_preserve_structure_slides = None + _parse_template_structure_slide = None _parse_template_structure_slides = None + _structure_subtree_signature = None _template_lock_errors = None + _template_prototype_errors = None _validate_template_structure_svg = None try: @@ -178,6 +221,40 @@ _BARE_HEX_VALUE_RE = re.compile( ) SVG_NS = "http://www.w3.org/2000/svg" XLINK_NS = "http://www.w3.org/1999/xlink" +_NON_VISUAL_SVG_TAGS = frozenset({ + 'defs', + 'desc', + 'metadata', + 'style', + 'title', +}) +_PPTX_ROOT_STRUCTURE_ATTRS = ( + 'data-pptx-master', + 'data-pptx-master-name', + 'data-pptx-layout', + 'data-pptx-layout-name', +) +_PPTX_STRUCTURE_ATTRS = frozenset({ + *_PPTX_ROOT_STRUCTURE_ATTRS, + 'data-pptx-layer', + 'data-pptx-layout-kind', + 'data-pptx-placeholder', + 'data-pptx-placeholder-binding', + 'data-pptx-placeholder-bounds', + 'data-pptx-placeholder-carrier', + 'data-pptx-placeholder-idx', +}) +_PPTX_PLACEHOLDER_DETAIL_ATTRS = frozenset({ + 'data-pptx-placeholder-binding', + 'data-pptx-placeholder-bounds', + 'data-pptx-placeholder-idx', +}) +_PPTX_STRUCTURE_SECTION_RE = re.compile( + r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)" +) +_PPTX_STRUCTURE_MODE_RE = re.compile( + r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^\s#]+)[ \t]*(?:#.*)?$" +) _SUPPORTED_FILTER_PRIMITIVES = frozenset({ 'feDropShadow', 'feGaussianBlur', @@ -192,6 +269,237 @@ _SUPPORTED_FILTER_PRIMITIVES = frozenset({ _FILTER_EFFECT_PRIMITIVES = frozenset({'feDropShadow', 'feGaussianBlur'}) +def _declared_pptx_structure_mode(project_path: Path) -> str | None: + """Return the explicitly locked SVG structure mode without a fallback.""" + lock_path = project_path / 'spec_lock.md' + try: + content = lock_path.read_text(encoding='utf-8') + except OSError: + return None + section_match = _PPTX_STRUCTURE_SECTION_RE.search(content) + if section_match is None: + return None + mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1)) + return mode_match.group(1).strip().lower() if mode_match else None + + +def _placeholder_bounds_error(value: str) -> str | None: + """Return a concise error for invalid design-zone bounds.""" + raw_values = [item for item in re.split(r"[\s,]+", value.strip()) if item] + if len(raw_values) != 4: + return "must contain exactly four numbers: x y width height" + try: + values = tuple(float(item) for item in raw_values) + except ValueError: + return "must contain only numeric values" + if not all(math.isfinite(item) for item in values): + return "must contain only finite values" + if values[2] <= 0 or values[3] <= 0: + return "must use positive width and height" + return None + + +def _local_pptx_structure_errors( + root: ET.Element, + svg_path: Path, + *, + require_structure: bool, +) -> List[str]: + """Validate the authoring shape of the structured SVG contract.""" + errors: List[str] = [] + root_values = { + attr: (root.get(attr) or '').strip() + for attr in _PPTX_ROOT_STRUCTURE_ATTRS + } + has_root_structure = any(root_values.values()) + if require_structure or has_root_structure: + missing = [attr for attr, value in root_values.items() if not value] + if missing: + errors.append( + f"{svg_path.name}: structured SVG root is missing " + + ', '.join(missing) + ) + + parent_by_id = { + id(child): parent + for parent in root.iter() + for child in list(parent) + } + for elem in root.iter(): + tag = elem.tag.rsplit('}', 1)[-1] + element_id = elem.get('id') or f"<{tag}>" + parent = parent_by_id.get(id(elem)) + + if elem is not root: + nested_root_attrs = [ + attr for attr in _PPTX_ROOT_STRUCTURE_ATTRS + if elem.get(attr) is not None + ] + if nested_root_attrs: + errors.append( + f"{svg_path.name}: {element_id} carries root-only metadata " + + ', '.join(nested_root_attrs) + ) + + if elem.get('data-pptx-layout-kind') is not None: + errors.append( + f"{svg_path.name}: data-pptx-layout-kind is a legacy distillation " + "attribute; restore the page to the structured contract" + ) + + layer = (elem.get('data-pptx-layer') or '').strip().lower() + placeholder = (elem.get('data-pptx-placeholder') or '').strip().lower() + if layer in {'master', 'layout'}: + if parent is not root: + errors.append( + f"{svg_path.name}: {element_id} data-pptx-layer={layer!r} " + "must be a direct child of the root " + ) + if tag == 'g': + errors.append( + f"{svg_path.name}: {element_id} is a marked as {layer}; " + "Master/Layout fixed visuals must be root-level atomic elements" + ) + if placeholder: + errors.append( + f"{svg_path.name}: {element_id} cannot be both a fixed " + f"{layer} element and a placeholder slot" + ) + + detail_attrs = [ + attr for attr in _PPTX_PLACEHOLDER_DETAIL_ATTRS + if elem.get(attr) is not None + ] + if detail_attrs and not placeholder: + errors.append( + f"{svg_path.name}: {element_id} uses placeholder detail metadata " + "without data-pptx-placeholder" + ) + + if placeholder: + if parent is not root: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} must be a " + "direct child of the root " + ) + if tag != 'g': + errors.append( + f"{svg_path.name}: placeholder slot {element_id} must be a " + "root-level " + ) + if not (elem.get('id') or '').strip(): + errors.append( + f"{svg_path.name}: every placeholder slot requires a stable id" + ) + wrapper_attrs = sorted( + attr.rsplit('}', 1)[-1] + for attr in elem.attrib + if attr != 'id' + and not attr.rsplit('}', 1)[-1].startswith('data-pptx-') + ) + if wrapper_attrs: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} is an " + "authoring boundary and may carry only id/data-pptx-*; remove " + + ', '.join(wrapper_attrs) + ) + bounds = (elem.get('data-pptx-placeholder-bounds') or '').strip() + if not bounds: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} requires " + "data-pptx-placeholder-bounds" + ) + else: + bounds_error = _placeholder_bounds_error(bounds) + if bounds_error: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} bounds " + + bounds_error + ) + + binding = ( + elem.get('data-pptx-placeholder-binding') or 'carrier' + ).strip().lower() + if binding not in {'carrier', 'proxy'}: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} has unknown " + f"binding {binding!r}; use carrier or proxy" + ) + carrier_descendants = [ + child for child in elem.iter() + if child is not elem + and child.get('data-pptx-placeholder-carrier') is not None + ] + visual_children = [ + child for child in list(elem) + if child.tag.rsplit('}', 1)[-1] not in _NON_VISUAL_SVG_TAGS + ] + direct_carriers = [ + child for child in visual_children + if (child.get('data-pptx-placeholder-carrier') or '').strip().lower() + == 'true' + ] + nested_carriers = [ + child for child in carrier_descendants + if parent_by_id.get(id(child)) is not elem + ] + if nested_carriers: + names = ', '.join( + child.get('id') or f"<{child.tag.rsplit('}', 1)[-1]}>" + for child in nested_carriers + ) + errors.append( + f"{svg_path.name}: placeholder slot {element_id} has nested " + f"carrier marker(s): {names}; the carrier must be a direct child" + ) + if binding == 'carrier': + if len(visual_children) != 1 or len(direct_carriers) != 1: + errors.append( + f"{svg_path.name}: placeholder slot {element_id} requires " + "exactly one visual direct child, marked " + "data-pptx-placeholder-carrier=\"true\"" + ) + if binding == 'proxy': + if placeholder != 'object': + errors.append( + f"{svg_path.name}: proxy binding is allowed only for an " + f"object placeholder, not {placeholder!r}" + ) + if carrier_descendants: + errors.append( + f"{svg_path.name}: proxy placeholder slot {element_id} must " + "not declare a visible placeholder carrier" + ) + if not visual_children: + errors.append( + f"{svg_path.name}: proxy placeholder slot {element_id} must " + "contain visible Slide-local content" + ) + + carrier_value = elem.get('data-pptx-placeholder-carrier') + if carrier_value is not None: + if carrier_value.strip().lower() != 'true': + errors.append( + f"{svg_path.name}: {element_id} " + "data-pptx-placeholder-carrier must equal true" + ) + if parent is None or not ( + parent.get('data-pptx-placeholder') or '' + ).strip(): + errors.append( + f"{svg_path.name}: placeholder carrier {element_id} must be a " + "direct child of a root placeholder slot" + ) + + if tag in _NON_VISUAL_SVG_TAGS and (layer or placeholder): + errors.append( + f"{svg_path.name}: non-visual {element_id} cannot carry " + "Master/Layout/placeholder ownership" + ) + + return list(dict.fromkeys(errors)) + + def _normalize_hex_rgb(value: str) -> str | None: """Normalize 3/4/6/8-digit HEX to alpha-free ``RRGGBB``.""" if not HEX_VALUE_RE.fullmatch(value): @@ -456,6 +764,12 @@ class SVGQualityChecker: # produce misleading errors on a broken document. root = self._parse_xml_root(content, result) if root is not None: + if root.get('transform'): + result['errors'].append( + 'Root transform is unsupported; apply transforms ' + 'to child elements or groups' + ) + # 1. Check viewBox self._check_viewbox(root, result, expected_format) @@ -486,6 +800,10 @@ class SVGQualityChecker: # 7b. Reject visual elements the native converter cannot dispatch. self._check_unsupported_visual_elements(root, result) + # 7c. Fail closed on invalid PPTX preset/adjustment metadata. + self._check_preset_geometry_metadata(root, result) + self._check_preset_geometry_transforms(root, result) + # 8. Check object-level animation anchor quality. self._check_animation_group_ids(root, result) @@ -857,7 +1175,8 @@ class SVGQualityChecker: f"<{tag} opacity> must be a numeric value from 0 to 1, got {raw_opacity!r}" ) if tag == 'g' and opacity < 1.0 and any( - descendant.get('data-pptx-native') + descendant.tag.rsplit('}', 1)[-1] != 'metadata' + and descendant.get('data-pptx-native') for descendant in elem.iter() ): result['warnings'].append( @@ -1264,6 +1583,172 @@ class SVGQualityChecker: f"{preview}{suffix}" ) + def _check_preset_geometry_metadata( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Validate round-trip preset metadata with the exporter's parser.""" + marked = [ + elem + for elem in root.iter() + if ( + elem.get('data-pptx-prst') is not None + or elem.get('data-pptx-frame') is not None + or elem.get('data-pptx-geometry-status') is not None + or elem.get('data-pptx-geometry-reason') is not None + or elem.get('data-pptx-geometry-kind') is not None + or elem.get('data-pptx-custgeom') is not None + or elem.get('data-pptx-preview-sha256') is not None + or elem.get('data-pptx-shape-id') is not None + or elem.get('data-pptx-shape-scope') is not None + or elem.get('data-pptx-shape-style') is not None + or elem.get(_AUTHORING_ATTR) is not None + or any(attr.startswith('data-pptx-av-') for attr in elem.attrib) + ) + ] + if not marked: + return + if _validate_preset_geometry_metadata is None: + result['errors'].append( + 'Unable to import PPTX preset metadata validator; ' + 'cannot verify native shape restoration' + ) + return + + issues = set() + for elem in marked: + tag = _local_name(elem) + elem_id = elem.get('id') + label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>' + for error in _validate_preset_geometry_metadata(elem): + issues.add(f'{label} has invalid PPTX shape metadata: {error}') + if _validate_authored_preset_tree is None: + if any( + elem.get(_AUTHORING_ATTR) is not None + for elem in root.iter() + ): + issues.add( + 'Unable to import authored PPTX preset validator' + ) + else: + for error in _validate_authored_preset_tree(root): + issues.add(f'Invalid authored PPTX preset: {error}') + if ( + _svg_preset_preview_fingerprint is None + or _resolve_preset_preview_hash is None + ): + issues.add('Unable to import PPTX preset preview fingerprint validator') + else: + for elem in root.iter(): + if ( + _local_name(elem) != 'g' + or elem.get('data-pptx-object') not in {'shape', 'connector'} + or elem.get('data-pptx-prst') is None + ): + continue + try: + expected = _resolve_preset_preview_hash(elem) + except ValueError as exc: + elem_id = elem.get('id') or '(no id)' + issues.add( + f' has an invalid PPTX preset ' + f'preview contract: {exc}' + ) + continue + if expected is None: + continue + actual = _svg_preset_preview_fingerprint(elem) + if actual != expected: + elem_id = elem.get('id') or '(no id)' + issues.add( + f' has a stale PPTX preset preview; ' + 'update the native carrier or restore the generated detail paths' + ) + result['errors'].extend(sorted(issues)) + + def _check_preset_geometry_transforms( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Reject preset transforms that DrawingML cannot represent exactly.""" + helpers = ( + _IDENTITY_MATRIX, + _matrix_multiply, + _parse_transform_matrix, + _rect_to_dml_xfrm, + _validate_dml_shape_matrix, + ) + if any(helper is None for helper in helpers): + return + + relevant: set[ET.Element] = set() + + def mark_relevant(element: ET.Element) -> bool: + found = element.get('data-pptx-prst') is not None + for child in element: + found = mark_relevant(child) or found + if found: + relevant.add(element) + return found + + mark_relevant(root) + issues = set() + + def visit(element: ET.Element, parent_matrix) -> None: + if element not in relevant: + return + matrix = parent_matrix + transform = element.get('transform') + if transform: + try: + local_matrix = _parse_transform_matrix(transform) + matrix = _matrix_multiply(parent_matrix, local_matrix) + except ValueError as exc: + issues.add( + f'<{_local_name(element)}> has invalid preset ' + f'transform: {exc}' + ) + return + if element.get('data-pptx-prst') is not None: + try: + raw_frame = element.get('data-pptx-frame') + if raw_frame: + frame = tuple( + float(part) + for part in re.split(r'[\s,]+', raw_frame.strip()) + ) + if len(frame) != 4: + raise ValueError( + 'data-pptx-frame must contain four numbers' + ) + preset = element.get('data-pptx-prst') or '' + _rect_to_dml_xfrm( + frame[0], + frame[1], + frame[2], + frame[3], + matrix, + preserve_degenerate_axes=( + element.get('data-pptx-object') == 'connector' + or preset in _CONNECTOR_PRESET_TYPES + ), + ) + else: + _validate_dml_shape_matrix(matrix) + except ValueError as exc: + elem_id = element.get('id') or '(no id)' + issues.add( + f'<{_local_name(element)} id="{elem_id}"> has ' + f'unsupported preset transform: {exc}' + ) + for child in element: + visit(child, matrix) + + visit(root, _IDENTITY_MATRIX) + result['errors'].extend(sorted(issues)) + def _check_animation_group_ids(self, root: ET.Element, result: Dict): """Warn when visible top-level groups cannot be customized.""" non_visual = {'defs', 'title', 'desc', 'metadata', 'style'} @@ -1335,10 +1820,55 @@ class SVGQualityChecker: def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None: """Validate opt-in native table/chart markers before PPTX export.""" + invalid_status_elements: set[ET.Element] = set() + for elem in root.iter(): + if elem.tag.rsplit('}', 1)[-1] == 'metadata': + continue + has_status = any( + elem.get(name) is not None + for name in ( + 'data-pptx-visual-status', + 'data-pptx-route-status', + 'data-pptx-native-status', + ) + ) + if not has_status: + continue + marker_id = elem.get('id') or elem.get('data-name') or '' + if ( + _native_marker_status_errors is None + or _native_marker_release_block_reason is None + ): + result['errors'].append( + "Unable to import native-object status validator; " + f"cannot verify PPTX graphic {marker_id}" + ) + continue + status_errors = _native_marker_status_errors(elem) + for error in status_errors: + result['errors'].append( + f"PPTX graphic {marker_id} has invalid status metadata: {error}" + ) + if status_errors: + invalid_status_elements.add(elem) + continue + if elem.get('data-pptx-route-status') == 'reconstruction-only': + route = ( + "--native-objects may reconstruct its active native marker" + if (elem.get('data-pptx-native') or '').strip() + else "default export keeps the visible placeholder" + ) + result['warnings'].append( + f"PPTX graphic {marker_id} is a reconstruction-only placeholder; " + f"it has no baked preview and {route}" + ) + for elem in root.iter(): status = elem.get('data-pptx-native-status') if not status or elem.tag.rsplit('}', 1)[-1] == 'metadata': continue + if elem.get('data-pptx-native'): + continue marker_id = elem.get('id') or elem.get('data-name') or '' result['warnings'].append( f"Native PPTX object {marker_id} is fallback-only: {status}" @@ -1346,7 +1876,11 @@ class SVGQualityChecker: markers = [ elem for elem in root.iter() - if elem.get('data-pptx-native') and elem.tag.rsplit('}', 1)[-1] != 'metadata' + if ( + elem.get('data-pptx-native') + and elem.tag.rsplit('}', 1)[-1] != 'metadata' + and elem not in invalid_status_elements + ) ] if not markers: return @@ -1377,6 +1911,7 @@ class SVGQualityChecker: warnings = _validate_native_object_marker_with_warnings( marker, ancestors=ancestors_tuple, + document_root=root, ) except RuntimeError as exc: result['errors'].append( @@ -1401,6 +1936,7 @@ class SVGQualityChecker: for warning in _native_object_marker_warnings( marker, ancestors=ancestors_tuple, + document_root=root, ): result['warnings'].append( f"data-pptx-native marker {marker_id}: {warning}" @@ -1412,29 +1948,84 @@ class SVGQualityChecker: svg_path: Path, result: Dict, ) -> None: - """Validate explicit template-export metadata when any is present.""" - structure_attrs = { - 'data-pptx-layer', - 'data-pptx-layout', - 'data-pptx-layout-name', - 'data-pptx-placeholder', - 'data-pptx-placeholder-bounds', - 'data-pptx-placeholder-idx', - 'data-pptx-editable', - } - if not any( + """Validate the intrinsic structured Master/Layout SVG contract.""" + has_structure_metadata = any( elem.get(attr) is not None for elem in root.iter() - for attr in structure_attrs - ): + for attr in _PPTX_STRUCTURE_ATTRS + ) + require_structure = bool( + self.template_mode + or svg_path.parent.name == 'svg_output' + ) + if not has_structure_metadata and not require_structure: return + result['errors'].extend(_local_pptx_structure_errors( + root, + svg_path, + require_structure=require_structure, + )) + if svg_path.parent.name == 'svg_output': + self._append_structure_coverage_warnings(root, result) if _validate_template_structure_svg is None: - result['warnings'].append( - "Detected PPTX template structure metadata, but its validator " - "could not be imported; template export will still validate it." + result['errors'].append( + "Structured PPTX metadata validator could not be imported; " + "the quality gate cannot verify this SVG" ) return result['errors'].extend(_validate_template_structure_svg(svg_path)) + result['errors'] = list(dict.fromkeys(result['errors'])) + + @staticmethod + def _append_structure_coverage_warnings( + root: ET.Element, + result: Dict, + ) -> None: + """Warn on mapped pages that compile to bare Masters / empty Layouts. + + Zero-slot and framing-only Layouts are legal contracts, so these stay + warnings; the workflow gate requires each one to be fixed or + explicitly kept with a stated reason. + """ + if not (root.get('data-pptx-layout') or '').strip(): + return + has_layer_mark = any( + elem.get('data-pptx-layer') is not None + for elem in root.iter() + ) + has_layout_atom = any( + child.get('data-pptx-layer') == 'layout' + for child in list(root) + ) + has_placeholder = any( + elem.get('data-pptx-placeholder') is not None + for elem in root.iter() + ) + if not has_layer_mark: + result['warnings'].append( + 'Mapped page declares data-pptx-layout but no data-pptx-layer ' + 'mark; the exported Master gets no shared background/chrome ' + 'and the Layout gets no static framing. Mark the deck-wide ' + 'background data-pptx-layer="master" and this layout key\'s ' + 'framing data-pptx-layer="layout".' + ) + if not has_placeholder and not has_layout_atom: + result['warnings'].append( + 'Mapped page has no placeholder slot and no ' + 'data-pptx-layer="layout" atom; its Layout exports empty. ' + 'Declare the slots the page actually has (title / subtitle / ' + 'body / picture / slide-number / footer) and mark the layout ' + 'key\'s static framing, or state why this fixed composition ' + 'is intentionally zero-slot.' + ) + elif not has_placeholder: + result['warnings'].append( + 'Mapped Layout has static framing but no insertable ' + 'placeholder slot. Keep it only when zero-slot is the ' + 'intended reusable contract; otherwise declare the slots the ' + 'page actually has (title / subtitle / body / picture / ' + 'slide-number / footer).' + ) def _check_semantic_markers( self, @@ -1829,11 +2420,11 @@ class SVGQualityChecker: print(f"[ERROR] Directory does not exist: {directory}") return [] - # Brand-only template directories (templates/brands//) have no SVG - # roster — design_spec.md frontmatter declares `kind: brand`. Skip SVG - # checks entirely; brand validation lives in register_template.py. + # Brand-only template workspaces have no SVG roster. Resolve the current + # nested spec first and keep legacy-flat roots readable. if self.template_mode and dir_path.is_dir(): - spec = dir_path / 'design_spec.md' + nested_spec = dir_path / 'templates' / 'design_spec.md' + spec = nested_spec if nested_spec.is_file() else dir_path / 'design_spec.md' if spec.exists() and _design_spec_is_brand(spec): print( f"[INFO] Brand directory detected (kind: brand) — " @@ -1883,62 +2474,12 @@ class SVGQualityChecker: target_path: Path, svg_files: List[Path], ) -> None: - """Validate project-level layout reuse and spec_lock mappings.""" - if ( - _load_pptx_structure_lock is None - or _parse_template_structure_slides is None - or _template_lock_errors is None - or _TemplateStructureError is None - ): - return + """Validate the all-page structured lock and reusable contracts.""" project_path = self._resolve_project_path(target_path) - try: - structure_lock = _load_pptx_structure_lock(project_path) - except _TemplateStructureError as exc: - self._pptx_structure_issues.append(('error', str(exc))) - return - - if ( - structure_lock is not None - and structure_lock.mode == 'template' - and structure_lock.template_adherence in {'strict', 'adaptive'} - and _parse_spec_lock is not None - ): - lock_path = project_path / 'spec_lock.md' - lock = _parse_spec_lock(lock_path) - page_layouts = lock.get('page_layouts', {}) - expected_pages = {f'P{index:02d}' for index in range(1, len(svg_files) + 1)} - missing_pages = sorted(expected_pages - set(page_layouts)) - if missing_pages: - self._pptx_structure_issues.append(( - 'error', - 'spec_lock.md template export requires page_layouts ' - 'rows for every generated page; missing: ' + ', '.join(missing_pages), - )) - template_dir = project_path / 'templates' - template_svgs = {path.stem for path in template_dir.glob('*.svg')} - missing_templates = sorted({ - value.strip().removesuffix('.svg') - for page, value in page_layouts.items() - if page in expected_pages - and value.strip().removesuffix('.svg') not in template_svgs - }) - if missing_templates: - self._pptx_structure_issues.append(( - 'error', - 'spec_lock.md template export references missing ' - 'template SVG(s): ' + ', '.join(missing_templates), - )) - - structure_attrs = { - 'data-pptx-layer', - 'data-pptx-layout', - 'data-pptx-layout-name', - 'data-pptx-placeholder', - 'data-pptx-placeholder-bounds', - 'data-pptx-placeholder-idx', - 'data-pptx-editable', - } + standard_project = bool( + not self.template_mode + and (project_path / 'svg_output').is_dir() + ) has_metadata = False for svg_path in svg_files: try: @@ -1948,54 +2489,322 @@ class SVGQualityChecker: if any( elem.get(attr) is not None for elem in root.iter() - for attr in structure_attrs + for attr in _PPTX_STRUCTURE_ATTRS ): has_metadata = True break - locked_mode = structure_lock.mode if structure_lock is not None else None - structure_locked = locked_mode in {'template', 'preserve'} - if not has_metadata and not structure_locked: + + if not standard_project and not self.template_mode and not has_metadata: + return + if ( + _load_pptx_structure_lock is None + or _parse_template_structure_slide is None + or _parse_template_structure_slides is None + or _structure_subtree_signature is None + or _template_lock_errors is None + or _TemplateStructureError is None + ): + self._pptx_structure_issues.append(( + 'error', + 'Structured PPTX project validation is unavailable because the ' + 'template_structure module could not be imported.', + )) + return + + if self.template_mode: + try: + specs = _parse_template_structure_slides(svg_files) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + self._pptx_structure_issues.extend( + ('error', message) + for message in self._shared_fixed_layer_errors(specs) + ) + self._pptx_structure_issues.extend( + ('warning', message) + for message in self._duplicate_layout_key_warnings(specs) + ) + return + + declared_mode = _declared_pptx_structure_mode(project_path) + if standard_project and declared_mode != 'structured': + label = repr(declared_mode) if declared_mode else ( + 'missing (legacy implicit baseline)' + ) + self._pptx_structure_issues.append(( + 'error', + 'release SVG projects require spec_lock.md ' + f'pptx_structure.mode: structured; found {label}. Follow ' + 'skills/ppt-master/workflows/restore-pptx-structure.md before export.', + )) return try: - if locked_mode == 'preserve': - if _parse_preserve_structure_slides is None: - return - specs = _parse_preserve_structure_slides(svg_files) + structure_lock = _load_pptx_structure_lock(project_path) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + if structure_lock is None or structure_lock.mode != 'structured': + self._pptx_structure_issues.append(( + 'error', + 'spec_lock.md must contain one complete ' + 'pptx_structure.mode: structured contract.', + )) + return + complete_roster = target_path.is_dir() + try: + if not complete_roster and target_path.is_file(): + sibling_files = sorted(target_path.parent.glob('*.svg')) + resolved_target = target_path.resolve() + slide_num = next( + ( + index + for index, sibling in enumerate(sibling_files, start=1) + if sibling.resolve() == resolved_target + ), + 1, + ) + specs = [ + _parse_template_structure_slide(target_path, slide_num) + ] else: specs = _parse_template_structure_slides(svg_files) except _TemplateStructureError as exc: self._pptx_structure_issues.append(('error', str(exc))) return - if not structure_locked: - return - self._pptx_structure_issues.extend( - ('error', message) - for message in _template_lock_errors(specs, structure_lock) - ) - if locked_mode != 'preserve': - return - if ( - _load_native_structure_contract is None - or _native_structure_lock_errors is None - ): - self._pptx_structure_issues.append(( - 'error', - 'Preserve mode validator could not load the native structure contract.', - )) - return - try: - contract = _load_native_structure_contract(structure_lock) - except _TemplateStructureError as exc: - self._pptx_structure_issues.append(('error', str(exc))) - return - self._pptx_structure_issues.extend( - ('error', message) - for message in _native_structure_lock_errors( - specs, - structure_lock, - contract, + + if complete_roster: + self._pptx_structure_issues.extend( + ('error', message) + for message in _template_lock_errors(specs, structure_lock) ) + else: + self._pptx_structure_issues.extend( + ('error', message) + for message in self._partial_structure_lock_errors( + specs, + structure_lock, + ) + ) + if _template_prototype_errors is not None: + self._pptx_structure_issues.extend( + ('error', message) + for message in _template_prototype_errors( + specs, + structure_lock, + require_complete_roster=complete_roster, + ) + ) + self._pptx_structure_issues.extend( + ('error', message) + for message in self._shared_fixed_layer_errors(specs) + ) + self._pptx_structure_issues.extend( + ('warning', message) + for message in self._duplicate_layout_key_warnings(specs) + ) + + @staticmethod + def _partial_structure_lock_errors(specs, structure_lock) -> List[str]: + """Compare explicitly checked pages without requiring the full roster.""" + references = { + reference.slide_num: reference + for reference in structure_lock.layouts + } + master_names = { + master.master_key: master.master_name + for master in structure_lock.masters + } + errors: List[str] = [] + for spec in specs: + page = f"P{spec.slide_num:02d}" + reference = references.get(spec.slide_num) + if reference is None: + errors.append(f"spec_lock.md pptx_layouts is missing {page}") + continue + if spec.master_key != reference.master_key: + errors.append( + f"{spec.svg_path.name}: data-pptx-master={spec.master_key!r} " + f"does not match spec_lock {page} Master key " + f"{reference.master_key!r}" + ) + if spec.layout_key != reference.layout_key: + errors.append( + f"{spec.svg_path.name}: data-pptx-layout={spec.layout_key!r} " + f"does not match spec_lock {page} layout key " + f"{reference.layout_key!r}" + ) + if spec.layout_name != reference.layout_name: + errors.append( + f"{spec.svg_path.name}: data-pptx-layout-name=" + f"{spec.layout_name!r} does not match spec_lock {page} " + f"layout name {reference.layout_name!r}" + ) + expected_master_name = master_names.get(spec.master_key) + if expected_master_name != spec.master_name: + errors.append( + f"{spec.svg_path.name}: data-pptx-master-name=" + f"{spec.master_name!r} does not match spec_lock Master " + f"{spec.master_key!r} name {expected_master_name!r}" + ) + return errors + + def _duplicate_layout_key_warnings(self, specs) -> List[str]: + """Flag distinct layout keys whose static contracts are identical. + + Keys split by page topic over one shared skeleton compile into + duplicate PowerPoint Layouts; the fingerprint compares the + id-insensitive layout-layer drawing plus the placeholder contract. + """ + prototypes: Dict[Tuple[str, str], Path] = {} + for spec in specs: + prototypes.setdefault( + (getattr(spec, 'master_key', ''), spec.layout_key), + spec.svg_path, + ) + if len(prototypes) < 2: + return [] + fingerprint_keys: Dict[tuple, List[str]] = {} + for (master_key, layout_key), svg_path in prototypes.items(): + fingerprint = self._layout_contract_fingerprint(svg_path) + if fingerprint is None: + continue + fingerprint_keys.setdefault( + (master_key, fingerprint), + [], + ).append(layout_key) + messages = [] + for keys in fingerprint_keys.values(): + if len(keys) < 2: + continue + joined = ', '.join(sorted(keys)) + messages.append( + f"layout keys {joined} declare identical static Layout framing " + "and placeholder contracts; they compile to duplicate Layouts. " + "Either merge them into one reusable key (spec_lock.md " + "pptx_layouts + each SVG root), or — when their reusable " + "contracts genuinely differ — assign distinct explicit default " + "placeholder bounds and/or mark only truly stable framing as " + 'data-pptx-layer="layout". Slide-local content geometry does not ' + "define a Layout." + ) + return messages + + @classmethod + def _shared_fixed_layer_errors(cls, specs) -> List[str]: + """Reject fixed atoms whose payload varies inside one reuse scope.""" + master_groups = defaultdict(list) + layout_groups = defaultdict(list) + for spec in specs: + master_groups[spec.master_key].append(spec) + layout_groups[(spec.master_key, spec.layout_key)].append(spec) + + try: + errors = cls._fixed_layer_group_errors(master_groups, 'master') + errors.extend(cls._fixed_layer_group_errors(layout_groups, 'layout')) + except _TemplateStructureError as exc: + return [str(exc)] + return errors + + @classmethod + def _fixed_layer_group_errors(cls, groups, layer: str) -> List[str]: + """Compare fixed atom payloads across grouped slide specifications.""" + errors = [] + for scope_key, group_specs in groups.items(): + if len(group_specs) < 2: + continue + variants = defaultdict(lambda: defaultdict(list)) + for spec in group_specs: + payloads = cls._fixed_layer_payloads(spec, layer) + for element_id, payload in payloads.items(): + variants[element_id][payload].append(spec) + for element_id, payload_specs in variants.items(): + if len(payload_specs) < 2: + continue + slide_names = ', '.join( + spec.svg_path.name + for spec in sorted(group_specs, key=lambda item: item.slide_num) + ) + if layer == 'master': + scope = f"Master {scope_key!r}" + else: + master_key, layout_key = scope_key + scope = ( + f"Layout {layout_key!r} under Master {master_key!r}" + ) + if element_id is None: + subject = "fixed visual resources" + verb = "differ" + else: + subject = f"fixed element {element_id!r}" + verb = "differs" + errors.append( + f"{scope} {subject} {verb} across slides: " + f"{slide_names}. Values marked data-pptx-layer={layer!r} must " + "remain identical throughout their reuse scope; move variable " + "text or images into a placeholder slot or keep them Slide-local." + ) + return errors + + @staticmethod + def _fixed_layer_payloads(spec, layer: str) -> Dict[object, tuple]: + """Return resolved fixed-layer visual payloads keyed by SVG id.""" + elements = ( + spec.master_elements if layer == 'master' else spec.layout_elements + ) + if not elements: + return {} + signature = _structure_subtree_signature( + spec.svg_path, + elements, + include_skin=True, + include_text=True, + asset_identity=True, + ) + return { + None if element_id == '__visual_resources__' else element_id: payload + for element_id, payload in signature + } + + @staticmethod + def _layout_contract_fingerprint(svg_path: Path): + """Id-insensitive static contract: layout-layer XML + placeholder slots.""" + try: + root = ET.parse(str(svg_path)).getroot() + except (OSError, ET.ParseError): + return None + layout_parts = [] + placeholder_parts = [] + for child in list(root): + if child.get('data-pptx-layer') == 'layout': + clone = copy.deepcopy(child) + for elem in clone.iter(): + elem.attrib.pop('id', None) + xml = ET.tostring(clone, encoding='unicode') + layout_parts.append(re.sub(r'\s+', ' ', xml).strip()) + placeholder = child.get('data-pptx-placeholder') + if placeholder is not None: + carrier_tags = tuple( + grandchild.tag.rsplit('}', 1)[-1] + for grandchild in list(child) + if ( + grandchild.get('data-pptx-placeholder-carrier') or '' + ).strip().lower() == 'true' + ) + placeholder_parts.append(( + placeholder, + child.tag.rsplit('}', 1)[-1], + child.get('data-pptx-placeholder-bounds') or '', + child.get('data-pptx-placeholder-idx') or '', + ( + child.get('data-pptx-placeholder-binding') or 'carrier' + ).strip().lower(), + carrier_tags, + )) + return ( + tuple(layout_parts), + tuple(sorted(placeholder_parts)), ) def _check_illustration_resource_contract(self, dir_path: Path) -> None: @@ -2098,11 +2907,18 @@ class SVGQualityChecker: @staticmethod def _resolve_project_path(dir_path: Path) -> Path: """Resolve a checker target directory to its project root.""" - if _project_root_for_svg_path is not None and dir_path.name in _SVG_WORK_DIR_NAMES: - return _project_root_for_svg_path(dir_path) - if (dir_path / 'svg_output').exists() or (dir_path / 'design_spec.md').exists(): - return dir_path - return dir_path.parent + candidate = dir_path.parent if dir_path.is_file() else dir_path + if ( + _project_root_for_svg_path is not None + and candidate.name in _SVG_WORK_DIR_NAMES + ): + return _project_root_for_svg_path(candidate) + if ( + (candidate / 'svg_output').exists() + or (candidate / 'design_spec.md').exists() + ): + return candidate + return candidate.parent @staticmethod def _split_md_table_row(line: str) -> List[str]: @@ -2413,7 +3229,7 @@ class SVGQualityChecker: - **Roster mismatch (orphan / missing)** is reported as an *error*: a stale roster will produce a wrong ``layouts_index.json`` entry. - **Explicit structure gaps** are errors: every reusable SVG declares - its Layout and at least one direct structure/placeholder element. + its Master and Layout identity. Zero-placeholder Layouts are valid. - **Placeholder gaps** are reported as *warnings*. Templates may legitimately omit conventional placeholders or swap them out (e.g. ``{{CLOSING_MESSAGE}}`` instead of ``{{THANK_YOU}}``), and a content @@ -2426,12 +3242,28 @@ class SVGQualityChecker: """ native_contract_path = dir_path / 'native_structure.json' source_template_path = dir_path / 'source_template.pptx' - native_contract = None + legacy_structure_detected = False for svg_file in svg_files: try: root = ET.parse(svg_file).getroot() except (OSError, ET.ParseError): continue + if not root.get('data-pptx-master'): + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'explicit_master_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-master metadata", + )) + if not root.get('data-pptx-master-name'): + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'explicit_master_name_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-master-name metadata", + )) if not root.get('data-pptx-layout'): self._template_issues.append(( 'error', @@ -2446,96 +3278,44 @@ class SVGQualityChecker: f"{svg_file.name}: reusable templates require root " "data-pptx-layout-name metadata", )) - has_structure_child = any( - child.get('data-pptx-layer') in {'master', 'layout'} - or child.get('data-pptx-placeholder') - for child in list(root) - ) - if not has_structure_child: + if root.get('data-pptx-layout-kind') is not None: + legacy_structure_detected = True self._template_issues.append(( 'error', - 'explicit_structure_empty', - f"{svg_file.name}: reusable templates require at least one " - "direct master/layout/placeholder declaration", + 'deck_instance_layout_kind', + f"{svg_file.name}: reusable template prototypes must omit " + "legacy data-pptx-layout-kind metadata", )) - if native_contract_path.exists() != source_template_path.exists(): + if any( + child.get('data-pptx-placeholder') is not None + and child.tag.rsplit('}', 1)[-1] != 'g' + for child in list(root) + ): + legacy_structure_detected = True + missing_bounds = [ + child.get('id') or child.tag.rsplit('}', 1)[-1] + for child in list(root) + if child.get('data-pptx-placeholder') is not None + and child.get('data-pptx-placeholder-bounds') is None + ] + if missing_bounds: + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'placeholder_bounds_missing', + f"{svg_file.name}: reusable templates require " + "explicit design-zone data-pptx-placeholder-bounds; missing: " + + ', '.join(missing_bounds), + )) + if native_contract_path.exists() or source_template_path.exists(): + legacy_structure_detected = True self._template_issues.append(( 'error', - 'native_structure_pair', - "native_structure.json and source_template.pptx must be shipped together", + 'legacy_native_structure_pair', + "legacy native_structure.json/source_template.pptx template " + "contracts must be restored through " + "skills/ppt-master/workflows/restore-pptx-structure.md", )) - elif native_contract_path.exists(): - if ( - _PptxStructureLock is None - or _load_native_structure_contract is None - or _TemplateStructureError is None - ): - self._template_issues.append(( - 'error', - 'native_structure_validator', - "native structure validator is unavailable", - )) - else: - try: - native_contract = _load_native_structure_contract(_PptxStructureLock( - mode='preserve', - source_template=source_template_path, - native_structure=native_contract_path, - )) - except _TemplateStructureError as exc: - self._template_issues.append(( - 'error', - 'native_structure_invalid', - str(exc), - )) - if ( - native_contract is not None - and _PptxLayoutReference is not None - and _parse_preserve_structure_slides is not None - and _template_lock_errors is not None - and _native_structure_lock_errors is not None - ): - try: - native_specs = _parse_preserve_structure_slides(svg_files) - native_layouts = { - layout.key: layout for layout in native_contract.layouts - } - references = tuple( - _PptxLayoutReference( - slide_num=spec.slide_num, - layout_key=spec.layout_key, - layout_name=( - native_layouts[spec.layout_key].name - if spec.layout_key in native_layouts - else spec.layout_name - ), - ) - for spec in native_specs - ) - native_lock = _PptxStructureLock( - mode='preserve', - layouts=references, - source_template=source_template_path, - native_structure=native_contract_path, - ) - native_errors = _template_lock_errors(native_specs, native_lock) - native_errors.extend(_native_structure_lock_errors( - native_specs, - native_lock, - native_contract, - )) - for message in native_errors: - self._template_issues.append(( - 'error', - 'native_structure_svg', - message, - )) - except _TemplateStructureError as exc: - self._template_issues.append(( - 'error', - 'native_structure_svg', - str(exc), - )) spec_path = dir_path / 'design_spec.md' spec_text = spec_path.read_text(encoding='utf-8') if spec_path.exists() else "" @@ -2545,12 +3325,21 @@ class SVGQualityChecker: re.MULTILINE, ) declared_structure_mode = mode_match.group(1).lower() if mode_match else None - if native_contract is None and declared_structure_mode != 'template': + if declared_structure_mode != 'structured': + legacy_structure_detected = True self._template_issues.append(( 'error', 'explicit_structure_mode', "design_spec.md frontmatter must declare " - "native_structure_mode: template", + "native_structure_mode: structured", + )) + if legacy_structure_detected: + self._template_issues.append(( + 'error', + 'legacy_structure_contract', + "legacy template structure detected; run " + "skills/ppt-master/workflows/restore-pptx-structure.md before " + "Step 3 consumption", )) spec_pages = self._extract_spec_roster(spec_text) if spec_text else [] custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {} @@ -2922,8 +3711,10 @@ class SVGQualityChecker: for _sev, kind, msg in warnings: print(f" [{kind}] {msg}") if not errors: - print(" No structural roster issues. Placeholder hints above are advisory only;") - print(" declare 'placeholders:' frontmatter in design_spec.md to silence them.") + print(" No structural roster issues.") + print(" Conventional placeholder-name hints may be declared through " + "'placeholders:' frontmatter. Placeholder bounds are mandatory " + "design-zone metadata.") def _apply_aggregated_issue_counts(self): """Mirror project-level aggregate issues into summary counters once.""" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py index 7d0d4f71..4d677e87 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py @@ -33,6 +33,12 @@ class ConvertContext: defs: dict[str, ET.Element] = field(default_factory=dict) id_counter: int = 2 # 1 is reserved for spTree root + # Imported PPTX shape ids are reserved before conversion so newly authored + # SVG elements cannot steal an id referenced by a native connector. + reserved_shape_ids: frozenset[int] = frozenset() + source_shape_id_map: dict[tuple[str, str], int] = field(default_factory=dict) + claimed_shape_ids: set[int] = field(default_factory=set) + referenced_shape_ids: set[int] = field(default_factory=set) slide_num: int = 1 translate_x: float = 0.0 translate_y: float = 0.0 @@ -88,9 +94,49 @@ class ConvertContext: def next_id(self) -> int: """Allocate the next shape ID.""" cid = self.id_counter - self.id_counter += 1 + while cid in self.reserved_shape_ids or cid in self.claimed_shape_ids: + cid += 1 + self.id_counter = cid + 1 + self.claimed_shape_ids.add(cid) return cid + def claim_shape_id( + self, + source_id: str | None, + source_scope: str | None = None, + ) -> int: + """Claim a pre-reserved imported shape id, or allocate a fresh one.""" + if source_id is None: + return self.next_id() + scope = source_scope or 'slide' + key = (scope, source_id) + shape_id = self.source_shape_id_map.get(key) + if shape_id is None: + raise ValueError( + f'Unreserved data-pptx-shape-id {source_id!r} in scope {scope!r}' + ) + if shape_id in self.claimed_shape_ids: + raise ValueError( + f'Duplicate data-pptx-shape-id {source_id!r} in scope {scope!r}' + ) + self.claimed_shape_ids.add(shape_id) + return shape_id + + def reference_shape_id( + self, + source_id: str, + source_scope: str | None = None, + ) -> int: + """Resolve and record a connector target in the imported id space.""" + scope = source_scope or 'slide' + shape_id = self.source_shape_id_map.get((scope, source_id)) + if shape_id is None: + raise ValueError( + f'Unknown connector shape reference {source_id!r} in scope {scope!r}' + ) + self.referenced_shape_ids.add(shape_id) + return shape_id + def next_rel_id(self) -> str: """Allocate the next relationship ID (rIdN).""" rid = f'rId{self.rel_id_counter}' @@ -155,6 +201,10 @@ class ConvertContext: return ConvertContext( defs=self.defs, id_counter=self.id_counter, + reserved_shape_ids=self.reserved_shape_ids, + source_shape_id_map=self.source_shape_id_map, + claimed_shape_ids=self.claimed_shape_ids, + referenced_shape_ids=self.referenced_shape_ids, slide_num=self.slide_num, translate_x=self.translate_x + dx, translate_y=self.translate_y + dy, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py index d152952f..1a969d47 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py @@ -2,12 +2,28 @@ from __future__ import annotations +import base64 +import binascii +import hashlib import math import re from pathlib import Path from typing import Any from xml.etree import ElementTree as ET +from pptx_shapes import ( + has_relationship_attributes, + resolve_preset_preview_hash, + svg_preset_preview_fingerprint, + svg_text_fingerprint, + validate_ooxml_xfrm, +) +from pptx_to_svg.preset_authoring import ( + AUTHORING_ATTR, + AUTHORING_VALUE, + validate_authored_preset_group, + validate_authored_preset_tree, +) from resource_paths import icon_search_dirs_for_svg from .context import ConvertContext, ShapeResult @@ -29,7 +45,11 @@ from .elements import ( convert_text, convert_image, convert_nested_svg, ) from ..animation_config import is_chrome_id -from ..native_objects import convert_native_object, native_marker_transform +from ..native_objects import ( + convert_native_object, + native_marker_transform, + snapshot_native_fallback_freshness, +) from ..semantic_markers import is_static_page_frame @@ -128,6 +148,125 @@ def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None: return cx, cy +def _txbody_metadata(elem: ET.Element) -> ET.Element | None: + for child in elem: + if ( + child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata' + and child.get('data-pptx-part') == 'txbody' + ): + return child + return None + + +_TXBODY_UNCHANGED_ATTR = 'data-pptx-runtime-txbody-unchanged' +_PREVIEW_UNCHANGED_ATTR = 'data-pptx-runtime-preview-unchanged' + + +def _mark_unchanged_txbody_groups(root: ET.Element) -> None: + """Snapshot author-visible text state before exporter preprocessing.""" + for group in root.iter(): + if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g': + continue + metadata = _txbody_metadata(group) + if metadata is None: + continue + expected = metadata.get('data-pptx-text-sha256') + actual = svg_text_fingerprint(group) + group.set(_TXBODY_UNCHANGED_ATTR, '1' if expected == actual else '0') + + +def _mark_unchanged_preset_previews(root: ET.Element) -> None: + """Snapshot visible preset layers before exporter preprocessing.""" + for group in root.iter(): + if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g': + continue + if ( + group.get('data-pptx-object') not in {'shape', 'connector'} + or group.get('data-pptx-prst') is None + ): + continue + try: + expected = resolve_preset_preview_hash(group) + except ValueError as exc: + raise SvgNativeConversionError( + f'Invalid preset preview fingerprint contract: {exc}' + ) from exc + if expected is None: + continue + actual = svg_preset_preview_fingerprint(group) + group.set(_PREVIEW_UNCHANGED_ATTR, '1' if expected == actual else '0') + + +def _require_unchanged_preset_preview(group: ET.Element) -> None: + try: + expected = resolve_preset_preview_hash(group) + except ValueError as exc: + raise SvgNativeConversionError( + f'Invalid preset preview fingerprint contract: {exc}' + ) from exc + if expected is None: + return + snapshot = group.get(_PREVIEW_UNCHANGED_ATTR) + if snapshot == '1': + return + if snapshot is None and svg_preset_preview_fingerprint(group) == expected: + return + raise SvgNativeConversionError( + 'Visible preset preview was edited without updating its native ' + 'data-pptx-prst/frame/adjustment carrier; export stopped to avoid ' + 'silently discarding the SVG edit' + ) + + +def _decode_unchanged_txbody( + group: ET.Element, + metadata: ET.Element, +) -> str | None: + expected_hash = metadata.get('data-pptx-text-sha256') + if not expected_hash: + raise SvgNativeConversionError('txbody metadata requires a text hash') + snapshot = group.get(_TXBODY_UNCHANGED_ATTR) + if snapshot == '0': + return None + if snapshot != '1' and svg_text_fingerprint(group) != expected_hash: + return None + if metadata.get('data-pptx-encoding') != 'base64': + raise SvgNativeConversionError('txbody metadata requires base64 encoding') + try: + raw = base64.b64decode((metadata.text or '').strip(), validate=True) + txbody = ET.fromstring(raw) + decoded = raw.decode('utf-8') + except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: + raise SvgNativeConversionError(f'Invalid txbody metadata: {exc}') from exc + if txbody.tag != ( + '{http://schemas.openxmlformats.org/presentationml/2006/main}txBody' + ): + raise SvgNativeConversionError('txbody metadata payload must be p:txBody') + if has_relationship_attributes(txbody): + raise SvgNativeConversionError( + 'txbody metadata must not contain part-local relationship attributes' + ) + return decoded + + +def _append_shape_text( + shape: ShapeResult, + txbody_xml: str, +) -> ShapeResult: + if not shape.xml.lstrip().startswith('') or not shape.xml.rstrip().endswith(''): + raise SvgNativeConversionError('Native txBody can only attach to p:sp') + closing = shape.xml.rfind('') + return ShapeResult( + xml=( + shape.xml[:closing] + + txbody_xml + + '\n' + + shape.xml[closing:] + ), + bounds_emu=shape.bounds_emu, + ) + + # --------------------------------------------------------------------------- # Group handling # --------------------------------------------------------------------------- @@ -240,6 +379,71 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: ctx.anim_targets.append((int(shape_match.group(1)), elem_id)) return native_result + if ( + elem.get('data-pptx-object') in {'shape', 'connector'} + and elem.get('data-pptx-prst') is not None + ): + if elem.get(AUTHORING_ATTR) == AUTHORING_VALUE: + authoring_errors = validate_authored_preset_group(elem) + if authoring_errors: + raise SvgNativeConversionError( + 'Invalid authored preset shape: ' + + '; '.join(authoring_errors) + ) + _require_unchanged_preset_preview(elem) + + txbody_meta = _txbody_metadata(elem) + logical_text_shape = ( + elem.get('data-pptx-object') == 'shape' + and ( + elem.get('data-pptx-prst') is not None + or elem.get('data-pptx-geometry-kind') == 'custom' + ) + and txbody_meta is not None + ) + if logical_text_shape: + carrier_children = [ + child for child in elem + if child.get('data-pptx-part') == 'geometry' + ] + allowed_parts = { + 'geometry', + 'geometry-detail', + 'geometry-preview', + 'txbody', + } + has_foreign_visual = any( + child.tag.replace(f'{{{SVG_NS}}}', '') not in {'text', 'metadata'} + and child.get('data-pptx-part') not in allowed_parts + for child in elem + ) + native_text = _decode_unchanged_txbody(elem, txbody_meta) + if len(carrier_children) == 1 and not has_foreign_visual and native_text is not None: + geometry_ctx = child_ctx + if transform and not native_subtree_active: + geometry_ctx = ctx.child( + 0, 0, 1.0, 1.0, + transform_matrix=parse_transform_matrix(transform), + filter_id=filter_id, + style_overrides=style_overrides, + opacity_multiplier=local_opacity, + ) + geometry_result = convert_element(carrier_children[0], geometry_ctx) + ctx.sync_from_child(geometry_ctx) + if geometry_result is None: + raise SvgNativeConversionError( + 'Logical text shape has no convertible geometry carrier' + ) + restored = _append_shape_text( + geometry_result, + native_text, + ) + if should_animate_group and elem_id: + shape_match = re.search(r' ShapeResult | None: if not child_results: return None - # Single-child non-semantic groups are flattened to reduce nesting. Top-level - # semantic groups are preserved so animations target the group, not its - # individual child shapes. - if len(child_results) == 1 and not should_animate_group: + # A logical imported preset may contain several render-only SVG detail + # paths, but after those are skipped it owns exactly one native object. + # Flatten that carrier even at the top level; otherwise animation grouping + # would turn one source ``p:sp`` into a ``p:grpSp`` wrapper. Retarget an + # optional animation to the restored leaf shape ID. + logical_native_shape_group = ( + elem.get('data-pptx-object') in {'shape', 'connector'} + and ( + elem.get('data-pptx-prst') is not None + or elem.get('data-pptx-geometry-kind') == 'custom' + ) + ) + explicit_native_group = elem.get('data-pptx-object') == 'group' + if ( + len(child_results) == 1 + and not explicit_native_group + and (not should_animate_group or logical_native_shape_group) + ): + if should_animate_group and elem_id: + shape_match = re.search(r' ShapeResult | None: off_y = int(round(group_y + delta_y)) shapes_xml = '\n'.join(result.xml for result in child_results) - group_id = ctx.next_id() + group_id = ( + ctx.claim_shape_id( + elem.get('data-pptx-shape-id'), + elem.get('data-pptx-shape-scope'), + ) + if elem.get('data-pptx-object') == 'group' + else ctx.next_id() + ) # Record top-level semantic groups (e.g. ) so the # PPTX builder can emit per-element entrance timing. Only the outermost @@ -324,6 +553,8 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: rot_emu = 0 if matrix_supported else int(angle_deg * 60000) rot_attr = f' rot="{rot_emu}"' if rot_emu else '' + validate_ooxml_xfrm(off_x, off_y, group_w, group_h) + validate_ooxml_xfrm(group_x, group_y, group_w, group_h) return ShapeResult(xml=f''' @@ -413,6 +644,16 @@ def _is_full_canvas_rect( """Return whether a rect is a safe candidate for native slide background.""" if elem.get('transform') or elem.get('filter') or elem.get('clip-path'): return False + if any( + elem.get(attr) is not None + for attr in ( + 'data-pptx-object', + 'data-pptx-prst', + 'data-pptx-frame', + 'data-pptx-geometry-status', + ) + ): + return False if _f(elem.get('rx')) > 0 or _f(elem.get('ry')) > 0: return False @@ -529,6 +770,115 @@ def collect_defs(root: ET.Element) -> dict[str, ET.Element]: return defs +def _build_source_shape_id_map(root: ET.Element) -> dict[tuple[str, str], int]: + """Allocate page-unique ids for part-scoped imported shape identities.""" + source_entries: list[tuple[tuple[str, str], int]] = [] + seen_keys: set[tuple[str, str]] = set() + for elem in root.iter(): + raw_id = elem.get('data-pptx-shape-id') + if raw_id is None: + continue + scope = elem.get('data-pptx-shape-scope') or 'slide' + if re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None: + raise SvgNativeConversionError( + f'Invalid data-pptx-shape-scope {scope!r}' + ) + try: + shape_id = int(raw_id) + except ValueError as exc: + raise SvgNativeConversionError( + f'Invalid data-pptx-shape-id {raw_id!r}' + ) from exc + if shape_id < 2 or shape_id > 0xFFFFFFFF: + raise SvgNativeConversionError( + f'data-pptx-shape-id must be between 2 and 4294967295, got {raw_id!r}' + ) + key = (scope, raw_id) + if key in seen_keys: + continue + seen_keys.add(key) + source_entries.append((key, shape_id)) + + preferred_ids = {shape_id for _key, shape_id in source_entries} + next_fresh = max(preferred_ids, default=1) + 1 + used: set[int] = set() + mapping: dict[tuple[str, str], int] = {} + for key, preferred in source_entries: + output_id = preferred + if output_id in used: + while next_fresh in preferred_ids or next_fresh in used: + next_fresh += 1 + if next_fresh > 0xFFFFFFFF: + raise SvgNativeConversionError('Exhausted PowerPoint shape id range') + output_id = next_fresh + next_fresh += 1 + used.add(output_id) + mapping[key] = output_id + return mapping + + +def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str, Any]: + """Describe the native geometry decision for conversion diagnostics.""" + xml = result.xml.lstrip() + if xml.startswith(''): + return {'output_geometry': 'group', 'fidelity': 'visual-only'} + if xml.startswith(''): + return {'output_geometry': 'picture', 'fidelity': 'native-normalized'} + if xml.startswith(''): + return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'} + + preset_match = re.search(r'') + fidelity = ( + 'exact' + if source_preset == preset + and elem.get('data-pptx-frame') is not None + and not is_connector + else 'native-normalized' + ) + return { + 'output_geometry': 'preset', + 'preset': preset, + 'fidelity': fidelity, + } + if re.search(r')', xml): + carrier = next( + ( + candidate + for candidate in elem.iter() + if candidate.get('data-pptx-part') == 'geometry' + ), + elem, + ) + source_custom = ( + carrier.get('data-pptx-geometry-kind') == 'custom' + and carrier.get('data-pptx-frame') is not None + ) + expected_hash = carrier.get('data-pptx-geometry-sha256') + actual_hash = hashlib.sha256( + (carrier.get('d') or '').strip().encode('utf-8') + ).hexdigest() + unchanged = source_custom and expected_hash == actual_hash + if unchanged: + fidelity = 'exact' + geometry_source = 'preserved-metadata' + elif source_custom: + fidelity = 'native-normalized' + geometry_source = 'svg-recompiled' + else: + fidelity = 'visual-only' + geometry_source = 'svg-authored' + return { + 'output_geometry': 'custom', + 'fidelity': fidelity, + 'geometry_source': geometry_source, + } + return {'output_geometry': 'unknown', 'fidelity': 'visual-only'} + + def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Dispatch an SVG element to the appropriate converter.""" tag = elem.tag.replace(f'{{{SVG_NS}}}', '') @@ -545,6 +895,13 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None event['id'] = elem_id for attr in ( 'data-pptx-layer', + 'data-pptx-object', + 'data-pptx-shape-id', + 'data-pptx-frame', + 'data-pptx-prst', + 'data-pptx-part', + 'data-pptx-geometry-status', + 'data-pptx-geometry-reason', 'data-pptx-placeholder', 'data-pptx-placeholder-bounds', 'data-pptx-placeholder-idx', @@ -553,9 +910,20 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None value = elem.get(attr) if value is not None: event[attr] = value + adjustments = { + attr[len('data-pptx-av-'):]: value + for attr, value in elem.attrib.items() + if attr.startswith('data-pptx-av-') + } + if adjustments: + event['adjustments'] = dict(sorted(adjustments.items())) event.update(metadata) ctx.trace_events.append(event) + if elem.get('data-pptx-part') == 'geometry-detail': + trace('skip', reason='render-only-preset-geometry-detail') + return None + converter = _CONVERTERS.get(tag) if converter: try: @@ -570,6 +938,7 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None metadata['shape_id'] = int(shape_match.group(1)) if result.bounds_emu is not None: metadata['bounds_emu'] = list(result.bounds_emu) + metadata.update(_geometry_trace_metadata(elem, result)) trace('native', **metadata) else: trace('skip', reason='empty-or-non-rendering') @@ -701,6 +1070,20 @@ def convert_svg_to_slide_shapes( """ tree = ET.parse(str(svg_path)) root = tree.getroot() + if root.get('transform'): + raise SvgNativeConversionError( + 'Root transform is unsupported; apply transforms to child ' + 'elements or groups' + ) + authored_errors = validate_authored_preset_tree(root) + if authored_errors: + raise SvgNativeConversionError( + 'Invalid authored preset structure: ' + '; '.join(authored_errors) + ) + _mark_unchanged_txbody_groups(root) + _mark_unchanged_preset_previews(root) + if native_objects: + snapshot_native_fallback_freshness(root) trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None trace_steps: list[dict[str, Any]] = [] @@ -806,8 +1189,11 @@ def convert_svg_to_slide_shapes( ) defs = collect_defs(root) + source_shape_id_map = _build_source_shape_id_map(root) ctx = ConvertContext( defs=defs, + reserved_shape_ids=frozenset(source_shape_id_map.values()), + source_shape_id_map=source_shape_id_map, slide_num=slide_num, viewport_width=viewport_width, viewport_height=viewport_height, @@ -884,6 +1270,15 @@ def convert_svg_to_slide_shapes( if tag not in _NON_VISUAL_TAGS: skipped += 1 + unresolved_connector_targets = sorted( + ctx.referenced_shape_ids - ctx.claimed_shape_ids + ) + if unresolved_connector_targets: + raise SvgNativeConversionError( + 'Connector target shape ids were reserved but not restored: ' + + ', '.join(str(shape_id) for shape_id in unresolved_connector_targets) + ) + # Animation target fallback. Semantic groups are the # preferred anchors (set inside convert_g). When the SVG has none # at the root we fall back to top-level primitives, but only when diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py index 3274b783..a6a94801 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py @@ -2,15 +2,27 @@ from __future__ import annotations +import base64 +import binascii +import hashlib import io import math import re -import base64 from pathlib import Path from typing import Any from urllib.parse import unquote_to_bytes from xml.etree import ElementTree as ET +from pptx_shapes import ( + CONNECTOR_PRESET_TYPES, + OOXML_COORDINATE_MAX, + OOXML_COORDINATE_MIN, + get_preset_registry, + has_relationship_attributes, + load_shape_type_values, + validate_ooxml_xfrm, +) +from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE from resource_paths import resolve_external_image_reference from .context import ConvertContext, ShapeResult @@ -109,6 +121,160 @@ def _wrap_shape( ''' +def _wrap_connector( + shape_id: int, + name: str, + off_x: int, + off_y: int, + ext_cx: int, + ext_cy: int, + geom_xml: str, + fill_xml: str, + stroke_xml: str, + effect_xml: str = '', + rot: int = 0, + xfrm_attr: str = '', + connection_xml: str = '', + extra_xml: str = '', +) -> str: + """Wrap DrawingML content into a native ``p:cxnSp`` connector.""" + rot_attr = f' rot="{rot}"' if rot else '' + xfrm_attrs = f'{xfrm_attr}{rot_attr}' + return f''' + + +{connection_xml} + + + +{geom_xml} +{fill_xml} +{stroke_xml} +{effect_xml} + +{extra_xml} +''' + + +def _wrap_geometry_object( + elem: ET.Element, + ctx: ConvertContext, + shape_id: int, + name: str, + off_x: int, + off_y: int, + ext_cx: int, + ext_cy: int, + geom_xml: str, + fill_xml: str, + stroke_xml: str, + effect_xml: str = '', + xfrm_attr: str = '', +) -> str: + """Wrap a semantic leaf as a shape or connector without guessing.""" + name = elem.get('data-pptx-shape-name') or name + shape_style_xml = _decode_shape_style(elem) + object_kind = elem.get('data-pptx-object') + if object_kind != 'connector': + return _wrap_shape( + shape_id, + name, + off_x, + off_y, + ext_cx, + ext_cy, + geom_xml, + fill_xml, + stroke_xml, + effect_xml, + extra_xml=shape_style_xml, + xfrm_attr=xfrm_attr, + ) + + prst = elem.get('data-pptx-prst') + is_custom = elem.get('data-pptx-geometry-kind') == 'custom' + if prst is None and not is_custom: + raise ValueError( + 'data-pptx-object="connector" requires preset or preserved ' + 'custom geometry' + ) + return _wrap_connector( + shape_id, + name, + off_x, + off_y, + ext_cx, + ext_cy, + geom_xml, + fill_xml, + stroke_xml, + effect_xml, + xfrm_attr=xfrm_attr, + connection_xml=_connector_connection_xml(elem, ctx), + extra_xml=shape_style_xml, + ) + + +def _decode_shape_style(elem: ET.Element) -> str: + encoded = elem.get('data-pptx-shape-style') + if not encoded: + return '' + try: + raw = base64.b64decode(encoded, validate=True) + style = ET.fromstring(raw) + decoded = raw.decode('utf-8') + except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: + raise ValueError(f'Invalid shape-style metadata: {exc}') from exc + if style.tag != ( + '{http://schemas.openxmlformats.org/presentationml/2006/main}style' + ): + raise ValueError('Shape-style metadata payload must be p:style') + if has_relationship_attributes(style): + raise ValueError( + 'Shape-style metadata must not contain relationship attributes' + ) + return decoded + + +def _connector_connection_xml(elem: ET.Element, ctx: ConvertContext) -> str: + """Restore connector endpoint attachment using the reserved source id map.""" + parts: list[str] = [] + for endpoint, tag in (('start', 'stCxn'), ('end', 'endCxn')): + raw_shape_id = elem.get(f'data-pptx-{endpoint}-shape-id') + raw_site = elem.get(f'data-pptx-{endpoint}-site') + if raw_shape_id is None and raw_site is None: + continue + if raw_shape_id is None or raw_site is None: + raise ValueError( + f'Connector {endpoint} endpoint requires both shape-id and site' + ) + target_scope = ( + elem.get(f'data-pptx-{endpoint}-shape-scope') + or elem.get('data-pptx-shape-scope') + or 'slide' + ) + target_id = ctx.reference_shape_id(raw_shape_id, target_scope) + try: + site = int(raw_site) + except ValueError as exc: + raise ValueError( + f'Invalid connector {endpoint} site {raw_site!r}' + ) from exc + if site < 0 or site > 0xFFFFFFFF: + raise ValueError( + f'Connector {endpoint} site is outside unsigned integer range: {site}' + ) + parts.append(f'') + return ''.join(parts) + + +def _claim_element_shape_id(elem: ET.Element, ctx: ConvertContext) -> int: + return ctx.claim_shape_id( + elem.get('data-pptx-shape-id'), + elem.get('data-pptx-shape-scope'), + ) + + def _context_transform_matrix(ctx: ConvertContext) -> tuple[float, float, float, float, float, float]: """Return the current context as a full SVG affine matrix.""" if ctx.use_transform_matrix: @@ -157,12 +323,15 @@ def _shape_xfrm_from_svg_rect( resolved_w: float, resolved_h: float, transform: str | None, + *, + preserve_degenerate_axes: bool = False, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Build DrawingML xfrm data for an SVG rectangle-like element.""" if _uses_full_transform(ctx, transform): return rect_to_dml_xfrm( raw_x, raw_y, raw_w, raw_h, _combined_transform_matrix(ctx, transform), + preserve_degenerate_axes=preserve_degenerate_axes, ) off_x = px_to_emu(resolved_x) @@ -204,27 +373,362 @@ def _transform_path_commands( _BEZIER_QUARTER_K = 0.5522847498 -def _build_preset_geom_from_meta(elem: ET.Element) -> str | None: - """Build native DrawingML preset geometry from SVG metadata.""" +# The hash-locked shared catalog is the single source of truth for the 187 +# ECMA-376 ``ST_ShapeType`` values. Loading it here makes exporter validation +# fail closed if the catalog is missing, corrupt, or incomplete. +PPTX_PRESET_SHAPE_TYPES = frozenset(load_shape_type_values()) + +_PPTX_AV_PREFIX = 'data-pptx-av-' +_PPTX_GUIDE_NAME_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]{0,63}') +_PPTX_VAL_FORMULA_RE = re.compile(r'val[\t ]+([+-]?\d+)') +def _parse_preset_geometry_metadata( + elem: ET.Element, +) -> tuple[str | None, list[tuple[str, str]], tuple[float, float, float, float] | None]: + """Parse and validate rendering-neutral preset geometry metadata.""" + status = (elem.get('data-pptx-geometry-status') or '').strip() + authoring = elem.get(AUTHORING_ATTR) + if authoring not in {None, AUTHORING_VALUE}: + raise ValueError(f'Unsupported {AUTHORING_ATTR} value {authoring!r}') + if authoring == AUTHORING_VALUE: + object_kind = elem.get('data-pptx-object') + if object_kind not in {'shape', 'connector'}: + raise ValueError( + 'Authored preset metadata requires data-pptx-object=' + '"shape" or "connector"' + ) + preset = elem.get('data-pptx-prst') + if preset is None: + raise ValueError('Authored preset metadata requires data-pptx-prst') + if preset in CONNECTOR_PRESET_TYPES and object_kind != 'connector': + raise ValueError( + f'Connector preset {preset!r} requires ' + 'data-pptx-object="connector"' + ) + if object_kind == 'connector' and preset not in CONNECTOR_PRESET_TYPES: + raise ValueError( + f'Authored connector requires a connector preset, got {preset!r}' + ) + if elem.get('data-pptx-frame') is None: + raise ValueError('Authored preset metadata requires data-pptx-frame') + if status not in {'', 'exact', 'unsupported'}: + raise ValueError( + f'Unsupported data-pptx-geometry-status {status!r}; ' + 'expected exact or unsupported' + ) + raw_reason = elem.get('data-pptx-geometry-reason') + if raw_reason is not None and status != 'unsupported': + raise ValueError( + 'data-pptx-geometry-reason requires ' + 'data-pptx-geometry-status="unsupported"' + ) + if status == 'unsupported': + reason = (raw_reason or 'unspecified').strip() + raise ValueError(f'Unsupported source PPTX geometry: {reason}') + prst = elem.get('data-pptx-prst') - if prst != 'round2SameRect': - return None + allowed_guide_names: frozenset[str] = frozenset() + if prst is not None: + if prst != prst.strip() or prst not in PPTX_PRESET_SHAPE_TYPES: + raise ValueError(f'Unknown or invalid data-pptx-prst {prst!r}') + allowed_guide_names = frozenset( + guide.name + for guide in get_preset_registry().get(prst).adjustments + ) - def _adj_attr(name: str, default: int) -> int: + guide_formulas: dict[str, str] = {} + for attr_name, raw_fmla in elem.attrib.items(): + if not attr_name.startswith(_PPTX_AV_PREFIX): + continue + if prst is None: + raise ValueError(f'{attr_name} requires data-pptx-prst') + guide_name = attr_name[len(_PPTX_AV_PREFIX):] + if not _PPTX_GUIDE_NAME_RE.fullmatch(guide_name): + raise ValueError(f'Invalid preset adjustment guide name {guide_name!r}') + if guide_name not in allowed_guide_names: + raise ValueError( + f'Preset {prst!r} has no adjustment guide named {guide_name!r}' + ) + formula = raw_fmla.strip() + if not formula: + raise ValueError(f'{attr_name} must not be empty') + match = _PPTX_VAL_FORMULA_RE.fullmatch(formula) + if match is not None: + value = int(match.group(1)) + if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: + raise ValueError( + f'{attr_name} value {value} is outside OOXML coordinate range' + ) + guide_formulas[guide_name] = formula + + # Compatibility for SVGs emitted before the generic ``data-pptx-av-*`` + # contract. New imports always use the canonical full-formula attributes. + if prst == 'round2SameRect': + guide_names = set(guide_formulas) + for guide_name, default in (('adj1', 16667), ('adj2', 0)): + legacy_name = f'data-pptx-{guide_name}' + if guide_name in guide_names or elem.get(legacy_name) is None: + continue + raw_value = elem.get(legacy_name, str(default)) + try: + value = int(float(raw_value)) + except ValueError as exc: + raise ValueError(f'{legacy_name} must be numeric, got {raw_value!r}') from exc + value = max(0, min(100000, value)) + guide_formulas[guide_name] = f'val {value}' + + guides: list[tuple[str, str]] = [] + if prst is not None and guide_formulas: + registry = get_preset_registry() try: - return int(float(elem.get(name, str(default)))) - except ValueError: - return default + evaluated = registry.evaluate( + prst, + 100000, + 100000, + adjustments=guide_formulas, + ) + except ValueError as exc: + raise ValueError( + f'Invalid adjustment formula for preset {prst!r}: {exc}' + ) from exc + for name, value in evaluated.adjustments.items(): + if ( + name in guide_formulas + and not OOXML_COORDINATE_MIN + <= value + <= OOXML_COORDINATE_MAX + ): + raise ValueError( + f'data-pptx-av-{name} evaluates outside OOXML coordinate range' + ) + guides = [ + (guide.name, guide_formulas[guide.name]) + for guide in registry.get(prst).adjustments + if guide.name in guide_formulas + ] - adj1 = max(0, min(100000, _adj_attr('data-pptx-adj1', 16667))) - adj2 = max(0, min(100000, _adj_attr('data-pptx-adj2', 0))) - return ( - '' - '' - f'' - f'' - '' - '' + frame = None + raw_frame = elem.get('data-pptx-frame') + if raw_frame is not None: + parts = re.split(r'[\s,]+', raw_frame.strip()) + if len(parts) != 4: + raise ValueError( + 'data-pptx-frame must contain exactly four numbers: x y width height' + ) + try: + frame = tuple(float(part) for part in parts) + except ValueError as exc: + raise ValueError(f'Invalid data-pptx-frame {raw_frame!r}') from exc + if not all(math.isfinite(value) for value in frame): + raise ValueError(f'data-pptx-frame must contain finite numbers, got {raw_frame!r}') + is_connector = ( + elem.get('data-pptx-object') == 'connector' + or prst in CONNECTOR_PRESET_TYPES + ) + if is_connector: + if frame[2] < 0 or frame[3] < 0 or (frame[2] == 0 and frame[3] == 0): + raise ValueError( + 'Connector data-pptx-frame dimensions must be non-negative ' + f'and not both zero, got {raw_frame!r}' + ) + elif frame[2] <= 0 or frame[3] <= 0: + raise ValueError( + f'data-pptx-frame width and height must be positive, got {raw_frame!r}' + ) + validate_ooxml_xfrm( + px_to_emu(frame[0]), + px_to_emu(frame[1]), + px_to_emu(frame[2]), + px_to_emu(frame[3]), + ) + + return prst, guides, frame + + +def validate_preset_geometry_metadata(elem: ET.Element) -> list[str]: + """Return native shape metadata errors for authoring-time validation.""" + errors: list[str] = [] + try: + _parse_preset_geometry_metadata(elem) + except ValueError as exc: + errors.append(str(exc)) + if elem.get('data-pptx-custgeom') is not None: + try: + _build_preserved_custom_geom(elem) + except ValueError as exc: + errors.append(str(exc)) + if elem.get('data-pptx-shape-style') is not None: + try: + _decode_shape_style(elem) + except ValueError as exc: + errors.append(str(exc)) + raw_shape_id = elem.get('data-pptx-shape-id') + if raw_shape_id is not None: + try: + shape_id = int(raw_shape_id) + except ValueError: + errors.append(f'Invalid data-pptx-shape-id {raw_shape_id!r}') + else: + if shape_id < 2 or shape_id > 0xFFFFFFFF: + errors.append( + 'data-pptx-shape-id must be between 2 and 4294967295' + ) + scope = elem.get('data-pptx-shape-scope') + if scope is not None and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None: + errors.append(f'Invalid data-pptx-shape-scope {scope!r}') + for endpoint in ('start', 'end'): + target = elem.get(f'data-pptx-{endpoint}-shape-id') + site = elem.get(f'data-pptx-{endpoint}-site') + if (target is None) != (site is None): + errors.append( + f'Connector {endpoint} endpoint requires both shape-id and site' + ) + if target is not None: + try: + target_id = int(target) + site_id = int(site or '') + except ValueError: + errors.append(f'Invalid connector {endpoint} endpoint metadata') + else: + if target_id < 2 or target_id > 0xFFFFFFFF: + errors.append(f'Connector {endpoint} shape-id is out of range') + if site_id < 0 or site_id > 0xFFFFFFFF: + errors.append(f'Connector {endpoint} site is out of range') + return errors + + +def _build_preset_geom_from_meta(elem: ET.Element) -> str | None: + """Build validated native DrawingML preset geometry from SVG metadata.""" + prst, guides, _frame = _parse_preset_geometry_metadata(elem) + if prst is None: + return None + if not guides: + return f'' + guide_xml = ''.join( + f'' + for name, fmla in guides + ) + return f'{guide_xml}' + + +def _build_preserved_custom_geom(elem: ET.Element) -> str | None: + """Return unchanged native ``a:custGeom`` metadata, or mark it stale.""" + kind = elem.get('data-pptx-geometry-kind') + if kind is None: + return None + if kind != 'custom': + raise ValueError(f'Unsupported data-pptx-geometry-kind {kind!r}') + encoded = elem.get('data-pptx-custgeom') + expected_hash = elem.get('data-pptx-geometry-sha256') + if not encoded or not expected_hash: + raise ValueError( + 'Custom geometry metadata requires data-pptx-custgeom and ' + 'data-pptx-geometry-sha256' + ) + actual_hash = hashlib.sha256( + (elem.get('d') or '').strip().encode('utf-8') + ).hexdigest() + if actual_hash != expected_hash: + return None + try: + raw = base64.b64decode(encoded, validate=True) + custom = ET.fromstring(raw) + decoded = raw.decode('utf-8') + except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: + raise ValueError(f'Invalid custom geometry metadata: {exc}') from exc + if custom.tag != ( + '{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom' + ): + raise ValueError('Custom geometry metadata payload must be a:custGeom') + if has_relationship_attributes(custom): + raise ValueError( + 'Custom geometry metadata must not contain relationship attributes' + ) + return decoded + + +def _shape_xfrm_from_preset_frame( + elem: ET.Element, + ctx: ConvertContext, + fallback_raw_rect: tuple[float, float, float, float], + fallback_resolved_rect: tuple[float, float, float, float], + transform: str | None, +) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: + """Use the preserved logical frame for native preset size when present.""" + prst, _guides, frame = _parse_preset_geometry_metadata(elem) + if frame is None: + raw_x, raw_y, raw_w, raw_h = fallback_raw_rect + x, y, w, h = fallback_resolved_rect + else: + raw_x, raw_y, raw_w, raw_h = frame + x = ctx_x(raw_x, ctx) + y = ctx_y(raw_y, ctx) + w = ctx_w(raw_w, ctx) + h = ctx_h(raw_h, ctx) + preserves_zero_axis = ( + elem.get('data-pptx-object') == 'connector' + or prst in CONNECTOR_PRESET_TYPES + ) + xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect( + ctx, + raw_x, + raw_y, + raw_w, + raw_h, + x, + y, + w, + h, + transform, + preserve_degenerate_axes=preserves_zero_axis, + ) + if not preserves_zero_axis: + ext_cx = max(ext_cx, 1) + ext_cy = max(ext_cy, 1) + bounds_emu = ( + bounds_emu[0], + bounds_emu[1], + max(bounds_emu[2], off_x + ext_cx), + max(bounds_emu[3], off_y + ext_cy), + ) + return xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu + + +def _pathlike_preset_xfrm( + elem: ET.Element, + ctx: ConvertContext, + transform: str | None, + min_x: float, + min_y: float, + width: float, + height: float, +) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: + """Resolve a path-like preset xfrm from its logical frame or visual bounds.""" + _prst, _guides, frame = _parse_preset_geometry_metadata(elem) + if frame is None: + if _uses_full_transform(ctx, transform): + tag = elem.tag.rsplit('}', 1)[-1] + raise ValueError( + f'Transformed preset-bearing <{tag}> requires data-pptx-frame ' + 'to preserve its logical size' + ) + off_x = px_to_emu(min_x) + off_y = px_to_emu(min_y) + ext_cx = max(px_to_emu(width), 1) + ext_cy = max(px_to_emu(height), 1) + return ( + '', + off_x, + off_y, + ext_cx, + ext_cy, + (off_x, off_y, off_x + ext_cx, off_y + ext_cy), + ) + return _shape_xfrm_from_preset_frame( + elem, + ctx, + (0.0, 0.0, 1.0, 1.0), + (min_x, min_y, width, height), + transform, ) @@ -338,6 +842,7 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: y = ctx_y(raw_y, ctx) w = ctx_w(raw_w, ctx) h = ctx_h(raw_h, ctx) + preset_geom = _build_preset_geom_from_meta(elem) if w <= 0 or h <= 0: return None @@ -371,7 +876,9 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: transform = elem.get('transform') - if rx > 0 and abs(rx - ry) < 0.5: + if preset_geom is not None: + geom = preset_geom + elif rx > 0 and abs(rx - ry) < 0.5: # Symmetric corners → native PowerPoint rounded rectangle. adj is # the corner radius as a fraction of the shorter side, in 1/1000- # percent units, capped at 50000 (= radius equals half the shorter @@ -395,21 +902,33 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: else: geom = '' - shape_id = ctx.next_id() - xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect( - ctx, - raw_x, - raw_y, - raw_w, - raw_h, - x, - y, - w, - h, - transform, - ) + shape_id = _claim_element_shape_id(elem, ctx) + if preset_geom is not None: + xfrm = _shape_xfrm_from_preset_frame( + elem, + ctx, + (raw_x, raw_y, raw_w, raw_h), + (x, y, w, h), + transform, + ) + else: + xfrm = _shape_xfrm_from_svg_rect( + ctx, + raw_x, + raw_y, + raw_w, + raw_h, + x, + y, + w, + h, + transform, + ) + xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Rectangle {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, effect, xfrm_attr=xfrm_attr, @@ -529,12 +1048,13 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: cx_ = svg_length_x(elem.get('cx'), ctx) cy_ = svg_length_y(elem.get('cy'), ctx) r = svg_length_size(elem.get('r'), ctx) + preset_geom = _build_preset_geom_from_meta(elem) if r <= 0: return None # --- Donut-chart arc segment detection --- - if _is_donut_circle(elem, ctx): + if preset_geom is None and _is_donut_circle(elem, ctx): dasharray = _get_attr(elem, 'stroke-dasharray', ctx) dash_vals = re.split(r'[\s,]+', dasharray.strip()) dash_len = float(dash_vals[0]) if dash_vals else 0 @@ -591,7 +1111,7 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: get_element_opacity(elem, ctx), ) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) return ShapeResult( xml=_wrap_shape( shape_id, f'Arc {shape_id}', @@ -626,23 +1146,35 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: get_element_opacity(elem, ctx), ) - geom = '' + geom = preset_geom or '' - shape_id = ctx.next_id() - xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect( - ctx, - cx_ - r, - cy_ - r, - r * 2, - r * 2, - x, - y, - w, - h, - transform, - ) + shape_id = _claim_element_shape_id(elem, ctx) + if preset_geom is not None: + xfrm = _shape_xfrm_from_preset_frame( + elem, + ctx, + (cx_ - r, cy_ - r, r * 2, r * 2), + (x, y, w, h), + transform, + ) + else: + xfrm = _shape_xfrm_from_svg_rect( + ctx, + cx_ - r, + cy_ - r, + r * 2, + r * 2, + x, + y, + w, + h, + transform, + ) + xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Ellipse {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, effect, xfrm_attr=xfrm_attr, @@ -663,17 +1195,22 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to use custom geometry which is sufficient and avoids flipH/flipV complexity. """ + preset_geom = _build_preset_geom_from_meta(elem) transform = elem.get('transform') + raw_x1 = svg_length_x(elem.get('x1'), ctx) + raw_y1 = svg_length_y(elem.get('y1'), ctx) + raw_x2 = svg_length_x(elem.get('x2'), ctx) + raw_y2 = svg_length_y(elem.get('y2'), ctx) x1, y1 = _transformed_point( ctx, - svg_length_x(elem.get('x1'), ctx), - svg_length_y(elem.get('y1'), ctx), + raw_x1, + raw_y1, transform, ) x2, y2 = _transformed_point( ctx, - svg_length_x(elem.get('x2'), ctx), - svg_length_y(elem.get('y2'), ctx), + raw_x2, + raw_y2, transform, ) @@ -683,7 +1220,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: stroke_op = get_stroke_opacity(elem, ctx) stroke = build_stroke_xml(elem, ctx, stroke_op) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) @@ -693,6 +1230,47 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: _get_attr(elem, 'marker-end', ctx) ) + if preset_geom is not None: + # The preserved logical frame, not the rendered stroke/marker bounds, + # owns the native shape size. Horizontal/vertical connectors retain a + # one-EMU extent on the degenerate axis as required by DrawingML. + raw_w = abs(raw_x2 - raw_x1) + raw_h = abs(raw_y2 - raw_y1) + resolved_w = abs(x2 - x1) + resolved_h = abs(y2 - y1) + xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = ( + _shape_xfrm_from_preset_frame( + elem, + ctx, + (min(raw_x1, raw_x2), min(raw_y1, raw_y2), raw_w, raw_h), + (min_x, min_y, resolved_w, resolved_h), + transform, + ) + ) + if not _uses_full_transform(ctx, transform): + flip_attrs = [] + if x1 > x2: + flip_attrs.append(' flipH="1"') + if y1 > y2: + flip_attrs.append(' flipV="1"') + xfrm_attr += ''.join(flip_attrs) + xml = _wrap_geometry_object( + elem, + ctx, + shape_id, + f'Connector {shape_id}' if elem.get('data-pptx-object') == 'connector' + else f'Line {shape_id}', + off_x, + off_y, + w_emu, + h_emu, + preset_geom, + '', + stroke, + xfrm_attr=xfrm_attr, + ) + return ShapeResult(xml=xml, bounds_emu=bounds_emu) + if has_marker: # ---------------------------------------------------------------- # Preset geometry approach: prstGeom prst="line" @@ -727,22 +1305,17 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: elif flip_v: flip_attr = ' flipV="1"' - xml = ( - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'{stroke}' - f'' - f'' + xml = _wrap_shape( + shape_id, + f'Line {shape_id}', + off_x, + off_y, + w_emu, + h_emu, + '', + '', + stroke, + xfrm_attr=flip_attr, ) else: # ---------------------------------------------------------------- @@ -774,10 +1347,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: geom, '', stroke, ) - return ShapeResult( - xml=xml, - bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu), - ) + return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu)) # --------------------------------------------------------------------------- @@ -786,8 +1356,13 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" + preset_geom = _build_preset_geom_from_meta(elem) + preserved_custom_geom = _build_preserved_custom_geom(elem) + native_geom = preset_geom or preserved_custom_geom d = elem.get('d', '') if not d: + if native_geom is not None: + raise ValueError('Native-geometry requires a non-empty d attribute') return None commands = parse_svg_path(d) @@ -812,7 +1387,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: w_emu = px_to_emu(width) h_emu = px_to_emu(height) - geom = None if _uses_full_transform(ctx, transform) else _build_preset_geom_from_meta(elem) + geom = native_geom if geom is None: geom = f''' @@ -835,16 +1410,31 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: get_element_opacity(elem, ctx), ) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) + xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) + bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) + if native_geom is not None: + xfrm = _pathlike_preset_xfrm( + elem, + ctx, + transform, + min_x, + min_y, + width, + height, + ) + xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Freeform {shape_id}', off_x, off_y, w_emu, h_emu, - geom, fill, stroke, effect, + geom, fill, stroke, effect, xfrm_attr=xfrm_attr, ), - bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu), + bounds_emu=bounds_emu, ) @@ -862,8 +1452,11 @@ def _parse_points(points_str: str) -> list[tuple[float, float]]: def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" + preset_geom = _build_preset_geom_from_meta(elem) points = _parse_points(elem.get('points', '')) if not points: + if preset_geom is not None: + raise ValueError('Preset-bearing requires valid points') return None commands = [PathCommand('M', [points[0][0], points[0][1]])] @@ -889,7 +1482,7 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None w_emu = px_to_emu(width) h_emu = px_to_emu(height) - geom = f''' + geom = preset_geom or f''' @@ -902,23 +1495,41 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) + xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) + bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) + if preset_geom is not None: + xfrm = _pathlike_preset_xfrm( + elem, + ctx, + transform, + min_x, + min_y, + width, + height, + ) + xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Polygon {shape_id}', off_x, off_y, w_emu, h_emu, - geom, fill, stroke, + geom, fill, stroke, xfrm_attr=xfrm_attr, ), - bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu), + bounds_emu=bounds_emu, ) def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML custom geometry shape.""" + preset_geom = _build_preset_geom_from_meta(elem) points = _parse_points(elem.get('points', '')) if not points: + if preset_geom is not None: + raise ValueError('Preset-bearing requires valid points') return None commands = [PathCommand('M', [points[0][0], points[0][1]])] @@ -943,7 +1554,7 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non w_emu = px_to_emu(width) h_emu = px_to_emu(height) - geom = f''' + geom = preset_geom or f''' @@ -956,16 +1567,31 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) + xfrm_attr = '' off_x = px_to_emu(min_x) off_y = px_to_emu(min_y) + bounds_emu = (off_x, off_y, off_x + w_emu, off_y + h_emu) + if preset_geom is not None: + xfrm = _pathlike_preset_xfrm( + elem, + ctx, + transform, + min_x, + min_y, + width, + height, + ) + xfrm_attr, off_x, off_y, w_emu, h_emu, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Polyline {shape_id}', off_x, off_y, w_emu, h_emu, - geom, '', stroke, + geom, '', stroke, xfrm_attr=xfrm_attr, ), - bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu), + bounds_emu=bounds_emu, ) @@ -1801,7 +2427,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: get_element_opacity(elem, ctx), ) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) rot_attr = f' rot="{text_rot}"' if text_rot else '' if paragraph_runs is not None: @@ -2535,7 +3161,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: clip_is_noop = clip_geom == '' meet_fit = None if not clip_is_noop else _resolve_image_meet_fit(elem, img_data, w, h) - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) if meet_fit is not None: dx, dy, fit_w, fit_h = meet_fit if ctx.use_transform_matrix: @@ -2598,6 +3224,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML ellipse shape.""" + preset_geom = _build_preset_geom_from_meta(elem) raw_cx = svg_length_x(elem.get('cx'), ctx) raw_cy = svg_length_y(elem.get('cy'), ctx) rx_attr = elem.get('rx') @@ -2626,25 +3253,37 @@ def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None fill = build_fill_xml(elem, ctx, fill_op) stroke = build_stroke_xml(elem, ctx, stroke_op) - geom = '' + geom = preset_geom or '' transform = elem.get('transform') - shape_id = ctx.next_id() - xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect( - ctx, - raw_cx - raw_rx, - raw_cy - raw_ry, - raw_rx * 2, - raw_ry * 2, - x, - y, - w, - h, - transform, - ) + shape_id = _claim_element_shape_id(elem, ctx) + if preset_geom is not None: + xfrm = _shape_xfrm_from_preset_frame( + elem, + ctx, + (raw_cx - raw_rx, raw_cy - raw_ry, raw_rx * 2, raw_ry * 2), + (x, y, w, h), + transform, + ) + else: + xfrm = _shape_xfrm_from_svg_rect( + ctx, + raw_cx - raw_rx, + raw_cy - raw_ry, + raw_rx * 2, + raw_ry * 2, + x, + y, + w, + h, + transform, + ) + xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = xfrm return ShapeResult( - xml=_wrap_shape( + xml=_wrap_geometry_object( + elem, + ctx, shape_id, f'Ellipse {shape_id}', off_x, off_y, ext_cx, ext_cy, geom, fill, stroke, xfrm_attr=xfrm_attr, @@ -2746,7 +3385,7 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N transform = elem.get('transform') - shape_id = ctx.next_id() + shape_id = _claim_element_shape_id(elem, ctx) xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect( ctx, svg_x, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/styles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/styles.py index 218c1842..fcb71f8b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/styles.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/styles.py @@ -6,13 +6,15 @@ import math import re from xml.etree import ElementTree as ET +from pptx_shapes import validate_ooxml_line_width + from .context import ConvertContext from .theme_colors import ThemeColorSpec, color_node_xml from .utils import ( SVG_NS, ANGLE_UNIT, DASH_PRESETS, px_to_emu, _f, _get_attr, parse_svg_length, combine_opacity, parse_inline_style, parse_opacity, parse_stop_style, - parse_svg_color, resolve_url_id, + matrix_multiply, parse_svg_color, parse_transform_matrix, resolve_url_id, ) @@ -442,6 +444,33 @@ def _emit_line_end( return f'' +def _effective_stroke_scale(elem: ET.Element, ctx: ConvertContext) -> float: + """Approximate the effective SVG geometry transform as one line-width scale.""" + vector_effect = _get_attr(elem, 'vector-effect', ctx) + if vector_effect and vector_effect.strip().lower() == 'non-scaling-stroke': + return 1.0 + + if ctx.use_transform_matrix: + matrix = ctx.transform_matrix + else: + matrix = ( + ctx.scale_x, 0.0, + 0.0, ctx.scale_y, + ctx.translate_x, ctx.translate_y, + ) + + # The context already contains ancestor transforms. Shape converters apply + # the leaf element's transform directly, so compose that local matrix once. + transform = elem.get('transform') + if transform: + matrix = matrix_multiply(matrix, parse_transform_matrix(transform)) + + # DrawingML has one line width. sqrt(|det|) equals the uniform scale for a + # similarity transform and the principal-scale geometric mean otherwise. + a, b, c, d, _e, _f = matrix + return math.sqrt(abs(a * d - b * c)) + + def build_stroke_xml( elem: ET.Element, ctx: ConvertContext, @@ -452,8 +481,9 @@ def build_stroke_xml( if not stroke or stroke.strip().lower() in ('none', 'transparent'): return '' - width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0) - width_emu = px_to_emu(width) + source_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0) + width_emu = px_to_emu(source_width * _effective_stroke_scale(elem, ctx)) + validate_ooxml_line_width(width_emu) # Dash pattern dash_xml = '' @@ -468,7 +498,7 @@ def build_stroke_xml( parts = re.split(r'[\s,]+', dasharray.strip()) d_raw = float(parts[0]) sp_raw = float(parts[1]) if len(parts) > 1 else d_raw - sw = max(width, 0.001) + sw = max(source_width, 0.001) d_pct = int(d_raw / sw * 100000) sp_pct = int(sp_raw / sw * 100000) dash_xml = f'' diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/theme_fonts.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/theme_fonts.py index 559d6796..6ec97880 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/theme_fonts.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/theme_fonts.py @@ -125,17 +125,17 @@ def _font_size_hpt(raw: str, field: str) -> int: def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec: - """Load required title/body defaults for template slide-master txStyles.""" + """Load required title/body defaults for explicit Layout master txStyles.""" lock_path = project_path / "spec_lock.md" if not lock_path.is_file(): raise ThemeFontError( - "template mode requires spec_lock.md typography title and body rows" + "explicit Layout export requires spec_lock.md typography title and body rows" ) rows = _typography_rows(lock_path) missing = [field for field in ("title", "body") if field not in rows] if missing: raise ThemeFontError( - "template mode requires spec_lock.md typography rows: " + "explicit Layout export requires spec_lock.md typography rows: " + ", ".join(missing) ) return MasterTextStyleSpec( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/utils.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/utils.py index 9919c6aa..340287be 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/utils.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/utils.py @@ -7,6 +7,8 @@ import math import re from xml.etree import ElementTree as ET +from pptx_shapes import validate_ooxml_xfrm + from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX # --------------------------------------------------------------------------- @@ -264,31 +266,68 @@ def _rotate_matrix(angle_deg: float, cx: float | None = None, cy: float | None = ) +def _skew_matrix(angle_deg: float, *, x_axis: bool) -> AffineMatrix: + radians = math.radians(angle_deg) + if abs(math.cos(radians)) <= 1e-12: + raise ValueError(f'Invalid SVG skew angle {angle_deg!r}') + tangent = math.tan(radians) + if x_axis: + return (1.0, 0.0, tangent, 1.0, 0.0, 0.0) + return (1.0, tangent, 0.0, 1.0, 0.0, 0.0) + + def parse_transform_matrix(transform_str: str) -> AffineMatrix: - """Parse an SVG transform list into one affine matrix.""" + """Parse a complete SVG transform list into one affine matrix. + + Unsupported or malformed operations fail closed. Treating an unknown + operation as the identity would silently discard a visible SVG edit. + """ if not transform_str: return IDENTITY_MATRIX matrix = IDENTITY_MATRIX - for name, raw_args in _TRANSFORM_RE.findall(transform_str): + cursor = 0 + matched = False + for match in _TRANSFORM_RE.finditer(transform_str): + if re.fullmatch(r'[\s,]*', transform_str[cursor:match.start()]) is None: + raise ValueError(f'Invalid SVG transform syntax {transform_str!r}') + matched = True + name, raw_args = match.groups() + if re.fullmatch(r'(?:[\s,]*' + _NUMBER_RE.pattern + r')*[\s,]*', raw_args) is None: + raise ValueError(f'Invalid arguments for SVG transform {name!r}') args = [float(n) for n in _NUMBER_RE.findall(raw_args)] + if not all(math.isfinite(value) for value in args): + raise ValueError(f'Non-finite arguments for SVG transform {name!r}') name = name.lower() - local = IDENTITY_MATRIX - - if name == 'matrix' and len(args) >= 6: + if name == 'matrix' and len(args) == 6: local = (args[0], args[1], args[2], args[3], args[4], args[5]) - elif name == 'translate' and args: + elif name == 'translate' and len(args) in {1, 2}: local = _translate_matrix(args[0], args[1] if len(args) > 1 else 0.0) - elif name == 'scale' and args: + elif name == 'scale' and len(args) in {1, 2}: local = _scale_matrix(args[0], args[1] if len(args) > 1 else None) - elif name == 'rotate' and args: + elif name == 'rotate' and len(args) in {1, 3}: local = _rotate_matrix( args[0], args[1] if len(args) > 2 else None, args[2] if len(args) > 2 else None, ) + elif name == 'skewx' and len(args) == 1: + local = _skew_matrix(args[0], x_axis=True) + elif name == 'skewy' and len(args) == 1: + local = _skew_matrix(args[0], x_axis=False) + else: + raise ValueError( + f'Unsupported or malformed SVG transform {match.group(0)!r}' + ) matrix = matrix_multiply(matrix, local) + cursor = match.end() + + if ( + not matched + or re.fullmatch(r'[\s,]*', transform_str[cursor:]) is None + ): + raise ValueError(f'Invalid SVG transform syntax {transform_str!r}') return matrix @@ -299,12 +338,32 @@ def transform_point(matrix: AffineMatrix, x: float, y: float) -> tuple[float, fl return a * x + c * y + e, b * x + d * y + f +def validate_dml_shape_matrix(matrix: AffineMatrix) -> None: + """Reject affine shear that a DrawingML shape transform cannot express.""" + a, b, c, d, _e, _f = matrix + x_length = math.hypot(a, b) + y_length = math.hypot(c, d) + if x_length <= 1e-12 or y_length <= 1e-12: + raise ValueError( + 'SVG zero-scale transform cannot be represented by a visible ' + 'DrawingML shape' + ) + dot = a * c + b * d + if abs(dot) > x_length * y_length * 1e-9: + raise ValueError( + 'SVG shear/skew cannot be represented by a DrawingML ' + 'shape transform' + ) + + def rect_to_dml_xfrm( x: float, y: float, w: float, h: float, matrix: AffineMatrix, + *, + preserve_degenerate_axes: bool = False, ) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]: """Map a transformed SVG rectangle to DrawingML xfrm attributes. @@ -322,11 +381,18 @@ def rect_to_dml_xfrm( vx = p3[0] - p0[0] vy = p3[1] - p0[1] - rect_w = max(math.hypot(ux, uy), 0.001) - rect_h = max(math.hypot(vx, vy), 0.001) + rect_w = math.hypot(ux, uy) + rect_h = math.hypot(vx, vy) + validate_dml_shape_matrix(matrix) + if not preserve_degenerate_axes: + rect_w = max(rect_w, 0.001) + rect_h = max(rect_h, 0.001) cross = ux * vy - uy * vx - if cross < 0: + if rect_w <= 1e-12 and rect_h > 1e-12: + angle_deg = math.degrees(math.atan2(vy, vx)) - 90.0 + flip_attr = '' + elif cross < 0: angle_deg = math.degrees(math.atan2(-uy, -ux)) flip_attr = ' flipH="1"' else: @@ -342,6 +408,7 @@ def rect_to_dml_xfrm( off_y = px_to_emu(center_y - rect_h / 2) ext_cx = px_to_emu(rect_w) ext_cy = px_to_emu(rect_h) + validate_ooxml_xfrm(off_x, off_y, ext_cx, ext_cy) xs = [p0[0], p1[0], p2[0], p3[0]] ys = [p0[1], p1[1], p2[1], p3[1]] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/__init__.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/__init__.py index 2d202a88..ac03d4e9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/__init__.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/__init__.py @@ -27,6 +27,12 @@ from .chartex import ( _chart_ex_style_xml, _chart_ex_xml, ) +from .fallback_hash import ( + native_fallback_contract_warnings, + require_fresh_native_fallback, + snapshot_native_fallback_freshness, + stamp_native_fallback_baseline, +) from .marker_common import ( CHART_CONTENT_TYPE, CHARTEX_CONTENT_TYPE, @@ -44,6 +50,7 @@ from .marker_common import ( _validate_bounds_inputs, native_marker_transform, ) +from .marker_status import native_marker_status_errors from .table import ( _build_native_table, _native_table_warnings, @@ -59,6 +66,8 @@ __all__ = [ "convert_native_object", "native_object_marker_warnings", "native_marker_transform", + "snapshot_native_fallback_freshness", + "stamp_native_fallback_baseline", "validate_native_object_marker", "validate_native_object_marker_with_warnings", ] @@ -101,7 +110,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str colors_name, ) ctx.package_files[style_part] = _chart_ex_style_xml() - ctx.package_files[colors_part] = _chart_ex_colors_xml() + ctx.package_files[colors_part] = _chart_ex_colors_xml(payload) ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data) ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE @@ -170,22 +179,28 @@ def _validate_native_object_marker_payload( validate_chrome: bool = True, ctx: ConvertContext | None = None, ancestors: tuple[ET.Element, ...] = (), + require_fresh_fallback: bool = False, ) -> tuple[str, dict[str, Any], list[list[Any]] | None]: kind = (elem.get("data-pptx-native") or "").strip().lower() if not kind: return "", {}, None + status_errors = native_marker_status_errors(elem) + if status_errors: + raise RuntimeError("; ".join(status_errors)) if kind not in _NATIVE_KINDS: raise RuntimeError(f"Unsupported data-pptx-native value: {kind}") if _local_tag(elem) != "g": raise RuntimeError("Native PPTX table/chart markers must be elements") native_marker_transform(elem.get("transform")) + if require_fresh_fallback: + require_fresh_native_fallback(elem, use_runtime_snapshot=True) payload = _load_payload(elem, kind) bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors) off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx) table_rows = None if kind == "table": - table_rows, col_count = _validate_table_payload(payload) + table_rows, col_count, _merge_layout = _validate_table_payload(payload) if ext_cx < col_count or ext_cy < len(table_rows): raise RuntimeError( "Native PPTX table bounds must provide at least one EMU per row and column" @@ -218,26 +233,39 @@ def validate_native_object_marker_with_warnings( elem: ET.Element, *, ancestors: tuple[ET.Element, ...] = (), + document_root: ET.Element | None = None, ) -> list[str]: """Validate a data-pptx-native marker and return non-fatal warnings.""" kind, payload, table_rows = _validate_native_object_marker_payload( elem, ancestors=ancestors, ) + warnings = ( + native_fallback_contract_warnings( + elem, + document_root=document_root, + ) + if kind else [] + ) if kind == "table" and table_rows is not None: - return _native_table_warnings(elem, table_rows) - if kind == "chart": - return _native_chart_chrome_warnings(elem, payload) - return [] + warnings.extend(_native_table_warnings(elem, table_rows)) + elif kind == "chart": + warnings.extend(_native_chart_chrome_warnings(elem, payload)) + return warnings def native_object_marker_warnings( elem: ET.Element, *, ancestors: tuple[ET.Element, ...] = (), + document_root: ET.Element | None = None, ) -> list[str]: """Return non-fatal warnings for a data-pptx-native marker.""" - return validate_native_object_marker_with_warnings(elem, ancestors=ancestors) + return validate_native_object_marker_with_warnings( + elem, + ancestors=ancestors, + document_root=document_root, + ) def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: @@ -250,11 +278,20 @@ def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult elem, validate_chrome=False, ctx=ctx, + require_fresh_fallback=True, ) + marker_id = elem.get("id") or "" + for warning in native_fallback_contract_warnings( + elem, + use_runtime_snapshot=True, + ): + print( + f" Warning: data-pptx-native marker {marker_id}: {warning}", + file=sys.stderr, + ) if kind == "table": return _build_native_table(elem, ctx, payload) payload, warnings = _native_chart_export_payload(elem, payload) - marker_id = elem.get("id") or "" for warning in warnings: print( f" Warning: data-pptx-native marker {marker_id}: {warning}", diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py index 71443167..92c42992 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py @@ -222,6 +222,155 @@ _DEFAULT_CHART_COLORS = [ "9E480E", ] +_AXIS_ROLE_DEFAULTS = { + "category": ("text", "bottom"), + "secondary_category": ("text", "bottom"), + "secondary_value": ("value", "right"), + "value": ("value", "left"), + "x": ("value", "bottom"), + "y": ("value", "left"), +} + + +def _chart_axes( + payload: dict[str, Any], + allowed_roles: set[str], +) -> dict[str, dict[str, Any]]: + """Normalize the narrow classic-chart axis contract.""" + raw_axes = payload.get("axes") + if raw_axes is None: + return {} + if not isinstance(raw_axes, dict): + raise RuntimeError("Native PPTX chart axes must be an object") + + unknown_roles = set(raw_axes) - allowed_roles + if unknown_roles: + roles = ", ".join(sorted(unknown_roles)) + raise RuntimeError(f"Native PPTX chart axes contains unsupported role(s): {roles}") + + axes: dict[str, dict[str, Any]] = {} + for role, raw_config in raw_axes.items(): + if not isinstance(raw_config, dict): + raise RuntimeError(f"Native PPTX chart axes.{role} must be an object") + allowed_fields = { + "kind", "label_position", "major_gridlines", "major_unit", + "maximum", "minimum", "number_format", "position", "reverse", + "visible", + } + unknown_fields = set(raw_config) - allowed_fields + if unknown_fields: + fields = ", ".join(sorted(unknown_fields)) + raise RuntimeError( + f"Native PPTX chart axes.{role} contains unsupported field(s): {fields}" + ) + default_kind, default_position = _AXIS_ROLE_DEFAULTS[role] + kind = _compact_key(raw_config.get("kind") or default_kind) + if kind not in {"date", "text", "value"}: + raise RuntimeError( + f"Native PPTX chart axes.{role}.kind must be date, text, or value" + ) + if role in {"category", "secondary_category"} and kind not in {"date", "text"}: + raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be date or text") + if role in {"value", "secondary_value", "x", "y"} and kind != "value": + raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be value") + + position_aliases = { + "b": "bottom", + "bottom": "bottom", + "l": "left", + "left": "left", + "r": "right", + "right": "right", + "t": "top", + "top": "top", + } + position = position_aliases.get( + _compact_key(raw_config.get("position") or default_position) + ) + if position is None: + raise RuntimeError( + f"Native PPTX chart axes.{role}.position must be bottom, left, right, or top" + ) + allowed_positions = ( + {"bottom", "top"} + if role in {"category", "secondary_category", "x"} + else {"left", "right"} + ) + if position not in allowed_positions: + choices = ", ".join(sorted(allowed_positions)) + raise RuntimeError( + f"Native PPTX chart axes.{role}.position must be one of: {choices}" + ) + + config: dict[str, Any] = {"kind": kind, "position": position} + for field in ("visible", "reverse", "major_gridlines"): + value = raw_config.get(field) + if value is None: + continue + if not isinstance(value, bool): + raise RuntimeError( + f"Native PPTX chart axes.{role}.{field} must be a boolean" + ) + config[field] = value + + raw_label_position = raw_config.get("label_position") + if raw_label_position is not None: + label_aliases = { + "high": "high", + "low": "low", + "nextto": "next_to", + "none": "none", + } + label_position = label_aliases.get(_compact_key(raw_label_position)) + if label_position is None: + raise RuntimeError( + f"Native PPTX chart axes.{role}.label_position must be one of: " + "high, low, next_to, none" + ) + config["label_position"] = label_position + + raw_number_format = raw_config.get("number_format") + if raw_number_format is not None: + if not isinstance(raw_number_format, str): + raise RuntimeError( + f"Native PPTX chart axes.{role}.number_format must be a string" + ) + if not raw_number_format.strip(): + raise RuntimeError( + f"Native PPTX chart axes.{role}.number_format must be non-empty" + ) + config["number_format"] = raw_number_format + + for field in ("minimum", "maximum", "major_unit"): + value = raw_config.get(field) + if value is None: + continue + number = _chart_number(value) + if field == "major_unit": + if role not in {"value", "secondary_value", "x", "y"}: + raise RuntimeError( + f"Native PPTX chart axes.{role}.major_unit is unsupported" + ) + if number <= 0: + raise RuntimeError( + f"Native PPTX chart axes.{role}.major_unit must be positive" + ) + config[field] = number + if ( + config.get("minimum") is not None + and config.get("maximum") is not None + and config["minimum"] >= config["maximum"] + ): + raise RuntimeError( + f"Native PPTX chart axes.{role}.minimum must be less than maximum" + ) + axes[role] = config + return axes + + +def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool: + return axes.get("category", {}).get("kind") == "date" + def _chart_kind(payload: dict[str, Any]) -> tuple[str, str | None, str | None]: raw_type = payload.get("type") or payload.get("chart_type") or "column" @@ -440,7 +589,7 @@ def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str, return style -def _category_series(payload: dict[str, Any], categories: list[str]) -> list[dict[str, Any]]: +def _category_series(payload: dict[str, Any], categories: list[Any]) -> list[dict[str, Any]]: raw_series = payload.get("series", []) if not categories or not isinstance(raw_series, list) or not raw_series: raise RuntimeError("Native PPTX chart requires non-empty categories and series") @@ -506,7 +655,21 @@ def _category_chart_data( alias_grouping: str | None, alias_style: str | None, ) -> dict[str, Any]: - categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")] + axes = _chart_axes(payload, {"category", "value"}) + if axes and chart_type in {"bar", "doughnut", "of_pie", "pie"}: + raise RuntimeError( + f"Native PPTX {chart_type} chart axes are outside current support" + ) + if _category_axis_is_date(axes) and chart_type != "area": + raise RuntimeError( + "Native PPTX date category axes are currently supported for area charts only" + ) + raw_categories = _chart_list(payload.get("categories", []), "categories") + categories = ( + [_chart_number(item) for item in raw_categories] + if _category_axis_is_date(axes) + else [str(item) for item in raw_categories] + ) style = payload.get("style") if isinstance(payload.get("style"), dict) else {} series = _category_series(payload, categories) @@ -571,6 +734,7 @@ def _category_chart_data( grouping, len(categories), ), + "axes": axes, "series": series, } @@ -615,10 +779,33 @@ def _plot_series_area_style(plot_payload: dict[str, Any]) -> bool: return False +def _combo_series_indices( + plot_payload: dict[str, Any], + series_count: int, +) -> list[int] | None: + raw_indices = plot_payload.get("series_indices") + if raw_indices is None: + return None + indices: list[int] = [] + for value in _chart_list(raw_indices, "plots[].series_indices"): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise RuntimeError( + "Native PPTX combo series_indices must contain non-negative integers" + ) + indices.append(value) + if len(indices) != series_count or len(set(indices)) != len(indices): + raise RuntimeError( + "Native PPTX combo series_indices must be unique and match series length" + ) + return indices + + def _combo_plot_entry( plot_payload: dict[str, Any], - categories: list[str], + categories: list[Any], *, + category_is_numeric: bool, + axes: dict[str, dict[str, Any]], fallback_series: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: chart_type, alias_grouping, alias_style = _combo_plot_type(plot_payload) @@ -626,31 +813,82 @@ def _combo_plot_entry( raise RuntimeError( "Native PPTX combo line plot with series fill_opacity requires area_fill: true" ) - plot_series = fallback_series or _category_series(plot_payload, categories) + axis = _combo_axis_name(plot_payload) + category_role = "secondary_category" if axis == "secondary" else "category" + axis_is_date = axes.get(category_role, {}).get("kind") == "date" + raw_numeric = plot_payload.get("category_numeric") + if raw_numeric is not None and not isinstance(raw_numeric, bool): + raise RuntimeError( + "Native PPTX combo plot category_numeric must be a boolean" + ) + if axis_is_date and raw_numeric is False: + raise RuntimeError( + "Native PPTX combo date-axis categories must remain numeric" + ) + plot_category_is_numeric = axis_is_date or ( + raw_numeric if raw_numeric is not None else category_is_numeric + ) + raw_plot_categories = plot_payload.get("categories") + category_items = ( + categories + if raw_plot_categories is None + else _chart_list(raw_plot_categories, "plots[].categories") + ) + plot_categories = ( + [_chart_number(item) for item in category_items] + if plot_category_is_numeric + else [str(item) for item in category_items] + ) + if not plot_categories: + raise RuntimeError("Native PPTX combo plot categories must be non-empty") + plot_series = fallback_series or _category_series(plot_payload, plot_categories) grouping = ( _chart_grouping(chart_type, plot_payload, alias_grouping) if chart_type in {"area", "column", "line"} else None ) entry: dict[str, Any] = { - "axis": _combo_axis_name(plot_payload), + "axis": axis, + "categories": plot_categories, + "category_is_numeric": plot_category_is_numeric, "data_labels": _chart_data_labels( plot_payload, chart_type, grouping, - len(categories), + len(plot_categories), ), "grouping": grouping, "series": plot_series, "type": chart_type, } + series_indices = _combo_series_indices(plot_payload, len(plot_series)) + if series_indices is not None: + entry["series_indices"] = series_indices if chart_type == "line": entry["line_style"] = _line_style(plot_payload, alias_style) return entry def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]: - categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")] + axes = _chart_axes( + payload, + {"category", "secondary_category", "secondary_value", "value"}, + ) + raw_category_numeric = payload.get("category_numeric") + if raw_category_numeric is not None and not isinstance(raw_category_numeric, bool): + raise RuntimeError("Native PPTX combo category_numeric must be a boolean") + primary_axis_is_date = _category_axis_is_date(axes) + if primary_axis_is_date and raw_category_numeric is False: + raise RuntimeError("Native PPTX combo date-axis categories must remain numeric") + category_is_numeric = primary_axis_is_date or raw_category_numeric is True + raw_categories = _chart_list(payload.get("categories", []), "categories") + categories = ( + [_chart_number(item) for item in raw_categories] + if category_is_numeric + else [str(item) for item in raw_categories] + ) + if not categories: + raise RuntimeError("Native PPTX combo chart categories must be non-empty") raw_plots = payload.get("plots", payload.get("chart_plots")) plots: list[dict[str, Any]] = [] @@ -658,7 +896,12 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]: for item in _chart_list(raw_plots, "plots"): if not isinstance(item, dict): raise RuntimeError("Native PPTX combo plots must be objects") - plots.append(_combo_plot_entry(item, categories)) + plots.append(_combo_plot_entry( + item, + categories, + category_is_numeric=category_is_numeric, + axes=axes, + )) else: raw_series = _chart_list(payload.get("series", []), "series") if not raw_series: @@ -668,8 +911,22 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]: raise RuntimeError("Native PPTX chart series entries must be objects") if not (item.get("type") or item.get("chart_type")): raise RuntimeError("Native PPTX combo series entries require type") + if any( + field in item + for field in ("categories", "category_numeric", "series_indices") + ): + raise RuntimeError( + "Native PPTX combo typed series with plot-scoped metadata " + "must use plots" + ) one_series = _category_series({"series": [item]}, categories) - plot = _combo_plot_entry(item, categories, fallback_series=one_series) + plot = _combo_plot_entry( + item, + categories, + category_is_numeric=category_is_numeric, + axes=axes, + fallback_series=one_series, + ) signature = ( plot["axis"], plot.get("grouping"), @@ -694,15 +951,52 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]: if not plots: raise RuntimeError("Native PPTX combo chart requires at least one plot") + if not any(plot["axis"] == "primary" for plot in plots): + raise RuntimeError("Native PPTX combo chart requires a primary-axis plot") + has_secondary_plot = any(plot["axis"] == "secondary" for plot in plots) + if not has_secondary_plot and { + "secondary_category", "secondary_value", + }.intersection(axes): + raise RuntimeError( + "Native PPTX combo secondary axes require a secondary-axis plot" + ) + series_index_groups = [plot.get("series_indices") for plot in plots] + if any(group is not None for group in series_index_groups): + if any(group is None for group in series_index_groups): + raise RuntimeError( + "Native PPTX combo series_indices must cover every plot" + ) + flat_indices = [ + index + for group in series_index_groups + for index in group + ] + if sorted(flat_indices) != list(range(len(flat_indices))): + raise RuntimeError( + "Native PPTX combo series_indices must form one contiguous range" + ) flat_series: list[dict[str, Any]] = [] + independent_categories = any( + plot["categories"] != categories + or plot["category_is_numeric"] != category_is_numeric + for plot in plots + ) + next_column = 1 for plot in plots: plot["start_index"] = len(flat_series) + if independent_categories: + plot["category_column"] = next_column + plot["start_column"] = next_column + 1 + next_column += len(plot["series"]) + 1 flat_series.extend(plot["series"]) if not flat_series: raise RuntimeError("Native PPTX combo chart requires at least one series") return { + "axes": axes, "categories": categories, + "category_is_numeric": category_is_numeric, + "independent_categories": independent_categories, "kind": "combo", "plots": plots, "series": flat_series, @@ -866,6 +1160,9 @@ def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, A def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]: if _data_labels_config(payload) is not None: raise RuntimeError("Native PPTX stock chart data labels are outside current support") + axes = _chart_axes(payload, {"category", "value"}) + if "category" in axes and not _category_axis_is_date(axes): + raise RuntimeError("Native PPTX stock chart category axis must be date") categories = [ _chart_number(item) for item in _chart_list(payload.get("categories", payload.get("dates", [])), "categories") @@ -884,6 +1181,7 @@ def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]: if len(series) != 4: raise RuntimeError("Native PPTX stock chart requires exactly four series: open, high, low, close") return { + "axes": axes, "categories": categories, "kind": "category", "series": series, @@ -911,6 +1209,7 @@ def _xy_chart_data( raise RuntimeError( f"Native PPTX {chart_type} chart data labels are outside current support" ) + axes = _chart_axes(payload, {"x", "y"}) raw_series = payload.get("series", []) if not isinstance(raw_series, list) or not raw_series: raise RuntimeError("Native PPTX XY chart requires non-empty series") @@ -966,6 +1265,7 @@ def _xy_chart_data( if chart_type == "scatter" and scatter_style not in style_aliases: raise RuntimeError("Native PPTX scatter_style is unsupported") return { + "axes": axes, "kind": "xy", "type": chart_type, "scatter_style": style_aliases.get(scatter_style, "marker"), diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py index d6500514..12cadb4c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py @@ -8,6 +8,7 @@ from xml.etree import ElementTree as ET from ..drawingml.utils import detect_text_lang, _xml_escape from .chart_data import ( _DEFAULT_CHART_COLORS, + _category_axis_is_date, _chart_list, _data_label_position, _data_label_point_items, @@ -51,17 +52,42 @@ def _string_cache(values: list[str]) -> str: return f'{points}' -def _number_cache(values: list[int | float]) -> str: +def _number_cache( + values: list[int | float], + number_format: str = "General", +) -> str: points = "".join( f'{value}' for idx, value in enumerate(values) ) return ( - 'General' + f'{_xml_escape(number_format)}' f'{points}' ) +def _category_reference_xml( + categories: list[Any], + *, + column_index: int = 1, + numeric: bool, + number_format: str | None = None, +) -> str: + reference_tag = "numRef" if numeric else "strRef" + cache = ( + _number_cache(categories, number_format or "General") + if numeric + else _string_cache([str(value) for value in categories]) + ) + return ( + f"" + f"Sheet1!${_excel_col(column_index)}$2:" + f"${_excel_col(column_index)}${len(categories) + 1}" + f"{cache}" + f"" + ) + + def _series_color_xml( color: str | None, *, @@ -273,7 +299,7 @@ def _marker_xml(symbol: str | None) -> str: def _series_xml( - categories: list[str], + categories: list[Any], series: list[dict[str, Any]], *, chart_type: str, @@ -282,21 +308,41 @@ def _series_xml( radar_marker_style: str | None = None, radar_style: str = "marker", colors: list[str], + category_is_numeric: bool = False, + category_number_format: str | None = None, data_labels: dict[str, Any] | None = None, data_label_font_size: int = 900, data_label_color: str | None = None, data_label_font_face: str | None = None, + category_column: int = 1, + color_start_index: int | None = None, + series_indices: list[int] | None = None, start_column: int = 2, start_index: int = 0, ) -> str: parts: list[str] = [] + category_xml = _category_reference_xml( + categories, + column_index=category_column, + numeric=category_is_numeric, + number_format=category_number_format, + ) for offset, item in enumerate(series): - index = start_index + offset + index = ( + series_indices[offset] + if series_indices is not None + else start_index + offset + ) + color_index = ( + color_start_index + if color_start_index is not None + else start_index + ) + offset column_index = offset + start_column fill_opacity = item.get("fill_opacity") if chart_type == "area" else None line_width = item.get("line_width") if chart_type in {"area", "line"} else None color_xml = _series_color_xml( - _chart_color(colors, index), + _chart_color(colors, color_index), fill_opacity=fill_opacity, line_width=line_width, ) @@ -325,7 +371,10 @@ def _series_xml( smooth_xml = '' if chart_type == "radar": if radar_style == "filled": - color_xml = _series_color_xml(_chart_color(colors, index), line=False) + color_xml = _series_color_xml( + _chart_color(colors, color_index), + line=False, + ) marker_xml = _marker_xml(radar_marker_style) invert_xml = '' if chart_type in {"bar", "column"} else "" data_labels_xml = ( @@ -351,10 +400,7 @@ def _series_xml( "" f"{color_xml}{invert_xml}{marker_xml}{point_colors_xml}" f"{data_labels_xml}" - "" - f"Sheet1!$A$2:$A${len(categories) + 1}" - f"{_string_cache(categories)}" - "" + f"{category_xml}" "" f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}" f"{_number_cache(item['values'])}" @@ -573,6 +619,165 @@ def _line_area_chart_group_xml( ) +def _axis_scaling_xml(config: dict[str, Any]) -> str: + orientation = "maxMin" if config.get("reverse") else "minMax" + maximum = ( + f'' + if config.get("maximum") is not None else "" + ) + minimum = ( + f'' + if config.get("minimum") is not None else "" + ) + return f'{maximum}{minimum}' + + +def _axis_position(config: dict[str, Any], default: str) -> str: + return { + "bottom": "b", + "left": "l", + "right": "r", + "top": "t", + }.get(str(config.get("position") or ""), default) + + +def _axis_label_position(config: dict[str, Any], default: str) -> str: + return { + "high": "high", + "low": "low", + "next_to": "nextTo", + "none": "none", + }.get(str(config.get("label_position") or ""), default) + + +def _axis_number_format_xml( + config: dict[str, Any], + default: str | None = None, +) -> str: + number_format = config.get("number_format", default) + if number_format is None: + return "" + return f'' + + +def _axis_major_gridlines_xml( + config: dict[str, Any], + *, + default: bool, + color: str | None, +) -> str: + enabled = config.get("major_gridlines", default) + return _major_gridlines_xml(color) if enabled else "" + + +def _axis_pair_xml( + cat_ax_id: str, + val_ax_id: str, + *, + axis_font_size: int, + axis_title_font_size: int, + axis_titles: dict[str, Any], + chart_style: dict[str, str | None], + chart_type: str, + grouping: str | None, + show_value_axis_labels: bool, + axes: dict[str, dict[str, Any]], + secondary: bool, +) -> str: + category_role = "secondary_category" if secondary else "category" + value_role = "secondary_value" if secondary else "value" + category = axes.get(category_role, {}) + value = axes.get(value_role, {}) + category_kind = str(category.get("kind") or ("date" if chart_type == "stock" else "text")) + category_tag = "dateAx" if category_kind == "date" else "catAx" + default_cat_pos = "l" if chart_type == "bar" else "b" + default_val_pos = "r" if secondary else ("b" if chart_type == "bar" else "l") + cat_pos = _axis_position(category, default_cat_pos) + val_pos = _axis_position(value, default_val_pos) + cat_delete = _bool_attr(not category.get("visible", not secondary)) + val_delete = _bool_attr(not value.get("visible", True)) + cat_tick_label_pos = _axis_label_position(category, "nextTo") + default_val_tick = "nextTo" if show_value_axis_labels else "none" + val_tick_label_pos = _axis_label_position(value, default_val_tick) + default_value_format = "0%" if grouping == "percentStacked" else None + cat_number_format = _axis_number_format_xml( + category, + "m/d/yyyy" if category_kind == "date" else None, + ) + val_number_format = _axis_number_format_xml(value, default_value_format) + axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) + axis_tx_pr = _chart_tx_pr_xml( + axis_font_size, + chart_style.get("text_color"), + font_face=chart_style.get("font_face"), + ) + cat_title_xml = "" if secondary else _axis_title_xml( + _first_present(axis_titles.get("category"), axis_titles.get("x")), + font_size=axis_title_font_size, + color=chart_style.get("text_color"), + font_face=chart_style.get("font_face"), + ) + value_title_key = "secondary_value" if secondary else "value" + value_title = axis_titles.get(value_title_key) + if not secondary: + value_title = _first_present(value_title, axis_titles.get("y")) + val_title_xml = _axis_title_xml( + value_title, + font_size=axis_title_font_size, + color=chart_style.get("text_color"), + font_face=chart_style.get("font_face"), + ) + cat_gridlines = _axis_major_gridlines_xml( + category, + default=False, + color=chart_style.get("grid_color"), + ) + val_gridlines = _axis_major_gridlines_xml( + value, + default=not secondary, + color=chart_style.get("grid_color"), + ) + if category_kind == "date": + category_tail = '' + else: + category_tail = ( + '' + '' + ) + is_combo = chart_type == "combo" + cross_between = "" + if chart_type == "area" and category_kind == "date": + cross_between = '' + elif chart_type == "stock" or is_combo: + cross_between = '' + major_unit = ( + f'' + if value.get("major_unit") is not None else "" + ) + value_crosses = "max" if secondary else "autoZero" + return ( + f"" + f'{_axis_scaling_xml(category)}' + f'' + f"{cat_gridlines}{cat_title_xml}{cat_number_format}" + '' + f'' + f"{axis_sp_pr}{axis_tx_pr}" + f'{category_tail}' + f"" + "" + f'{_axis_scaling_xml(value)}' + f'' + f"{val_gridlines}{val_title_xml}{val_number_format}" + '' + f'' + f"{axis_sp_pr}{axis_tx_pr}" + f'' + f"{cross_between}{major_unit}" + "" + ) + + def _secondary_axis_xml( cat_ax_id: str, val_ax_id: str, @@ -582,42 +787,20 @@ def _secondary_axis_xml( axis_titles: dict[str, Any], chart_style: dict[str, str | None], grouping: str | None = None, + axes: dict[str, dict[str, Any]] | None = None, ) -> str: - val_num_fmt = ( - '' - if grouping == "percentStacked" - else "" - ) - axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) - axis_tx_pr = _chart_tx_pr_xml( - axis_font_size, - chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - val_title_xml = _axis_title_xml( - axis_titles.get("secondary_value"), - font_size=axis_title_font_size, - color=chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - # Hidden secondary category axis controls secondary area fill baseline. - return ( - "" - f'' - '' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - '' - "" - "" - f'' - f'{val_title_xml}{val_num_fmt}' - '' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - "" + return _axis_pair_xml( + cat_ax_id, + val_ax_id, + axis_font_size=axis_font_size, + axis_title_font_size=axis_title_font_size, + axis_titles=axis_titles, + chart_style=chart_style, + chart_type="combo", + grouping=grouping, + show_value_axis_labels=True, + axes=axes or {}, + secondary=True, ) @@ -645,7 +828,7 @@ def _combo_plot_xml( axis_titles: dict[str, Any], chart_style: dict[str, str | None], ) -> str: - categories = chart_data["categories"] + axes = chart_data.get("axes") or {} primary_cat_ax_id = "2068027336" primary_val_ax_id = "2113994440" secondary_cat_ax_id = "2080229232" @@ -653,10 +836,14 @@ def _combo_plot_xml( parts: list[str] = [] for plot in sorted(chart_data["plots"], key=_combo_plot_layer): + categories = plot["categories"] + category_is_numeric = bool(plot.get("category_is_numeric")) chart_type = plot["type"] axis = plot.get("axis", "primary") cat_ax_id = secondary_cat_ax_id if axis == "secondary" else primary_cat_ax_id val_ax_id = secondary_val_ax_id if axis == "secondary" else primary_val_ax_id + category_role = "secondary_category" if axis == "secondary" else "category" + category_number_format = axes.get(category_role, {}).get("number_format") start_index = int(plot.get("start_index", 0)) grouping = plot.get("grouping") or ("clustered" if chart_type == "column" else "standard") ser_xml = _series_xml( @@ -665,12 +852,17 @@ def _combo_plot_xml( chart_type=chart_type, grouping=grouping, colors=colors, + category_is_numeric=category_is_numeric, + category_number_format=category_number_format, data_labels=_data_labels_config(plot), data_label_font_size=axis_font_size, data_label_color=chart_style.get("text_color"), data_label_font_face=chart_style.get("font_face"), line_style=plot.get("line_style", "line"), - start_column=2 + start_index, + category_column=int(plot.get("category_column", 1)), + color_start_index=start_index, + series_indices=plot.get("series_indices"), + start_column=int(plot.get("start_column", 2 + start_index)), start_index=start_index, ) data_labels = _data_labels_config(plot) @@ -717,8 +909,9 @@ def _combo_plot_xml( axis_title_font_size=axis_title_font_size, axis_titles=axis_titles, chart_style=chart_style, - chart_type="column", + chart_type="combo", grouping=_combo_axis_grouping(chart_data["plots"], "primary"), + axes=axes, ) if has_secondary_axis: axes_xml += _secondary_axis_xml( @@ -729,6 +922,7 @@ def _combo_plot_xml( axis_titles=axis_titles, chart_style=chart_style, grouping=_combo_axis_grouping(chart_data["plots"], "secondary"), + axes=axes, ) return "".join(parts) + axes_xml @@ -772,6 +966,7 @@ def _chart_plot_xml( axis_title_font_size=axis_title_font_size, axis_titles=axis_titles, chart_style=chart_style, + axes=chart_data.get("axes") or {}, ) return ( f'' @@ -788,6 +983,7 @@ def _chart_plot_xml( axis_title_font_size=axis_title_font_size, axis_titles=axis_titles, chart_style=chart_style, + axes=chart_data.get("axes") or {}, ) return ( '' @@ -803,6 +999,19 @@ def _chart_plot_xml( if chart_type == "stock": stock_cat_ax_id = "2068027336" stock_val_ax_id = "2113994440" + stock_axes = chart_data.get("axes") or {} + stock_category_format = None + if stock_axes: + stock_category_format = ( + stock_axes.get("category", {}).get("number_format") + or "m/d/yyyy" + ) + stock_series_xml = _stock_series_xml( + categories, + series, + colors=colors, + category_number_format=stock_category_format, + ) axes_xml = _stock_axis_xml( stock_cat_ax_id, stock_val_ax_id, @@ -810,10 +1019,11 @@ def _chart_plot_xml( axis_title_font_size=axis_title_font_size, axis_titles=axis_titles, chart_style=chart_style, + axes=stock_axes, ) return ( "" - f"{_stock_series_xml(categories, series, colors=colors)}" + f"{stock_series_xml}" '' '' f'' @@ -832,6 +1042,17 @@ def _chart_plot_xml( radar_marker_style=chart_data.get("radar_marker_style"), radar_style=chart_data.get("radar_style", "marker"), colors=colors, + category_is_numeric=_category_axis_is_date(chart_data.get("axes") or {}), + category_number_format=( + (chart_data.get("axes") or {}) + .get("category", {}) + .get( + "number_format", + "m/d/yyyy" + if _category_axis_is_date(chart_data.get("axes") or {}) + else None, + ) + ), data_labels=chart_data.get("data_labels"), data_label_font_size=axis_font_size, data_label_color=chart_style.get("text_color"), @@ -865,6 +1086,7 @@ def _chart_plot_xml( chart_type=chart_type, grouping=grouping, show_value_axis_labels=chart_data.get("show_value_axis_labels", True), + axes=chart_data.get("axes") or {}, ) overlap_xml = ( '' @@ -901,6 +1123,7 @@ def _chart_plot_xml( chart_type=chart_type, grouping=grouping, show_value_axis_labels=chart_data.get("show_value_axis_labels", True), + axes=chart_data.get("axes") or {}, ) line_tail_xml = '' if chart_type == "line" else "" return ( @@ -939,6 +1162,7 @@ def _chart_plot_xml( chart_style=chart_style, chart_type=chart_type, show_value_axis_labels=chart_data.get("show_value_axis_labels", True), + axes=chart_data.get("axes") or {}, ) return ( f'' @@ -962,51 +1186,20 @@ def _axis_xml( chart_type: str, grouping: str | None = None, show_value_axis_labels: bool = True, + axes: dict[str, dict[str, Any]] | None = None, ) -> str: - cat_pos = "l" if chart_type == "bar" else "b" - val_pos = "b" if chart_type == "bar" else "l" - val_num_fmt = ( - '' - if grouping == "percentStacked" - else "" - ) - val_tick_label_pos = "nextTo" if show_value_axis_labels else "none" - axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) - axis_tx_pr = _chart_tx_pr_xml( - axis_font_size, - chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - cat_title_xml = _axis_title_xml( - _first_present(axis_titles.get("category"), axis_titles.get("x")), - font_size=axis_title_font_size, - color=chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - val_title_xml = _axis_title_xml( - _first_present(axis_titles.get("value"), axis_titles.get("y")), - font_size=axis_title_font_size, - color=chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - return ( - "" - f'' - f'{cat_title_xml}' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - '' - "" - "" - f'' - f'{_major_gridlines_xml(chart_style.get("grid_color"))}' - f"{val_title_xml}{val_num_fmt}" - '' - f'' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - "" + return _axis_pair_xml( + cat_ax_id, + val_ax_id, + axis_font_size=axis_font_size, + axis_title_font_size=axis_title_font_size, + axis_titles=axis_titles, + chart_style=chart_style, + chart_type=chart_type, + grouping=grouping, + show_value_axis_labels=show_value_axis_labels, + axes=axes or {}, + secondary=False, ) @@ -1018,7 +1211,11 @@ def _xy_axis_xml( axis_title_font_size: int, axis_titles: dict[str, Any], chart_style: dict[str, str | None], + axes: dict[str, dict[str, Any]] | None = None, ) -> str: + normalized_axes = axes or {} + x_axis = normalized_axes.get("x", {}) + y_axis = normalized_axes.get("y", {}) axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) axis_tx_pr = _chart_tx_pr_xml( axis_font_size, @@ -1037,24 +1234,56 @@ def _xy_axis_xml( color=chart_style.get("text_color"), font_face=chart_style.get("font_face"), ) - return ( - "" - f'' - f'{x_title_xml}' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - '' - "" - "" - f'' - f'{_major_gridlines_xml(chart_style.get("grid_color"))}' - f'{y_title_xml}' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - '' - "" + + def value_axis_xml( + axis_id: str, + cross_axis_id: str, + config: dict[str, Any], + *, + default_position: str, + default_gridlines: bool, + title_xml: str, + ) -> str: + delete = _bool_attr(not config.get("visible", True)) + position = _axis_position(config, default_position) + gridlines = _axis_major_gridlines_xml( + config, + default=default_gridlines, + color=chart_style.get("grid_color"), + ) + number_format = _axis_number_format_xml(config) + tick_label_position = _axis_label_position(config, "nextTo") + major_unit = ( + f'' + if config.get("major_unit") is not None else "" + ) + return ( + "" + f'{_axis_scaling_xml(config)}' + f'' + f"{gridlines}{title_xml}{number_format}" + '' + f'' + f"{axis_sp_pr}{axis_tx_pr}" + f'' + f'{major_unit}' + "" + ) + + return value_axis_xml( + x_ax_id, + y_ax_id, + x_axis, + default_position="b", + default_gridlines=False, + title_xml=x_title_xml, + ) + value_axis_xml( + y_ax_id, + x_ax_id, + y_axis, + default_position="l", + default_gridlines=True, + title_xml=y_title_xml, ) @@ -1063,6 +1292,7 @@ def _stock_series_xml( series: list[dict[str, Any]], *, colors: list[str], + category_number_format: str | None = None, ) -> str: parts: list[str] = [] for index, item in enumerate(series): @@ -1078,7 +1308,7 @@ def _stock_series_xml( '' "" f"Sheet1!$A$2:$A${len(categories) + 1}" - f"{_number_cache(categories)}" + f"{_number_cache(categories, category_number_format or 'General')}" "" "" f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}" @@ -1098,44 +1328,22 @@ def _stock_axis_xml( axis_title_font_size: int, axis_titles: dict[str, Any], chart_style: dict[str, str | None], + axes: dict[str, dict[str, Any]] | None = None, ) -> str: - axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) - axis_tx_pr = _chart_tx_pr_xml( - axis_font_size, - chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - cat_title_xml = _axis_title_xml( - _first_present(axis_titles.get("category"), axis_titles.get("x")), - font_size=axis_title_font_size, - color=chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - val_title_xml = _axis_title_xml( - _first_present(axis_titles.get("value"), axis_titles.get("y")), - font_size=axis_title_font_size, - color=chart_style.get("text_color"), - font_face=chart_style.get("font_face"), - ) - return ( - "" - f'' - '' - f'{cat_title_xml}' - '' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - '' - "" - "" - f'' - f'{_major_gridlines_xml(chart_style.get("grid_color"))}' - f'{val_title_xml}' - '' - f"{axis_sp_pr}{axis_tx_pr}" - f'' - "" + normalized_axes = dict(axes or {}) + normalized_axes.setdefault("category", {"kind": "date", "position": "bottom"}) + return _axis_pair_xml( + cat_ax_id, + val_ax_id, + axis_font_size=axis_font_size, + axis_title_font_size=axis_title_font_size, + axis_titles=axis_titles, + chart_style=chart_style, + chart_type="stock", + grouping=None, + show_value_axis_labels=True, + axes=normalized_axes, + secondary=False, ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chartex.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chartex.py index 09234c88..e0577d88 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chartex.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chartex.py @@ -10,8 +10,10 @@ from .marker_common import ( CHART_COLOR_STYLE_REL_TYPE, CHART_STYLE_REL_TYPE, PACKAGE_REL_TYPE, + _clean_hex, _compact_key, _excel_col, + _first_present, ) @@ -240,11 +242,31 @@ def _chart_ex_style_xml() -> bytes: return b'''''' -def _chart_ex_colors_xml() -> bytes: - return b''' +def _chart_ex_colors_xml(payload: dict[str, Any]) -> bytes: + """Return a ChartEx color style that consumes the marker palette when present.""" + style = payload.get("style") if isinstance(payload.get("style"), dict) else {} + raw_colors = _first_present(style.get("colors"), payload.get("colors")) + colors = ( + [_clean_hex(color, "#4472C4") for color in raw_colors] + if isinstance(raw_colors, list) + else [] + ) + if not colors: + return b''' ''' + color_entries = "".join( + f'' + for color in colors + ) + xml = f''' + +{color_entries} +''' + return xml.encode("utf-8") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/fallback_hash.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/fallback_hash.py new file mode 100644 index 00000000..04b19251 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/fallback_hash.py @@ -0,0 +1,154 @@ +"""Visible-fallback fingerprint contract for native chart/table markers.""" + +from __future__ import annotations + +import re +import secrets +from xml.etree import ElementTree as ET + +from pptx_shapes import ( + NATIVE_FALLBACK_SHA256_ATTR, + svg_native_fallback_fingerprint, +) + + +NATIVE_FALLBACK_RUNTIME_ATTR = "data-pptx-runtime-fallback-unchanged" +_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR = "data-pptx-runtime-fallback-token" +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_RUNTIME_TOKEN = secrets.token_hex(16) + + +def stamp_native_fallback_baseline( + elem: ET.Element, + *, + document_root: ET.Element | None = None, +) -> str: + """Record the current canonical visible-subtree hash on one marker.""" + digest = svg_native_fallback_fingerprint( + elem, + document_root=document_root, + ) + elem.set(NATIVE_FALLBACK_SHA256_ATTR, digest) + elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None) + elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None) + return digest + + +def snapshot_native_fallback_freshness(root: ET.Element) -> None: + """Snapshot raw marker freshness before exporter preprocessing mutates SVG.""" + for elem in root.iter(): + if elem.tag.rsplit("}", 1)[-1] == "metadata": + continue + if not (elem.get("data-pptx-native") or "").strip(): + continue + expected, invalid = _expected_native_fallback_hash(elem) + if invalid: + elem.set(NATIVE_FALLBACK_RUNTIME_ATTR, "invalid") + elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN) + elif expected is None: + elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None) + elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None) + else: + actual = svg_native_fallback_fingerprint( + elem, + document_root=root, + ) + elem.set( + NATIVE_FALLBACK_RUNTIME_ATTR, + "1" if actual == expected else "0", + ) + elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN) + + +def native_fallback_contract_warnings( + elem: ET.Element, + *, + use_runtime_snapshot: bool = False, + document_root: ET.Element | None = None, +) -> list[str]: + """Return non-blocking diagnostics for default/checker compatibility.""" + expected, invalid = _expected_native_fallback_hash(elem) + if invalid: + return [ + f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256; " + "default SVG fallback export remains available, but " + "--native-objects will fail" + ] + if expected is None: + return [ + f"has no {NATIVE_FALLBACK_SHA256_ATTR} baseline; legacy marker " + "remains native-compatible, but stale fallback edits cannot be detected" + ] + if _native_fallback_is_fresh( + elem, + expected, + use_runtime_snapshot=use_runtime_snapshot, + document_root=document_root, + ): + return [] + return [ + "visible SVG fallback differs from its recorded baseline; default " + "fallback export remains available, but --native-objects will fail" + ] + + +def require_fresh_native_fallback( + elem: ET.Element, + *, + use_runtime_snapshot: bool = False, + document_root: ET.Element | None = None, +) -> None: + """Fail the editable replacement route when a recorded fallback is stale.""" + expected, invalid = _expected_native_fallback_hash(elem) + if invalid: + raise RuntimeError( + f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256" + ) + if expected is None: + return + if _native_fallback_is_fresh( + elem, + expected, + use_runtime_snapshot=use_runtime_snapshot, + document_root=document_root, + ): + return + raise RuntimeError( + "Visible native-object SVG fallback was edited after its baseline was " + "recorded; --native-objects stopped to avoid discarding the SVG edit. " + "Use the default fallback export, or deliberately update the native " + "metadata and baseline together" + ) + + +def _expected_native_fallback_hash( + elem: ET.Element, +) -> tuple[str | None, bool]: + raw = elem.get(NATIVE_FALLBACK_SHA256_ATTR) + if raw is None: + return None, False + if raw != raw.strip() or _SHA256_RE.fullmatch(raw) is None: + return None, True + return raw.lower(), False + + +def _native_fallback_is_fresh( + elem: ET.Element, + expected: str, + *, + use_runtime_snapshot: bool, + document_root: ET.Element | None, +) -> bool: + if ( + use_runtime_snapshot + and elem.get(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR) == _RUNTIME_TOKEN + ): + snapshot = elem.get(NATIVE_FALLBACK_RUNTIME_ATTR) + if snapshot == "1": + return True + if snapshot in {"0", "invalid"}: + return False + return svg_native_fallback_fingerprint( + elem, + document_root=document_root, + ) == expected diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/marker_status.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/marker_status.py new file mode 100644 index 00000000..05dbc3ed --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/marker_status.py @@ -0,0 +1,60 @@ +"""Validate native-object fallback and release-route status attributes.""" + +from __future__ import annotations + +from xml.etree import ElementTree as ET + + +VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"}) +ROUTE_STATUSES = frozenset({"reconstruction-only"}) + + +def native_marker_status_errors(elem: ET.Element) -> list[str]: + """Return invalid or contradictory native-object status declarations.""" + errors: list[str] = [] + visual_raw = elem.get("data-pptx-visual-status") + route_raw = elem.get("data-pptx-route-status") + native_raw = elem.get("data-pptx-native") + fallback_raw = elem.get("data-pptx-native-status") + + visual = visual_raw.strip() if visual_raw is not None else None + route = route_raw.strip() if route_raw is not None else None + native = native_raw.strip() if native_raw is not None else "" + fallback = fallback_raw.strip() if fallback_raw is not None else "" + + if visual_raw is not None and visual_raw != visual: + errors.append("data-pptx-visual-status must not contain surrounding whitespace") + if route_raw is not None and route_raw != route: + errors.append("data-pptx-route-status must not contain surrounding whitespace") + if visual is not None and visual not in VISUAL_STATUSES: + errors.append(f"unsupported data-pptx-visual-status value: {visual!r}") + if route is not None and route not in ROUTE_STATUSES: + errors.append(f"unsupported data-pptx-route-status value: {route!r}") + if visual == "placeholder" and route != "reconstruction-only": + errors.append( + "data-pptx-visual-status='placeholder' requires " + "data-pptx-route-status='reconstruction-only'" + ) + if route == "reconstruction-only" and visual != "placeholder": + errors.append( + "data-pptx-route-status='reconstruction-only' requires " + "data-pptx-visual-status='placeholder'" + ) + if native and fallback: + errors.append( + "data-pptx-native and data-pptx-native-status are mutually exclusive" + ) + return errors + + +def native_marker_release_block_reason(elem: ET.Element) -> str | None: + """Return invalid status metadata that must block an export. + + A valid ``reconstruction-only`` declaration is diagnostic rather than a + release block: default export keeps its visible placeholder, while an + active native marker may still reconstruct the editable object. + """ + errors = native_marker_status_errors(elem) + if errors: + return f"invalid-status: {errors[0]}" + return None diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py index f655fb35..78d5f4c6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py @@ -2,12 +2,13 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from xml.etree import ElementTree as ET from ..drawingml.context import ConvertContext, ShapeResult from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml -from ..drawingml.utils import _xml_escape +from ..drawingml.utils import _xml_escape, detect_text_lang, font_px_to_hpt from .chart_style import _font_face_xml from .marker_common import ( TABLE_URI, @@ -17,6 +18,7 @@ from .marker_common import ( _compact_key, _first_present, _font_size_hpt, + _hex_or_none, _normalized_fallback_text, _number, _powerpoint_emu, @@ -32,19 +34,48 @@ def _table_text_run( bold: bool | None, font_size: int | None, font_face: str | None, + language: str | None, theme_color_spec: ThemeColorSpec | None, + italic: bool | None = None, + underline: bool | None = None, + strike: bool | None = None, + alt_language: str | None = None, + exact_font_face: bool = False, ) -> str: size_attr = f' sz="{font_size}"' if font_size is not None else "" bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else "" + italic_attr = f' i="{_bool_attr(italic)}"' if italic is not None else "" + underline_attr = ( + f' u="{"sng" if underline else "none"}"' + if underline is not None else "" + ) + strike_attr = ( + f' strike="{"sngStrike" if strike else "noStrike"}"' + if strike is not None else "" + ) + resolved_language = language or detect_text_lang(text) + language_attr = f' lang="{_xml_escape(resolved_language)}"' + alt_language_attr = ( + f' altLang="{_xml_escape(alt_language)}"' if alt_language else "" + ) color_xml = ( f'{color_node_xml(color, theme_color_spec, "text")}' if color else "" ) + if exact_font_face and font_face: + escaped_face = _xml_escape(font_face) + font_xml = ( + f'' + f'' + ) + else: + font_xml = _font_face_xml(font_face) space_attr = ' xml:space="preserve"' if text != text.strip() else "" return ( - f'' + f'' f'{color_xml}' - f'{_font_face_xml(font_face)}' + f'{font_xml}' "" f"{_xml_escape(text)}" ) @@ -56,15 +87,17 @@ def _cell_payload(value: Any) -> dict[str, Any]: return {"text": "" if value is None else str(value)} -_TABLE_SPAN_KEYS = { +_TABLE_CANONICAL_SPAN_KEYS = { "col_span", + "row_span", +} +_TABLE_UNSUPPORTED_SPAN_KEYS = { "colSpan", "grid_span", "gridSpan", "hMerge", "merge", "merged", - "row_span", "rowSpan", "vMerge", } @@ -78,6 +111,42 @@ _TABLE_MAX_ROWS = 1000 _TABLE_MAX_COLUMNS = 1000 +@dataclass(frozen=True) +class _TableMergeRegion: + row: int + col: int + row_span: int + col_span: int + + +@dataclass(frozen=True) +class _TableBorderSpec: + style: str + color: str | None = None + width: float | None = None + + +@dataclass(frozen=True) +class _TableRun: + text: str + bold: bool | None = None + italic: bool | None = None + underline: bool | None = None + strike: bool | None = None + color: str | None = None + font_size: int | None = None + font_family: str | None = None + lang: str | None = None + alt_lang: str | None = None + + +@dataclass(frozen=True) +class _TableParagraph: + text: str + align: str | None = None + runs: tuple[_TableRun, ...] | None = None + + def _table_rows(payload: dict[str, Any]) -> list[list[Any]]: columns = payload.get("columns") or [] rows = payload.get("rows") or [] @@ -92,24 +161,265 @@ def _table_rows(payload: dict[str, Any]) -> list[list[Any]]: return table_rows -def _check_table_spans(payload: dict[str, Any], table_rows: list[list[Any]]) -> None: +def _table_cell_paragraphs( + cell_data: dict[str, Any], +) -> tuple[_TableParagraph, ...] | None: + if "paragraphs" not in cell_data: + return None + if "text" in cell_data: + raise RuntimeError( + "Native PPTX table cell text and paragraphs are mutually exclusive" + ) + raw_paragraphs = cell_data.get("paragraphs") + if not isinstance(raw_paragraphs, list) or not raw_paragraphs: + raise RuntimeError( + "Native PPTX table cell paragraphs must be a non-empty list" + ) + + paragraphs: list[_TableParagraph] = [] + for idx, value in enumerate(raw_paragraphs, start=1): + if isinstance(value, str): + paragraphs.append(_TableParagraph(value)) + continue + if not isinstance(value, dict): + raise RuntimeError( + f"Native PPTX table paragraph {idx} must be a string or object" + ) + if set(value) - {"text", "runs", "align"}: + raise RuntimeError( + f"Native PPTX table paragraph {idx} accepts text/runs/align only" + ) + has_text = "text" in value + has_runs = "runs" in value + if has_text == has_runs: + raise RuntimeError( + f"Native PPTX table paragraph {idx} requires exactly one of text/runs" + ) + align = value.get("align") + if align is not None and align not in {"l", "ctr", "r"}: + raise RuntimeError( + f"Native PPTX table paragraph {idx} align must be l, ctr, or r" + ) + if has_text: + text = value.get("text") + if not isinstance(text, str): + raise RuntimeError( + f"Native PPTX table paragraph {idx} text must be a string" + ) + paragraphs.append(_TableParagraph(text, align)) + continue + + raw_runs = value.get("runs") + if not isinstance(raw_runs, list) or not raw_runs: + raise RuntimeError( + f"Native PPTX table paragraph {idx} runs must be a non-empty list" + ) + runs = tuple( + _table_run(run, paragraph_idx=idx, run_idx=run_idx) + for run_idx, run in enumerate(raw_runs, start=1) + ) + paragraphs.append( + _TableParagraph("".join(run.text for run in runs), align, runs) + ) + return tuple(paragraphs) + + +def _table_run( + value: Any, + *, + paragraph_idx: int, + run_idx: int, +) -> _TableRun: + label = f"paragraph {paragraph_idx} run {run_idx}" + if not isinstance(value, dict) or "text" not in value: + raise RuntimeError(f"Native PPTX table {label} must be a text object") + allowed = { + "text", "bold", "italic", "underline", "strike", "color", + "font_size", "font_family", "lang", "alt_lang", + } + unknown = set(value) - allowed + if unknown: + fields = ", ".join(sorted(unknown)) + raise RuntimeError( + f"Native PPTX table {label} contains unsupported field(s): {fields}" + ) + text = value.get("text") + if not isinstance(text, str): + raise RuntimeError(f"Native PPTX table {label} text must be a string") + + booleans: dict[str, bool | None] = {} + for field in ("bold", "italic", "underline", "strike"): + raw = value.get(field) + if raw is not None and not isinstance(raw, bool): + raise RuntimeError( + f"Native PPTX table {label} {field} must be a JSON boolean" + ) + booleans[field] = raw + + color: str | None = None + if value.get("color") is not None: + if not isinstance(value["color"], str): + raise RuntimeError(f"Native PPTX table {label} color must be a string") + color = _hex_or_none(value["color"]) + if color is None: + raise RuntimeError(f"Native PPTX table {label} color is unsupported") + + font_size: int | None = None + if value.get("font_size") is not None: + font_size_px = _number(value["font_size"], f"table {label} font_size") + if not 100 / 75 <= font_size_px <= 400000 / 75: + raise RuntimeError( + f"Native PPTX table {label} font_size is outside DrawingML range" + ) + font_size = font_px_to_hpt(font_size_px) + if not 100 <= font_size <= 400000: + raise RuntimeError( + f"Native PPTX table {label} font_size is outside DrawingML range" + ) + + font_family: str | None = None + if value.get("font_family") is not None: + if not isinstance(value["font_family"], str): + raise RuntimeError( + f"Native PPTX table {label} font_family must be a string" + ) + font_family = value["font_family"].strip() + if not font_family or "," in font_family: + raise RuntimeError( + f"Native PPTX table {label} font_family must be one typeface" + ) + + languages: dict[str, str | None] = {} + for field in ("lang", "alt_lang"): + raw = value.get(field) + if raw is None: + languages[field] = None + continue + if not isinstance(raw, str) or not raw.strip(): + raise RuntimeError( + f"Native PPTX table {label} {field} must be a non-empty string" + ) + languages[field] = raw.strip() + + return _TableRun( + text=text, + bold=booleans["bold"], + italic=booleans["italic"], + underline=booleans["underline"], + strike=booleans["strike"], + color=color, + font_size=font_size, + font_family=font_family, + lang=languages["lang"], + alt_lang=languages["alt_lang"], + ) + + +def _table_span_value( + cell_data: dict[str, Any], + key: str, + *, + row_idx: int, + col_idx: int, +) -> int: + value = cell_data.get(key, 1) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise RuntimeError( + f"Native PPTX table cell R{row_idx}C{col_idx} {key} must be a " + "positive JSON integer" + ) + return value + + +def _merge_covered_cell_is_blank(value: Any) -> bool: + if value is None or value == "": + return True + if not isinstance(value, dict): + return False + if any(key != "text" for key in value): + return False + text = value.get("text") + return text is None or text == "" + + +def _resolve_table_merge_layout( + payload: dict[str, Any], + table_rows: list[list[Any]], + col_count: int, +) -> dict[tuple[int, int], _TableMergeRegion]: for key in _TABLE_TOP_LEVEL_SPAN_KEYS: if key in payload: raise RuntimeError( - "Native PPTX table merged cells are not supported; use SVG fallback " - "or merge cells in PowerPoint after export" + f"Native PPTX table uses unsupported top-level merged-cell field: {key}" ) + + owners: dict[tuple[int, int], _TableMergeRegion] = {} for row_idx, row in enumerate(table_rows, start=1): for col_idx, cell in enumerate(row, start=1): - if not isinstance(cell, dict): - continue - used_keys = sorted(key for key in _TABLE_SPAN_KEYS if key in cell) - if used_keys: - keys = ", ".join(used_keys) - raise RuntimeError( - f"Native PPTX table cell R{row_idx}C{col_idx} uses unsupported " - f"merged-cell field(s): {keys}" + if isinstance(cell, dict): + used_keys = sorted( + key for key in _TABLE_UNSUPPORTED_SPAN_KEYS if key in cell ) + if used_keys: + keys = ", ".join(used_keys) + raise RuntimeError( + f"Native PPTX table cell R{row_idx}C{col_idx} uses " + f"unsupported merged-cell field(s): {keys}; use row_span/col_span " + "on the merge anchor" + ) + + position = (row_idx - 1, col_idx - 1) + owner = owners.get(position) + if owner is not None: + if isinstance(cell, dict) and any( + key in cell for key in _TABLE_CANONICAL_SPAN_KEYS + ): + raise RuntimeError( + f"Native PPTX table merge anchor R{row_idx}C{col_idx} overlaps " + f"merge rooted at R{owner.row + 1}C{owner.col + 1}" + ) + if not _merge_covered_cell_is_blank(cell): + raise RuntimeError( + f"Native PPTX table merge-covered cell R{row_idx}C{col_idx} " + "must be blank" + ) + continue + + cell_data = _cell_payload(cell) + row_span = _table_span_value( + cell_data, "row_span", row_idx=row_idx, col_idx=col_idx, + ) + col_span = _table_span_value( + cell_data, "col_span", row_idx=row_idx, col_idx=col_idx, + ) + if row_span == 1 and col_span == 1: + continue + if ( + row_idx - 1 + row_span > len(table_rows) + or col_idx - 1 + col_span > col_count + ): + raise RuntimeError( + f"Native PPTX table merge rooted at R{row_idx}C{col_idx} exceeds " + f"the resolved {len(table_rows)}x{col_count} grid" + ) + + region = _TableMergeRegion( + row=row_idx - 1, + col=col_idx - 1, + row_span=row_span, + col_span=col_span, + ) + for covered_row in range(region.row, region.row + region.row_span): + for covered_col in range(region.col, region.col + region.col_span): + covered_position = (covered_row, covered_col) + prior = owners.get(covered_position) + if prior is not None: + raise RuntimeError( + f"Native PPTX table merge rooted at R{row_idx}C{col_idx} " + f"overlaps merge rooted at R{prior.row + 1}C{prior.col + 1}" + ) + owners[covered_position] = region + return owners def _grid_is_strict(payload: dict[str, Any]) -> bool: @@ -174,29 +484,40 @@ def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]] def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None: style = payload.get("style") if isinstance(payload.get("style"), dict) else {} _table_bool(style.get("band_row"), "style.band_row", default=True) + if "borders" in style: + raise RuntimeError( + "Native PPTX table per-side borders are supported on cells only" + ) for row in table_rows: for cell in row: cell_data = _cell_payload(cell) + _table_cell_paragraphs(cell_data) if "bold" in cell_data: _table_bool(cell_data["bold"], "cell bold", default=False) for side in ("left", "right", "top", "bottom"): _table_padding_value(cell_data, style, side) - border_width = _table_border_width(cell_data, style) - if border_width > 0: + for border in _table_border_specs(cell_data, style).values(): + if border is None or border.style == "none": + continue + assert border.width is not None _powerpoint_line_width_emu( - border_width, + border.width, "table border_width", ) _table_anchor(cell_data, style) -def _validate_table_payload(payload: dict[str, Any]) -> tuple[list[list[Any]], int]: +def _validate_table_payload( + payload: dict[str, Any], +) -> tuple[list[list[Any]], int, dict[tuple[int, int], _TableMergeRegion]]: table_rows = _table_rows(payload) - _check_table_spans(payload, table_rows) col_count = _validate_table_lengths(payload, table_rows) + for row in table_rows: + row.extend([""] * (col_count - len(row))) + merge_layout = _resolve_table_merge_layout(payload, table_rows, col_count) _table_header_rows(payload, len(table_rows)) _validate_table_cell_formatting(payload, table_rows) - return table_rows, col_count + return table_rows, col_count, merge_layout def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]: @@ -204,9 +525,16 @@ def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]: for row in table_rows: for cell in row: cell_data = _cell_payload(cell) - text = _normalized_fallback_text(cell_data.get("text")) - if text: - counts[text] = counts.get(text, 0) + 1 + paragraphs = _table_cell_paragraphs(cell_data) + texts = ( + [paragraph.text for paragraph in paragraphs] + if paragraphs is not None + else [cell_data.get("text")] + ) + for value in texts: + text = _normalized_fallback_text(value) + if text: + counts[text] = counts.get(text, 0) + 1 return counts @@ -360,35 +688,158 @@ def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> flo return _number(1 if width_raw is None else width_raw, "table border_width") +_TABLE_BORDER_SIDES = ("left", "right", "top", "bottom") +_TABLE_BORDER_TAGS = { + "left": "lnL", + "right": "lnR", + "top": "lnT", + "bottom": "lnB", +} + + +def _strict_table_border_color(value: Any, side: str) -> str: + raw = value if isinstance(value, str) else "" + if len(raw) != 7 or not raw.startswith("#"): + raise RuntimeError( + f"Native PPTX table {side} border color must be #RRGGBB" + ) + try: + int(raw[1:], 16) + except ValueError as exc: + raise RuntimeError( + f"Native PPTX table {side} border color must be #RRGGBB" + ) from exc + return raw[1:].upper() + + +def _table_border_override(value: Any, side: str) -> _TableBorderSpec: + if not isinstance(value, dict): + raise RuntimeError( + f"Native PPTX table {side} border must be an object" + ) + border_style = value.get("style") + if border_style == "none": + if set(value) != {"style"}: + raise RuntimeError( + f"Native PPTX table {side} border style none accepts no other fields" + ) + return _TableBorderSpec("none") + if border_style != "solid": + raise RuntimeError( + f"Native PPTX table {side} border style must be solid or none" + ) + if set(value) != {"style", "color", "width"}: + raise RuntimeError( + f"Native PPTX table {side} solid border requires style/color/width only" + ) + width = _number(value.get("width"), f"table {side} border width") + if width <= 0: + raise RuntimeError( + f"Native PPTX table {side} solid border width must be positive" + ) + _powerpoint_line_width_emu(width, f"table {side} border width") + return _TableBorderSpec( + "solid", + color=_strict_table_border_color(value.get("color"), side), + width=width, + ) + + +def _table_border_specs( + cell_data: dict[str, Any], + style: dict[str, Any], +) -> dict[str, _TableBorderSpec | None]: + raw_borders = cell_data.get("borders") + if raw_borders is None: + border_overrides: dict[str, Any] = {} + elif not isinstance(raw_borders, dict): + raise RuntimeError("Native PPTX table cell borders must be an object") + else: + unknown = sorted(set(raw_borders) - set(_TABLE_BORDER_SIDES)) + if unknown: + raise RuntimeError( + "Native PPTX table cell borders use unsupported side(s): " + + ", ".join(unknown) + ) + border_overrides = raw_borders + + legacy_width = _table_border_width(cell_data, style) + legacy_spec = ( + _TableBorderSpec( + "solid", + color=_clean_hex( + cell_data.get( + "border_color", + cell_data.get("borderColor", style.get("border_color")), + ), + "#D9DEE7", + ), + width=legacy_width, + ) + if legacy_width > 0 + else None + ) + return { + side: ( + _table_border_override(border_overrides[side], side) + if side in border_overrides + else legacy_spec + ) + for side in _TABLE_BORDER_SIDES + } + + def _table_border_xml( cell_data: dict[str, Any], style: dict[str, Any], theme_color_spec: ThemeColorSpec | None, ) -> str: - color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color"))) - width = _table_border_width(cell_data, style) - if width <= 0: + border_xml: list[str] = [] + for side, border in _table_border_specs(cell_data, style).items(): + if border is None: + continue + tag = _TABLE_BORDER_TAGS[side] + if border.style == "none": + border_xml.append(f'') + continue + assert border.color is not None and border.width is not None + line_width = _powerpoint_line_width_emu( + border.width, f"table {side} border width", + ) + border_xml.append( + f'' + f'{color_node_xml(border.color, theme_color_spec, "stroke")}' + '' + '' + f'' + ) + return "".join(border_xml) + + +def _table_merge_attrs( + region: _TableMergeRegion | None, + row_idx: int, + col_idx: int, +) -> str: + if region is None: return "" - color = _clean_hex(color_raw, "#D9DEE7") - line = ( - f'{color_node_xml(color, theme_color_spec, "stroke")}' - '' - ) - line_width = _powerpoint_line_width_emu(width, "table border_width") - return "".join( - f'{line}' - for tag in ("lnL", "lnR", "lnT", "lnB") - ) + attrs: list[str] = [] + if row_idx == region.row and region.row_span > 1: + attrs.append(f'rowSpan="{region.row_span}"') + if col_idx == region.col and region.col_span > 1: + attrs.append(f'gridSpan="{region.col_span}"') + if col_idx > region.col: + attrs.append('hMerge="1"') + if row_idx > region.row: + attrs.append('vMerge="1"') + return (" " + " ".join(attrs)) if attrs else "" def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult: - table_rows, col_count = _validate_table_payload(payload) + table_rows, col_count, merge_layout = _validate_table_payload(payload) header_rows = _table_header_rows(payload, len(table_rows)) preserve_source_style = elem.get("data-pptx-native-source") == "pptx" - for row in table_rows: - row.extend([""] * (col_count - len(row))) - style = payload.get("style") if isinstance(payload.get("style"), dict) else {} header_fill = _clean_hex(style.get("header_fill"), "#1F4E79") header_text = _clean_hex(style.get("header_text"), "#FFFFFF") @@ -397,6 +848,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA") font_face = str(style["font_family"]) if style.get("font_family") else None body_font_size = _font_size_hpt(style.get("font_size"), 18) + band_rows_enabled = _table_bool( + style.get("band_row"), + "style.band_row", + default=True, + ) header_font_size = _font_size_hpt( style.get("header_font_size", style.get("font_size")), 18, @@ -424,7 +880,20 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str for row_idx, row in enumerate(table_rows): is_header = row_idx < header_rows cells_xml: list[str] = [] - for cell in row: + for col_idx, cell in enumerate(row): + merge_region = merge_layout.get((row_idx, col_idx)) + merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx) + if merge_region is not None and ( + row_idx != merge_region.row or col_idx != merge_region.col + ): + cells_xml.append( + f'' + '' + '' + '
' + ) + continue + cell_data = _cell_payload(cell) if preserve_source_style: fill = ( @@ -440,7 +909,9 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str fill = _clean_hex( cell_data.get("fill"), header_fill if is_header else ( - band_fill if row_idx % 2 == 0 and row_idx else body_fill + band_fill + if band_rows_enabled and row_idx % 2 == 0 and row_idx + else body_fill ), ) color = _clean_hex( @@ -450,7 +921,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str align = str(cell_data.get("align") or ("ctr" if is_header else "l")) if align not in {"l", "ctr", "r"}: align = "l" - text = "" if cell_data.get("text") is None else str(cell_data.get("text")) + paragraphs = _table_cell_paragraphs(cell_data) if preserve_source_style: bold = ( _table_bool(cell_data["bold"], "cell bold", default=False) @@ -469,7 +940,75 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str ) if is_header and "font_size" not in cell_data: cell_font_size = header_font_size - paragraph_props = f'' if align != "l" else "" + language = ( + str(cell_data.get("lang") or style.get("lang") or "").strip() + or None + ) + if paragraphs is None: + text = ( + "" if cell_data.get("text") is None + else str(cell_data.get("text")) + ) + paragraph_props = ( + f'' if align != "l" else "" + ) + text_run_xml = _table_text_run( + text, + color=color, + bold=bold, + font_size=cell_font_size, + font_face=font_face, + language=language, + theme_color_spec=ctx.theme_color_spec, + ) + paragraphs_xml = ( + f"{paragraph_props}{text_run_xml}" + ) + else: + paragraph_parts: list[str] = [] + for paragraph in paragraphs: + paragraph_align = paragraph.align or align + paragraph_props = ( + f'' + if paragraph.align is not None or paragraph_align != "l" + else "" + ) + if paragraph.runs is None: + text_run_xml = _table_text_run( + paragraph.text, + color=color, + bold=bold, + font_size=cell_font_size, + font_face=font_face, + language=language, + theme_color_spec=ctx.theme_color_spec, + ) + else: + text_run_xml = "".join( + _table_text_run( + run.text, + color=run.color or color, + bold=run.bold if run.bold is not None else bold, + font_size=( + run.font_size + if run.font_size is not None + else cell_font_size + ), + font_face=run.font_family or font_face, + language=run.lang or language, + theme_color_spec=ctx.theme_color_spec, + italic=run.italic, + underline=run.underline, + strike=run.strike, + alt_language=run.alt_lang, + exact_font_face=run.font_family is not None, + ) + for run in paragraph.runs + ) + paragraph_parts.append( + f"{paragraph_props}{text_run_xml}" + ) + paragraphs_xml = "".join(paragraph_parts) anchor_keys = {"valign", "vertical_align"} anchor_attr = "" if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style): @@ -486,20 +1025,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str '' if fill else "" ) - text_run_xml = _table_text_run( - text, - color=color, - bold=bold, - font_size=cell_font_size, - font_face=font_face, - theme_color_spec=ctx.theme_color_spec, - ) cells_xml.append( - "" + f"" "" - f"{paragraph_props}" - f"{text_run_xml}" - "" + f"{paragraphs_xml}" + "" f'{border_xml}{fill_xml}' "" ) @@ -507,7 +1037,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str shape_id = ctx.next_id() first_row = _bool_attr(header_rows > 0) - band_row = _bool_attr(_table_bool(style.get("band_row"), "style.band_row", default=True)) + band_row = _bool_attr(band_rows_enabled) table_style_id = style.get("table_style_id") if table_style_id is None and not preserve_source_style: table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/workbook.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/workbook.py index f2ce2c4d..29770395 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/workbook.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/workbook.py @@ -127,6 +127,28 @@ def _minimal_workbook(rows: list[list[Any]]) -> bytes: def _minimal_category_chart_workbook(chart_data: dict[str, Any]) -> bytes: + if chart_data.get("kind") == "combo" and chart_data.get("independent_categories"): + plots = chart_data["plots"] + column_count = max( + int(plot["start_column"]) + len(plot["series"]) - 1 + for plot in plots + ) + rows: list[list[Any]] = [[None] * column_count] + for plot in plots: + category_column = int(plot["category_column"]) - 1 + start_column = int(plot["start_column"]) - 1 + for offset, series in enumerate(plot["series"]): + rows[0][start_column + offset] = series["name"] + for point_index, category in enumerate(plot["categories"], start=1): + while len(rows) <= point_index: + rows.append([None] * column_count) + rows[point_index][category_column] = category + for offset, series in enumerate(plot["series"]): + rows[point_index][start_column + offset] = ( + series["values"][point_index - 1] + ) + return _minimal_workbook(rows) + categories = chart_data["categories"] series = chart_data["series"] rows: list[list[Any]] = [[None] + [item["name"] for item in series]] diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py index 34404ce9..62dbabe2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py @@ -101,6 +101,7 @@ from .template_structure import ( TemplateElementSpec, TemplateSlideSpec, TemplateStructureError, + is_proxy_placeholder, match_native_placeholders, parse_preserve_slides, parse_template_slides, @@ -154,7 +155,7 @@ class PptxStructureContext: @dataclass class _TemplateRuntimeSlide: - """Parsed slide package state used by explicit template structure export.""" + """Parsed slide package state used by explicit Layout structure export.""" spec: TemplateSlideSpec slide_path: Path @@ -476,6 +477,35 @@ def _shape_id(elem: ET.Element) -> str | None: return None +def _set_shape_name(elem: ET.Element, name: str) -> None: + """Give one top-level shape a deterministic read-back identity.""" + for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"): + cnv.set("name", name) + return + raise TemplateStructureError( + f"Cannot name structured shape {name!r}: p:cNvPr is missing" + ) + + +def _top_level_shape_name_roster(root: ET.Element) -> tuple[str, ...]: + """Return the exact visible top-level shape-name sequence for read-back.""" + sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree") + if sp_tree is None: + raise TemplateStructureError("Structured part has no p:cSld/p:spTree") + names: list[str] = [] + for child in sp_tree: + if child.tag not in _TOP_LEVEL_SHAPE_TAGS: + continue + c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None) + name = c_nv_pr.get("name") if c_nv_pr is not None else None + if not name: + raise TemplateStructureError( + "Structured part contains a top-level shape without a name" + ) + names.append(name) + return tuple(names) + + def _top_level_shapes_by_id(root: ET.Element) -> dict[str, ET.Element]: sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree") if sp_tree is None: @@ -660,9 +690,19 @@ def _next_layout_part_number(extract_dir: Path) -> int: return max(numbers, default=0) + 1 -def _next_slide_layout_id(extract_dir: Path, master_xml: str) -> int: - """Return an unused id for a new sldLayoutId entry.""" - ids = [int(value) for value in re.findall(r'\bid="(\d{9,})"', master_xml)] +def _next_slide_layout_id(extract_dir: Path) -> int: + """Return a package-wide unused id for a new sldLayoutId entry.""" + ids: list[int] = [] + masters_dir = extract_dir / "ppt" / "slideMasters" + for master_path in sorted(masters_dir.glob("slideMaster*.xml")): + master_root = ET.parse(master_path).getroot() + ids.extend( + int(entry.attrib["id"]) + for entry in master_root.findall( + f"{{{PML_NS}}}sldLayoutIdLst/{{{PML_NS}}}sldLayoutId" + ) + if entry.attrib.get("id", "").isdigit() + ) presentation_path = extract_dir / "ppt" / "presentation.xml" if presentation_path.exists(): ids.extend( @@ -724,7 +764,7 @@ def _create_cover_layout(extract_dir: Path, master_part: str, base_layout_part: rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target) master_xml = master_path.read_text(encoding="utf-8") - layout_id = _next_slide_layout_id(extract_dir, master_xml) + layout_id = _next_slide_layout_id(extract_dir) entry = f'' if "" not in master_xml: raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}") @@ -787,24 +827,52 @@ def _create_custom_layout( base_rels_path.read_text(encoding="utf-8"), encoding="utf-8", ) + master_target = posixpath.relpath( + master_part, + posixpath.dirname(new_layout_part), + ) + rels_content = new_rels_path.read_text(encoding="utf-8") + master_rel_ids = [ + rel_id + for rel_id, attrs in _read_relationships(new_rels_path).items() + if attrs.get("Type") == SLIDE_MASTER_REL_TYPE + ] + if len(master_rel_ids) != 1: + raise RuntimeError( + f"Cloned slide layout must have one Master relationship: {new_layout_part}" + ) + master_rel_id = master_rel_ids[0] + master_rel_pattern = re.compile( + rf'(]*\bId="{re.escape(master_rel_id)}"' + rf'[^>]*\bTarget=")[^"]*(")' + ) + rels_content, replaced = master_rel_pattern.subn( + rf"\g<1>{master_target}\g<2>", + rels_content, + count=1, + ) + if replaced != 1: + raise RuntimeError( + f"Could not retarget cloned Layout to Master {master_part}" + ) + new_rels_path.write_text(rels_content, encoding="utf-8") master_path = extract_dir / master_part master_rels_path = _relationships_path_for_part(extract_dir, master_part) layout_target = posixpath.relpath(new_layout_part, posixpath.dirname(master_part)) rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target) - master_xml = master_path.read_text(encoding="utf-8") - layout_id = _next_slide_layout_id(extract_dir, master_xml) - entry = f'' - if "" not in master_xml: + master_tree = ET.parse(master_path) + master_root = master_tree.getroot() + layout_list = master_root.find(f"{{{PML_NS}}}sldLayoutIdLst") + if layout_list is None: raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}") - master_path.write_text( - master_xml.replace( - "", - f"{entry}", - 1, - ), - encoding="utf-8", + layout_id = _next_slide_layout_id(extract_dir) + ET.SubElement( + layout_list, + f"{{{PML_NS}}}sldLayoutId", + {"id": str(layout_id), f"{{{REL_NS}}}id": rel_id}, ) + _write_xml_tree(master_path, master_tree) content_types_path = extract_dir / "[Content_Types].xml" content_types_path.write_text( @@ -818,8 +886,132 @@ def _create_custom_layout( return posixpath.relpath(new_layout_part, "ppt/slides"), new_layout_part +def _set_master_picker_name(master_path: Path, master_name: str) -> None: + """Set the visible PowerPoint Master name on its common slide data.""" + tree = ET.parse(master_path) + c_sld = tree.getroot().find(f"{{{PML_NS}}}cSld") + if c_sld is None: + raise RuntimeError(f"Slide master has no p:cSld: {master_path}") + c_sld.set("name", master_name) + _write_xml_tree(master_path, tree) + + +def _next_master_part_number(extract_dir: Path) -> int: + numbers = [ + int(match.group(1)) + for path in (extract_dir / "ppt" / "slideMasters").glob("slideMaster*.xml") + if (match := re.fullmatch(r"slideMaster(\d+)\.xml", path.name)) + ] + return max(numbers, default=0) + 1 + + +def _clone_structured_master( + extract_dir: Path, + source_master_part: str, + master_name: str, +) -> str: + """Clone a clean Master part and register it with the Presentation.""" + master_num = _next_master_part_number(extract_dir) + master_part = f"ppt/slideMasters/slideMaster{master_num}.xml" + master_path = extract_dir / master_part + source_master_path = extract_dir / source_master_part + shutil.copyfile(source_master_path, master_path) + + tree = ET.parse(master_path) + root = tree.getroot() + c_sld = root.find(f"{{{PML_NS}}}cSld") + if c_sld is None: + raise RuntimeError(f"Slide master has no p:cSld: {source_master_part}") + c_sld.set("name", master_name) + layout_list = root.find(f"{{{PML_NS}}}sldLayoutIdLst") + if layout_list is None: + raise RuntimeError( + f"Slide master has no p:sldLayoutIdLst: {source_master_part}" + ) + for entry in list(layout_list): + layout_list.remove(entry) + _write_xml_tree(master_path, tree) + + source_rels = _relationships_path_for_part(extract_dir, source_master_part) + master_rels = _relationships_path_for_part(extract_dir, master_part) + master_rels.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_rels, master_rels) + for rel_id, attrs in tuple(_read_relationships(master_rels).items()): + if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE: + _remove_relationship(master_rels, rel_id) + + presentation_rels = extract_dir / "ppt" / "_rels" / "presentation.xml.rels" + relationship_target = posixpath.relpath(master_part, "ppt") + relationship_id = _append_relationship( + presentation_rels, + SLIDE_MASTER_REL_TYPE, + relationship_target, + ) + presentation_path = extract_dir / "ppt" / "presentation.xml" + presentation_xml = presentation_path.read_text(encoding="utf-8") + master_ids = [ + int(value) + for value in re.findall(r']*\bid="(\d+)"', presentation_xml) + ] + master_id = max(master_ids, default=(1 << 31) - 1) + 1 + if master_id > OOXML_UINT32_MAX: + raise TemplateStructureError("Presentation Master id exceeds OOXML UInt32") + entry = f'' + if "" not in presentation_xml: + raise RuntimeError("presentation.xml has no p:sldMasterIdLst") + presentation_path.write_text( + presentation_xml.replace( + "", + f"{entry}", + 1, + ), + encoding="utf-8", + ) + + content_types_path = extract_dir / "[Content_Types].xml" + content_types_path.write_text( + _add_content_type_override( + content_types_path.read_text(encoding="utf-8"), + master_part, + "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml", + ), + encoding="utf-8", + ) + return master_part + + +def _assign_structured_masters( + extract_dir: Path, + structure: PptxStructureContext, + specs: list[TemplateSlideSpec], +) -> dict[str, str]: + """Create one registered package Master for each explicit SVG Master key.""" + if not specs: + raise TemplateStructureError("Structured export requires at least one slide") + source_master = structure.slide_master_part(specs[0].slide_num) + masters: dict[str, str] = {} + for spec in specs: + existing_part = masters.get(spec.master_key) + if existing_part is None: + if not masters: + existing_part = source_master + _set_master_picker_name( + extract_dir / existing_part, + spec.master_name, + ) + else: + existing_part = _clone_structured_master( + extract_dir, + source_master, + spec.master_name, + ) + masters[spec.master_key] = existing_part + structure.slide_master_parts[spec.slide_num] = existing_part + return masters + + def _clear_master_placeholder_shapes(master_path: Path) -> None: - """Remove base-template placeholders before installing an explicit template.""" + """Remove base-package placeholders before installing structured content.""" tree = ET.parse(master_path) root = tree.getroot() sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree") @@ -1182,6 +1374,18 @@ _TEMPLATE_PLACEHOLDER_PROMPTS = { "footer": "Footer", } +_PARAGRAPH_BULLET_CHOICE_TAGS = { + f"{{{DML_NS}}}buNone", + f"{{{DML_NS}}}buAutoNum", + f"{{{DML_NS}}}buChar", + f"{{{DML_NS}}}buBlip", +} +_PARAGRAPH_PROPERTIES_TRAILING_TAGS = { + f"{{{DML_NS}}}tabLst", + f"{{{DML_NS}}}defRPr", + f"{{{DML_NS}}}extLst", +} + def _template_runtime_slides( extract_dir: Path, @@ -1191,7 +1395,7 @@ def _template_runtime_slides( """Load slide XML state and join it with SVG-to-shape trace ids.""" if not conversion_traces: raise TemplateStructureError( - "Template export requires native conversion traces for every slide" + "Explicit Layout export requires native conversion traces for every slide" ) trace_by_slide = { int(trace.get("slide_num", 0)): trace @@ -1246,9 +1450,15 @@ def _template_shape_for_item( ): return None if not shape_ids: + text_hint = ( + "; multiline text placeholders require the default paragraph merge " + "and cannot use --no-merge" + if item.placeholder and item.tag == "text" + else "" + ) raise TemplateStructureError( f"{state.spec.svg_path.name}: metadata element {item.element_id!r} " - "did not produce one top-level native shape" + f"did not produce one top-level native shape{text_hint}" ) raise TemplateStructureError( f"{state.spec.svg_path.name}: metadata element {item.element_id!r} " @@ -1256,6 +1466,133 @@ def _template_shape_for_item( ) +def _shape_transform(shape: ET.Element) -> ET.Element | None: + """Return the direct DrawingML transform for one top-level shape.""" + paths = { + f"{{{PML_NS}}}sp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm", + f"{{{PML_NS}}}pic": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm", + f"{{{PML_NS}}}cxnSp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm", + f"{{{PML_NS}}}graphicFrame": f"{{{PML_NS}}}xfrm", + f"{{{PML_NS}}}grpSp": f"{{{PML_NS}}}grpSpPr/{{{DML_NS}}}xfrm", + } + path = paths.get(shape.tag) + return shape.find(path) if path is not None else None + + +def _int_attr(elem: ET.Element, name: str, context: str) -> int: + try: + return int(elem.attrib[name]) + except (KeyError, ValueError) as exc: + raise TemplateStructureError(f"{context} has invalid {name!r}") from exc + + +def _flatten_group_transform( + group: ET.Element, + carrier: ET.Element, + *, + context: str, +) -> None: + """Map a single group's child transform into slide coordinates.""" + group_xfrm = _shape_transform(group) + carrier_xfrm = _shape_transform(carrier) + if group_xfrm is None or carrier_xfrm is None: + raise TemplateStructureError( + f"{context} cannot be unwrapped because its DrawingML transform is missing" + ) + if any(group_xfrm.get(name) is not None for name in ("rot", "flipH", "flipV")): + raise TemplateStructureError( + f"{context} wrapper carries an unsupported group rotation or flip" + ) + group_off = group_xfrm.find(f"{{{DML_NS}}}off") + group_ext = group_xfrm.find(f"{{{DML_NS}}}ext") + child_off = group_xfrm.find(f"{{{DML_NS}}}chOff") + child_ext = group_xfrm.find(f"{{{DML_NS}}}chExt") + carrier_off = carrier_xfrm.find(f"{{{DML_NS}}}off") + carrier_ext = carrier_xfrm.find(f"{{{DML_NS}}}ext") + if any(value is None for value in ( + group_off, + group_ext, + child_off, + child_ext, + carrier_off, + carrier_ext, + )): + raise TemplateStructureError( + f"{context} cannot be unwrapped because its group transform is incomplete" + ) + child_width = _int_attr(child_ext, "cx", context) + child_height = _int_attr(child_ext, "cy", context) + if child_width <= 0 or child_height <= 0: + raise TemplateStructureError(f"{context} group child extent must be positive") + scale_x = _int_attr(group_ext, "cx", context) / child_width + scale_y = _int_attr(group_ext, "cy", context) / child_height + mapped_x = _int_attr(group_off, "x", context) + round( + (_int_attr(carrier_off, "x", context) - _int_attr(child_off, "x", context)) + * scale_x + ) + mapped_y = _int_attr(group_off, "y", context) + round( + (_int_attr(carrier_off, "y", context) - _int_attr(child_off, "y", context)) + * scale_y + ) + carrier_off.set("x", str(mapped_x)) + carrier_off.set("y", str(mapped_y)) + carrier_ext.set( + "cx", + str(round(_int_attr(carrier_ext, "cx", context) * scale_x)), + ) + carrier_ext.set( + "cy", + str(round(_int_attr(carrier_ext, "cy", context) * scale_y)), + ) + + +def _unwrap_placeholder_carrier( + state: _TemplateRuntimeSlide, + item: TemplateElementSpec, +) -> ET.Element: + """Remove one SVG-only slot wrapper and return its top-level carrier.""" + wrapper = _template_shape_for_item(state, item) + if wrapper is None: + raise TemplateStructureError( + f"{state.spec.svg_path.name}: placeholder {item.element_id!r} cannot " + "be a slide background" + ) + if item.tag != "g" or wrapper.tag != f"{{{PML_NS}}}grpSp": + return wrapper + carriers = [ + child for child in wrapper if child.tag in _TOP_LEVEL_SHAPE_TAGS + ] + if len(carriers) != 1: + raise TemplateStructureError( + f"{state.spec.svg_path.name}: placeholder group {item.element_id!r} " + f"converted to {len(carriers)} native children; expected one carrier" + ) + carrier = carriers[0] + _flatten_group_transform( + wrapper, + carrier, + context=f"{state.spec.svg_path.name} placeholder {item.element_id!r}", + ) + sp_tree = _slide_sp_tree(state) + try: + wrapper_index = list(sp_tree).index(wrapper) + except ValueError as exc: + raise TemplateStructureError( + f"{state.spec.svg_path.name}: placeholder wrapper " + f"{item.element_id!r} is not top-level" + ) from exc + wrapper.remove(carrier) + sp_tree.remove(wrapper) + sp_tree.insert(wrapper_index, carrier) + wrapper_id = _shape_id(wrapper) + carrier_id = _shape_id(carrier) + if wrapper_id: + state.shapes.pop(wrapper_id, None) + if carrier_id: + state.shapes[carrier_id] = carrier + return carrier + + def _slide_sp_tree(state: _TemplateRuntimeSlide) -> ET.Element: sp_tree = state.root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree") if sp_tree is None: @@ -1263,6 +1600,21 @@ def _slide_sp_tree(state: _TemplateRuntimeSlide) -> ET.Element: return sp_tree +def _append_shape_to_runtime_slide( + state: _TemplateRuntimeSlide, + elem: ET.Element, +) -> None: + """Append a generated helper shape to one Slide with fresh object ids.""" + existing_ids = [ + int(cnv.attrib["id"]) + for cnv in state.root.iter(f"{{{PML_NS}}}cNvPr") + if cnv.attrib.get("id", "").isdigit() + ] + clone = ET.fromstring(ET.tostring(elem, encoding="utf-8")) + _renumber_shape_ids(clone, max(existing_ids, default=1) + 1) + _slide_sp_tree(state).append(clone) + + def _presentation_slide_size_emu(extract_dir: Path) -> tuple[int, int]: """Return the package slide size in EMU.""" presentation_path = extract_dir / "ppt" / "presentation.xml" @@ -1358,7 +1710,7 @@ def _remove_template_shape( def _move_template_background( states: list[_TemplateRuntimeSlide], target_path: Path, -) -> None: +) -> str: backgrounds = [ _extract_slide_background_xml(state.slide_path.read_text(encoding="utf-8")) for state in states @@ -1401,9 +1753,10 @@ def _move_template_background( if c_sld is None or background is None: raise TemplateStructureError( f"{state.spec.svg_path.name}: explicit background disappeared " - "during template structure assembly" + "during explicit Layout structure assembly" ) c_sld.remove(background) + return background_xml def _move_template_solid_background_shapes( @@ -1411,14 +1764,14 @@ def _move_template_solid_background_shapes( shapes: list[ET.Element], target_path: Path, slide_size_emu: tuple[int, int], -) -> bool: +) -> str | None: """Move repeated full-slide solid rects into a master/layout p:bg.""" backgrounds = [ _solid_background_xml_from_shape(shape, slide_size_emu) for shape in shapes ] if not any(backgrounds): - return False + return None if any(background is None for background in backgrounds): raise TemplateStructureError( "A template background resolves to a full-slide solid rect on only " @@ -1432,7 +1785,7 @@ def _move_template_solid_background_shapes( ) background_xml = backgrounds[0] if background_xml is None: - return False + return None target_xml = target_path.read_text(encoding="utf-8") updated = _put_background_on_part(target_xml, background_xml) if updated is None: @@ -1442,7 +1795,7 @@ def _move_template_solid_background_shapes( target_path.write_text(updated, encoding="utf-8") for state, shape in zip(states, shapes): _remove_template_shape(state, shape) - return True + return background_xml def _set_slide_tree_background( @@ -1470,9 +1823,9 @@ def _set_slide_tree_background( def _apply_template_slide_backgrounds( states: list[_TemplateRuntimeSlide], slide_size_emu: tuple[int, int], -) -> int: +) -> dict[str, str]: """Compile one-page solid backgrounds into slide-level p:bg.""" - applied = 0 + applied: dict[str, str] = {} for state in states: items = [ item for item in state.spec.elements @@ -1484,12 +1837,20 @@ def _apply_template_slide_backgrounds( shape = _template_shape_for_item(state, item) if shape is None: c_sld = state.root.find(f"{{{PML_NS}}}cSld") - if c_sld is None or c_sld.find(f"{{{PML_NS}}}bg") is None: + background = ( + c_sld.find(f"{{{PML_NS}}}bg") + if c_sld is not None + else None + ) + if background is None: raise TemplateStructureError( f"{state.spec.svg_path.name}: slide background disappeared " - "during template structure assembly" + "during explicit Layout structure assembly" ) - applied += 1 + applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = ET.tostring( + background, + encoding="unicode", + ) continue background_xml = _solid_background_xml_from_shape(shape, slide_size_emu) if background_xml is None: @@ -1499,7 +1860,7 @@ def _apply_template_slide_backgrounds( ) _remove_template_shape(state, shape) _set_slide_tree_background(state, background_xml) - applied += 1 + applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = background_xml return applied @@ -1509,27 +1870,28 @@ def _move_template_static_shape( target_path: Path, target_rels_path: Path, slide_size_emu: tuple[int, int], -) -> None: +) -> str | None: shapes = [_template_shape_for_item(state, item) for state in states] if any(shape is None for shape in shapes): if not all(shape is None for shape in shapes): raise TemplateStructureError( f"{item.element_id}: structure item is a background on only some slides" ) - _move_template_background(states, target_path) - return + return _move_template_background(states, target_path) resolved_shapes = [shape for shape in shapes if shape is not None] - if ( - item.is_background - and _move_template_solid_background_shapes( + if item.is_background: + background_xml = _move_template_solid_background_shapes( states, resolved_shapes, target_path, slide_size_emu, ) - ): - return + if background_xml is None: + raise TemplateStructureError( + f"{item.element_id!r} must compile to one exact p:bg payload" + ) + return background_xml canonical = { _canonical_shape_xml(shape, state.rels) for state, shape in zip(states, resolved_shapes) @@ -1560,9 +1922,11 @@ def _move_template_static_shape( prototype_state.rels, target_rels_path, ) + _set_shape_name(target_shape, f"{item.element_id} {item.layer.title()}") _append_shape_to_part(target_path, target_shape) for state, shape in zip(states, resolved_shapes): _remove_template_shape(state, shape) + return None def _shape_bounds_emu( @@ -1617,13 +1981,10 @@ def _replace_shape_xfrm( sp_pr.insert(0, xfrm) -def _set_layout_level_one_default_size( +def _layout_level_one_paragraph_properties( list_style: ET.Element, - source_run_pr: ET.Element | None, -) -> None: - """Persist the prototype run size as the Layout's level-one text default.""" - if source_run_pr is None or source_run_pr.get("sz") is None: - return +) -> ET.Element: + """Return the Layout list style's level-one paragraph properties.""" level_tag = f"{{{DML_NS}}}lvl1pPr" level_props = list_style.find(level_tag) if level_props is None: @@ -1641,6 +2002,17 @@ def _set_layout_level_one_default_size( len(list_style), ) list_style.insert(insert_at, level_props) + return level_props + + +def _set_layout_level_one_default_size( + list_style: ET.Element, + source_run_pr: ET.Element | None, +) -> None: + """Persist the prototype run size as the Layout's level-one text default.""" + if source_run_pr is None or source_run_pr.get("sz") is None: + return + level_props = _layout_level_one_paragraph_properties(list_style) default_props = level_props.find(f"{{{DML_NS}}}defRPr") if default_props is None: default_props = ET.Element(f"{{{DML_NS}}}defRPr") @@ -1657,6 +2029,42 @@ def _set_layout_level_one_default_size( default_props.set("sz", source_run_pr.get("sz", "")) +def _set_no_bullet_paragraph_properties( + paragraph_props: ET.Element, + *, + replace_existing: bool = False, +) -> None: + """Disable inherited bullets and hanging indent for a prose paragraph.""" + if replace_existing: + for child in list(paragraph_props): + if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS: + paragraph_props.remove(child) + + bullet_choice = next( + ( + child + for child in paragraph_props + if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS + ), + None, + ) + if bullet_choice is not None and bullet_choice.tag != f"{{{DML_NS}}}buNone": + return + if bullet_choice is None: + insert_at = next( + ( + index + for index, child in enumerate(paragraph_props) + if child.tag in _PARAGRAPH_PROPERTIES_TRAILING_TAGS + ), + len(paragraph_props), + ) + paragraph_props.insert(insert_at, ET.Element(f"{{{DML_NS}}}buNone")) + + paragraph_props.set("marL", "0") + paragraph_props.set("indent", "0") + + def _placeholder_text_body( source_shape: ET.Element, item: TemplateElementSpec, @@ -1689,12 +2097,17 @@ def _placeholder_text_body( else ET.Element(f"{{{DML_NS}}}lstStyle") ) _set_layout_level_one_default_size(list_style, source_run_pr) + if item.placeholder in {"body", "subtitle"}: + _set_no_bullet_paragraph_properties( + _layout_level_one_paragraph_properties(list_style), + replace_existing=item.placeholder == "body", + ) tx_body.append(list_style) paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p") - if item.placeholder == "body": + if item.placeholder in {"body", "subtitle"}: paragraph_props = ET.SubElement(paragraph, f"{{{DML_NS}}}pPr") - ET.SubElement(paragraph_props, f"{{{DML_NS}}}buNone") + _set_no_bullet_paragraph_properties(paragraph_props) if item.placeholder in {"slide-number", "date"}: field_type = ( "slidenum" @@ -1732,44 +2145,25 @@ def _placeholder_text_body( return tx_body -def _set_body_placeholder_no_bullets( +def _set_placeholder_no_inherited_bullets( shape: ET.Element, item: TemplateElementSpec, ) -> None: - """Preserve SVG prose instead of inheriting master bullet defaults.""" - if item.placeholder != "body": + """Keep prose bullet-free while preserving explicit subtitle bullets.""" + if item.placeholder not in {"body", "subtitle"}: return tx_body = shape.find(f"{{{PML_NS}}}txBody") if tx_body is None: return - bullet_tags = { - f"{{{DML_NS}}}buNone", - f"{{{DML_NS}}}buAutoNum", - f"{{{DML_NS}}}buChar", - f"{{{DML_NS}}}buBlip", - } - trailing_tags = { - f"{{{DML_NS}}}tabLst", - f"{{{DML_NS}}}defRPr", - f"{{{DML_NS}}}extLst", - } for paragraph in tx_body.findall(f"{{{DML_NS}}}p"): paragraph_props = paragraph.find(f"{{{DML_NS}}}pPr") if paragraph_props is None: paragraph_props = ET.Element(f"{{{DML_NS}}}pPr") paragraph.insert(0, paragraph_props) - for child in list(paragraph_props): - if child.tag in bullet_tags: - paragraph_props.remove(child) - insert_at = next( - ( - index - for index, child in enumerate(paragraph_props) - if child.tag in trailing_tags - ), - len(paragraph_props), + _set_no_bullet_paragraph_properties( + paragraph_props, + replace_existing=item.placeholder == "body", ) - paragraph_props.insert(insert_at, ET.Element(f"{{{DML_NS}}}buNone")) def _set_placeholder_theme_font_role( @@ -1847,6 +2241,64 @@ def _layout_placeholder_shape( return shape +def _placeholder_binding_proxy( + layout_placeholder: ET.Element, + item: TemplateElementSpec, +) -> ET.Element: + """Bind a Layout slot invisibly while leaving its visible content ordinary. + + An unbound object placeholder can leak its inherited empty frame into a + finished Slide in non-PowerPoint renderers. A hidden matching proxy suppresses + that inheritance. The zero-width transparent run avoids a LibreOffice empty- + placeholder black fill without adding visible content. + """ + proxy = ET.fromstring(ET.tostring(layout_placeholder, encoding="utf-8")) + c_nv_pr = next(proxy.iter(f"{{{PML_NS}}}cNvPr"), None) + if c_nv_pr is None: + raise TemplateStructureError( + f"Cannot create placeholder binding for {item.element_id!r}: " + "p:cNvPr is missing" + ) + placeholder = proxy.find(f".//{{{PML_NS}}}ph") + if placeholder is None: + raise TemplateStructureError( + f"Cannot create placeholder binding for {item.element_id!r}: " + "p:ph is missing" + ) + c_nv_pr.set( + "name", + "Placeholder Binding " + f"{placeholder.get('type', 'body')} {placeholder.get('idx', '0')}", + ) + c_nv_pr.set("hidden", "1") + tx_body = proxy.find(f"{{{PML_NS}}}txBody") + if tx_body is None: + raise TemplateStructureError( + f"Cannot create placeholder binding for {item.element_id!r}: " + "p:txBody is missing" + ) + for child in list(tx_body): + if child.tag == f"{{{DML_NS}}}p": + tx_body.remove(child) + paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p") + run = ET.SubElement(paragraph, f"{{{DML_NS}}}r") + run_props = ET.SubElement( + run, + f"{{{DML_NS}}}rPr", + {"lang": "en-US", "sz": "100"}, + ) + solid_fill = ET.SubElement(run_props, f"{{{DML_NS}}}solidFill") + color = ET.SubElement(solid_fill, f"{{{DML_NS}}}srgbClr", {"val": "FFFFFF"}) + ET.SubElement(color, f"{{{DML_NS}}}alpha", {"val": "0"}) + ET.SubElement(run, f"{{{DML_NS}}}t").text = "\u200b" + ET.SubElement( + paragraph, + f"{{{DML_NS}}}endParaRPr", + {"lang": "en-US"}, + ) + return proxy + + def _patch_slide_placeholder( shape: ET.Element, item: TemplateElementSpec, @@ -1880,7 +2332,7 @@ def _patch_slide_placeholder( raise TemplateStructureError( f"Placeholder {item.element_id!r} has no non-visual properties" ) - _set_body_placeholder_no_bullets(shape, item) + _set_placeholder_no_inherited_bullets(shape, item) _set_placeholder_theme_font_role(shape, item, theme_font_spec) for existing in list(nv_pr): if existing.tag == f"{{{PML_NS}}}ph": @@ -1933,7 +2385,7 @@ def _set_template_layout_header_footer( _write_xml_tree(layout_path, tree) -def _apply_template_structure( +def _apply_explicit_layout_structure( extract_dir: Path, structure: PptxStructureContext, specs: list[TemplateSlideSpec], @@ -1941,35 +2393,50 @@ def _apply_template_structure( theme_font_spec: ThemeFontSpec | None, *, verbose: bool = False, -) -> None: +) -> tuple[dict[str, str | None], dict[str, tuple[str, ...]]]: """Materialize explicit SVG master/layout/placeholder metadata into OOXML.""" + master_parts_by_key = _assign_structured_masters( + extract_dir, + structure, + specs, + ) states = _template_runtime_slides(extract_dir, specs, conversion_traces) states_by_slide = {state.spec.slide_num: state for state in states} slide_size_emu = _presentation_slide_size_emu(extract_dir) - master_items = specs[0].master_elements + expected_backgrounds: dict[str, str | None] = {} + expected_shape_rosters: dict[str, tuple[str, ...]] = {} states_by_master: dict[str, list[_TemplateRuntimeSlide]] = {} for state in states: - master_part = structure.slide_master_part(state.spec.slide_num) + master_part = master_parts_by_key[state.spec.master_key] states_by_master.setdefault(master_part, []).append(state) + master_shape_count = 0 for master_part, master_states in states_by_master.items(): master_path = extract_dir / master_part master_rels_path = _relationships_path_for_part(extract_dir, master_part) + expected_backgrounds[master_part] = _extract_slide_background_xml( + master_path.read_text(encoding="utf-8") + ) _clear_master_placeholder_shapes(master_path) + master_items = master_states[0].spec.master_elements for item in master_items: - _move_template_static_shape( + background_xml = _move_template_static_shape( master_states, item, master_path, master_rels_path, slide_size_emu, ) + if background_xml is not None: + expected_backgrounds[master_part] = background_xml + master_shape_count += 1 specs_by_layout: dict[str, list[TemplateSlideSpec]] = {} for spec in specs: specs_by_layout.setdefault(spec.layout_key, []).append(spec) placeholder_count = 0 layout_shape_count = 0 + created_layout_parts: set[str] = set() for layout_key, layout_specs in specs_by_layout.items(): layout_states = [states_by_slide[spec.slide_num] for spec in layout_specs] master_parts = { @@ -1995,6 +2462,7 @@ def _apply_template_structure( ) layout_path = extract_dir / layout_part layout_rels_path = _relationships_path_for_part(extract_dir, layout_part) + created_layout_parts.add(layout_part) placeholder_bindings = { binding.element.element_id: binding @@ -2002,22 +2470,38 @@ def _apply_template_structure( } for item in prototype.elements: if item.layer == "layout": - _move_template_static_shape( + background_xml = _move_template_static_shape( layout_states, item, layout_path, layout_rels_path, slide_size_emu, ) + if background_xml is not None: + expected_backgrounds[layout_part] = background_xml layout_shape_count += 1 continue if not item.placeholder: continue - prototype_shape = _template_shape_for_item(layout_states[0], item) - if prototype_shape is None: - raise TemplateStructureError( - f"Placeholder {item.element_id!r} cannot be a slide background" - ) + proxy_binding = is_proxy_placeholder(item) + if proxy_binding: + placeholder_shapes = [ + _template_shape_for_item(state, item) + for state in layout_states + ] + if any(shape is None for shape in placeholder_shapes): + raise TemplateStructureError( + f"Placeholder {item.element_id!r} cannot be a slide background" + ) + resolved_shapes = [ + shape for shape in placeholder_shapes if shape is not None + ] + else: + resolved_shapes = [ + _unwrap_placeholder_carrier(state, item) + for state in layout_states + ] + prototype_shape = resolved_shapes[0] binding = placeholder_bindings[item.element_id] assigned_idx = binding.assigned_idx layout_placeholder = _layout_placeholder_shape( @@ -2027,41 +2511,72 @@ def _apply_template_structure( theme_font_spec, ) _append_shape_to_part(layout_path, layout_placeholder) - for state in layout_states: - shape = _template_shape_for_item(state, item) - if shape is None: - raise TemplateStructureError( - f"{state.spec.svg_path.name}: placeholder {item.element_id!r} " - "did not produce a shape" + for state, shape in zip(layout_states, resolved_shapes): + if not proxy_binding: + _patch_slide_placeholder( + shape, + item, + assigned_idx, + theme_font_spec=theme_font_spec, + ) + _set_shape_name( + shape, + f"{item.element_id} Placeholder Carrier", + ) + else: + _set_shape_name( + shape, + f"{item.element_id} Proxy Content", + ) + _append_shape_to_runtime_slide( + state, + _placeholder_binding_proxy(layout_placeholder, item), ) - _patch_slide_placeholder( - shape, - item, - assigned_idx, - theme_font_spec=theme_font_spec, - ) placeholder_count += 1 _set_template_layout_header_footer(layout_path, prototype.placeholders) for state in layout_states: _set_slide_layout_target(state.rels_path, layout_target) - slide_background_count = _apply_template_slide_backgrounds( + slide_backgrounds = _apply_template_slide_backgrounds( states, slide_size_emu, ) + expected_backgrounds.update(slide_backgrounds) for state in states: _write_xml_tree(state.slide_path, state.tree) + expected_backgrounds.setdefault( + f"ppt/slides/slide{state.spec.slide_num}.xml", + None, + ) + expected_shape_rosters[ + f"ppt/slides/slide{state.spec.slide_num}.xml" + ] = _top_level_shape_name_roster(state.root) + for part in states_by_master: + expected_backgrounds.setdefault( + part, + _extract_slide_background_xml( + (extract_dir / part).read_text(encoding="utf-8") + ), + ) + for part in created_layout_parts: + expected_backgrounds.setdefault(part, None) + for part in (*states_by_master, *created_layout_parts): + expected_shape_rosters[part] = _top_level_shape_name_roster( + ET.parse(extract_dir / part).getroot() + ) if verbose: print( - " Template structure: " + " Explicit Layout structure: " + f"{len(states_by_master)} master(s), " f"{len(specs_by_layout)} layout(s), " - f"{len(master_items)} master element(s), " + f"{master_shape_count} master element(s), " f"{layout_shape_count} layout element(s), " - f"{slide_background_count} slide background(s), " + f"{len(slide_backgrounds)} slide background(s), " f"{placeholder_count} placeholder definition(s)" ) + return expected_backgrounds, expected_shape_rosters def _apply_preserved_structure( @@ -2564,7 +3079,7 @@ def _prune_unused_slide_layouts( master_path.write_text(master_xml, encoding="utf-8") if verbose and pruned: - print(f" Baseline layout prune: removed {pruned} unused base layout(s)") + print(f" Layout prune: removed {pruned} unused base layout(s)") return pruned @@ -3359,11 +3874,13 @@ def create_pptx_with_native_svg( native_objects: bool = False, conversion_trace_path: Path | None = None, doc_metadata: dict[str, Any] | None = None, - pptx_structure: str = "baseline", + pptx_structure: str = "structured", native_structure_contract: NativeStructureContract | None = None, theme_font_spec: ThemeFontSpec | None = None, master_text_style_spec: MasterTextStyleSpec | None = None, theme_color_spec: ThemeColorSpec | None = None, + structured_baseline: bool = False, + baseline_layout_specs: list[TemplateSlideSpec] | None = None, ) -> bool: """Create a PPTX file with native DrawingML shapes. @@ -3405,18 +3922,21 @@ def create_pptx_with_native_svg( then extracts semantic page-role layout families and exact family-wide structurally marked leading chrome; marker-free legacy SVGs retain filename/id fallback; - ``template`` consumes explicit SVG master/layout/placeholder + ``structured`` consumes explicit SVG master/layout/placeholder metadata; ``preserve`` reuses an imported source PPTX package; ``flat`` keeps generated structure slide-local. native_structure_contract: Validated source package contract for ``preserve`` mode. - theme_font_spec: Locked project major/minor fonts for baseline/template + theme_font_spec: Locked project major/minor fonts for baseline/structured theme inheritance. Preserve and flat modes ignore this value. - master_text_style_spec: Required locked title/body sizes for template - slide-master text styles. Other structure modes ignore this value. + master_text_style_spec: Required locked title/body sizes for structured + slide-master text styles. Other structure + routes ignore this value. theme_color_spec: Locked project color scheme for context-aware - baseline/template theme inheritance. Preserve and flat modes + baseline/structured theme inheritance. Preserve and flat modes ignore this value. + structured_baseline: Obsolete compatibility argument; must remain false. + baseline_layout_specs: Obsolete compatibility argument; must remain None. Returns: Whether all slides were successfully created. @@ -3431,14 +3951,22 @@ def create_pptx_with_native_svg( return False use_compat_mode = False - if pptx_structure not in {"baseline", "template", "preserve", "flat"}: + if pptx_structure not in {"baseline", "structured", "preserve", "flat"}: raise ValueError(f"Unsupported pptx_structure: {pptx_structure}") - if pptx_structure == "template" and master_text_style_spec is None: + if structured_baseline: raise ValueError( - "Template export requires locked typography title/body sizes " + "structured_baseline is obsolete; use pptx_structure='structured'" + ) + if baseline_layout_specs is not None: + raise ValueError( + "baseline_layout_specs is obsolete; structured export parses SVG metadata" + ) + if pptx_structure == "structured" and master_text_style_spec is None: + raise ValueError( + "Structured export requires locked typography title/body sizes " "in master_text_style_spec" ) - if use_native_shapes and pptx_structure == "template": + if use_native_shapes and pptx_structure == "structured": template_specs = parse_template_slides(svg_files) elif use_native_shapes and pptx_structure == "preserve": if native_structure_contract is None: @@ -3450,6 +3978,10 @@ def create_pptx_with_native_svg( native_structure_contract.layout(spec.layout_key) else: template_specs = None + template_background_expectations: dict[str, str | None] | None = None + template_shape_roster_expectations: ( + dict[str, tuple[str, ...]] | None + ) = None if template_specs is not None and not native_objects: native_placeholders = sorted({ item.placeholder @@ -3459,8 +3991,11 @@ def create_pptx_with_native_svg( }) if native_placeholders: kinds = ", ".join(str(kind) for kind in native_placeholders) + context = ( + pptx_structure.capitalize() + ) raise TemplateStructureError( - f"Template {kinds} placeholder(s) require --native-objects so each " + f"{context} {kinds} placeholder(s) require --native-objects so each " "marker becomes one native PowerPoint object" ) @@ -3570,30 +4105,18 @@ def create_pptx_with_native_svg( _clear_preserved_slide_collections(extract_dir) active_theme_font_spec = ( theme_font_spec - if use_native_shapes and pptx_structure in {"baseline", "template"} + if use_native_shapes and pptx_structure in {"baseline", "structured"} else None ) if active_theme_font_spec is not None: apply_theme_font_spec(extract_dir, active_theme_font_spec) active_theme_color_spec = ( theme_color_spec - if use_native_shapes and pptx_structure in {"baseline", "template"} + if use_native_shapes and pptx_structure in {"baseline", "structured"} else None ) if active_theme_color_spec is not None: apply_theme_color_spec(extract_dir, active_theme_color_spec) - if pptx_structure == "template": - master_count = apply_master_text_style_spec( - extract_dir, - master_text_style_spec, - ) - if verbose: - print( - " Template master text styles: " - f"{master_count} master(s), " - f"title {master_text_style_spec.title_hpt / 100:g}pt, " - f"body {master_text_style_spec.body_hpt / 100:g}pt" - ) structure = _read_slide_layout_targets(extract_dir, len(svg_files)) media_dir = extract_dir / 'ppt' / 'media' @@ -3641,7 +4164,7 @@ def create_pptx_with_native_svg( conversion_trace: list[dict[str, Any]] | None = [] if conversion_trace_path else None structure_trace: list[dict[str, Any]] | None = ( [] - if use_native_shapes and pptx_structure in {"baseline", "template", "preserve"} + if use_native_shapes and pptx_structure in {"baseline", "structured", "preserve"} else None ) @@ -4076,6 +4599,7 @@ def create_pptx_with_native_svg( extract_dir, len(svg_files), conversion_trace if conversion_trace is not None else structure_trace, + context="Baseline", verbose=verbose, ) _promote_common_slide_backgrounds_to_masters( @@ -4112,21 +4636,24 @@ def create_pptx_with_native_svg( if ( use_native_shapes - and pptx_structure == "template" + and pptx_structure == "structured" and success_count == len(svg_files) ): _convert_page_number_texts_to_fields( extract_dir, len(svg_files), conversion_trace if conversion_trace is not None else structure_trace, - context="Template", + context="Structured", verbose=verbose, ) if template_specs is None: raise TemplateStructureError( - "Template structure metadata was not parsed before export" + "Structured metadata was not parsed before export" ) - _apply_template_structure( + ( + template_background_expectations, + template_shape_roster_expectations, + ) = _apply_explicit_layout_structure( extract_dir, structure, template_specs, @@ -4134,6 +4661,17 @@ def create_pptx_with_native_svg( active_theme_font_spec, verbose=verbose, ) + master_count = apply_master_text_style_spec( + extract_dir, + master_text_style_spec, + ) + if verbose: + print( + " Structured master text styles: " + f"{master_count} master(s), " + f"title {master_text_style_spec.title_hpt / 100:g}pt, " + f"body {master_text_style_spec.body_hpt / 100:g}pt" + ) _prune_unused_slide_layouts( extract_dir, structure, @@ -4256,18 +4794,23 @@ def create_pptx_with_native_svg( zf.write(file_path, arcname) if ( use_native_shapes - and pptx_structure == "template" + and pptx_structure == "structured" and success_count == len(svg_files) ): if template_specs is None: raise TemplateStructureError( - "Template structure metadata was not parsed before validation" + "Explicit Layout metadata was not parsed before validation" ) try: - validate_pptx_template_package(temp_output_path, template_specs) + validate_pptx_template_package( + temp_output_path, + template_specs, + expected_backgrounds=template_background_expectations, + expected_shape_rosters=template_shape_roster_expectations, + ) except ValueError as exc: raise TemplateStructureError( - f"PPTX template package validation failed: {exc}" + f"PPTX structured package validation failed: {exc}" ) from exc try: validate_pptx_transition_package( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py index 59328e44..836261e7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys import json import math +import re import shutil import argparse from datetime import datetime @@ -37,6 +38,7 @@ if __package__ in {None, ''}: from .dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions from .discovery import find_svg_files, find_notes_files from .builder import create_pptx_with_native_svg +from ..native_objects.marker_status import native_marker_release_block_reason from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec from ..drawingml.theme_fonts import ( ThemeFontError, @@ -47,12 +49,10 @@ from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_d from .slide_xml import TRANSITIONS from .template_structure import ( TemplateStructureError, - load_native_structure_contract, load_pptx_structure_lock, - native_structure_lock_errors, - parse_preserve_slides, parse_template_slides, template_lock_errors, + template_prototype_errors, ) from ..animation_config import ( load_animation_config, @@ -66,6 +66,50 @@ def _as_dict(value: object) -> dict: return value if isinstance(value, dict) else {} +_PPTX_STRUCTURE_SECTION_RE = re.compile( + r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)" +) +_PPTX_STRUCTURE_MODE_RE = re.compile( + r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^\s#]+)[ \t]*(?:#.*)?$" +) +_LEGACY_PPTX_STRUCTURE_MODES = frozenset({ + 'baseline', + 'generated', + 'preserve', + 'template', +}) + + +def _declared_pptx_structure_mode(project_path: Path) -> str | None: + """Return the explicitly locked SVG export mode, without legacy fallback.""" + lock_path = project_path / 'spec_lock.md' + try: + content = lock_path.read_text(encoding='utf-8') + except OSError: + return None + section_match = _PPTX_STRUCTURE_SECTION_RE.search(content) + if section_match is None: + return None + mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1)) + return mode_match.group(1).strip().lower() if mode_match else None + + +def _print_structure_migration_error(mode: str | None) -> None: + """Explain how a legacy or absent SVG structure contract is restored.""" + label = repr(mode) if mode else 'missing (legacy implicit baseline)' + print( + "Error: release SVG export requires spec_lock.md " + "pptx_structure.mode: structured; found " + label + ".", + file=sys.stderr, + ) + print( + " Restore explicit Master/Layout metadata first by following " + "skills/ppt-master/workflows/restore-pptx-structure.md, then rerun " + "the quality gate and export.", + file=sys.stderr, + ) + + def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]]: """Return fallback-only native object statuses from SVG inputs.""" fallbacks: list[tuple[str, str, str]] = [] @@ -83,6 +127,50 @@ def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str] return fallbacks +def _release_blocked_graphics( + svg_files: list[Path], +) -> list[tuple[str, str, str]]: + """Return graphics whose status metadata is invalid.""" + blocked: list[tuple[str, str, str]] = [] + for svg_path in svg_files: + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError): + continue + for elem in root.iter(): + if elem.tag.rsplit('}', 1)[-1] == 'metadata': + continue + reason = native_marker_release_block_reason(elem) + if reason is None: + continue + marker_id = elem.get('id') or elem.get('data-name') or '' + blocked.append((svg_path.name, marker_id, reason)) + return blocked + + +def _reconstruction_only_graphics( + svg_files: list[Path], +) -> list[tuple[str, str, bool]]: + """Return valid placeholder routes for non-blocking diagnostics.""" + diagnostics: list[tuple[str, str, bool]] = [] + for svg_path in svg_files: + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError): + continue + for elem in root.iter(): + if elem.tag.rsplit('}', 1)[-1] == 'metadata': + continue + if elem.get('data-pptx-route-status') != 'reconstruction-only': + continue + if native_marker_release_block_reason(elem) is not None: + continue + marker_id = elem.get('id') or elem.get('data-name') or '' + active_native = bool((elem.get('data-pptx-native') or '').strip()) + diagnostics.append((svg_path.name, marker_id, active_native)) + return diagnostics + + def _recorded_narration_on_click_slides( ref_files: list[Path], animation_config: dict | None, @@ -214,24 +302,30 @@ Recorded narration: 'conversion decisions for debugging.') parser.add_argument('--native-objects', action='store_true', default=False, help='Opt in to converting explicit data-pptx-native table/chart ' - 'markers into native PowerPoint objects. Default off: marked ' - 'groups export through their SVG fallback children. When set, ' + 'markers into editable PowerPoint objects. This editable-first ' + 'replacement may normalize styling or omit unmodeled marker-local ' + 'visuals. Default off: marked groups export through their SVG ' + 'fallback children. When set, ' 'the default-flow export is named __native_charts.pptx ' 'to tell it apart from a plain shape export.') parser.add_argument( '--pptx-structure', - choices=['baseline', 'template', 'preserve', 'flat'], + choices=[ + 'structured', + 'flat', + 'baseline', + 'template', + 'preserve', + 'generated', + ], default=None, help=( - 'PPTX structure strategy for native export. When omitted, read ' - 'spec_lock.md pptx_structure.mode, falling back to baseline. baseline ' - 'promotes safe repeated background/chrome and extracts conservative ' - 'semantic page-role layout families plus exact family-wide ' - 'structurally marked chrome (legacy filenames/ids remain fallbacks); ' - 'template consumes explicit ' - 'data-pptx-layout/layer/placeholder metadata to build reusable layouts; ' - 'preserve is legacy compatibility for imported source packages; ' - 'flat leaves generated structure slide-local for debugging/comparison.' + 'PPTX structure strategy for native export. Release export requires ' + 'spec_lock.md pptx_structure.mode: structured and complete explicit ' + 'Master/Layout metadata in every SVG. flat is an explicit diagnostic ' + 'override that leaves all generated objects slide-local. baseline, ' + 'template, preserve, and generated are accepted only so legacy ' + 'invocations can receive a targeted migration error.' ), ) parser.add_argument('--no-image-optimize', action='store_true', @@ -316,46 +410,44 @@ Recorded narration: if not project_path.exists(): print(f"Error: Path does not exist: {project_path}") return 1 + structure_lock = None native_structure_contract = None pptx_structure = args.pptx_structure + declared_structure_mode = _declared_pptx_structure_mode(project_path) + if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES: + _print_structure_migration_error(pptx_structure) + return 1 if pptx_structure is None: + if declared_structure_mode != 'structured': + _print_structure_migration_error(declared_structure_mode) + return 1 + pptx_structure = 'structured' + elif pptx_structure == 'structured' and declared_structure_mode != 'structured': + _print_structure_migration_error(declared_structure_mode) + return 1 + + if pptx_structure == 'structured': try: structure_lock = load_pptx_structure_lock(project_path) except TemplateStructureError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 - pptx_structure = structure_lock.mode if structure_lock else 'baseline' - elif pptx_structure == 'preserve': - try: - structure_lock = load_pptx_structure_lock(project_path) - except TemplateStructureError as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - if structure_lock is None or structure_lock.mode != 'preserve': + if structure_lock is None or structure_lock.mode != 'structured': print( - "Error: --pptx-structure preserve requires a preserve-mode " - "spec_lock.md with source_template/native_structure rows", + "Error: spec_lock.md must contain one complete " + "pptx_structure.mode: structured contract", file=sys.stderr, ) return 1 - if pptx_structure == 'preserve': - if structure_lock is None: - print("Error: preserve mode requires spec_lock.md", file=sys.stderr) - return 1 - try: - native_structure_contract = load_native_structure_contract(structure_lock) - except TemplateStructureError as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 + theme_font_spec = None master_text_style_spec = None theme_color_spec = None - if pptx_structure in {'baseline', 'template'}: + if pptx_structure == 'structured': try: theme_font_spec = load_theme_font_spec(project_path) - if pptx_structure == 'template': - master_text_style_spec = load_master_text_style_spec(project_path) + master_text_style_spec = load_master_text_style_spec(project_path) theme_color_spec = load_theme_color_spec(project_path) except (ThemeFontError, ThemeColorError) as exc: print(f"Error: {exc}", file=sys.stderr) @@ -391,16 +483,13 @@ Recorded narration: print("Error: No SVG files found") return 1 - if ( - pptx_structure in {'template', 'preserve'} - and structure_lock is not None - ): + # Compatibility kwargs remain until the builder's old baseline-specific + # parameters are removed. Structured export never activates either path. + structured_baseline = False + baseline_layout_specs = None + if pptx_structure == 'structured' and structure_lock is not None: try: - template_specs = ( - parse_preserve_slides(native_files) - if pptx_structure == 'preserve' - else parse_template_slides(native_files) - ) + template_specs = parse_template_slides(native_files) except TemplateStructureError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 @@ -410,25 +499,61 @@ Recorded narration: for message in lock_errors: print(f" {message}", file=sys.stderr) return 1 - if pptx_structure == 'preserve': - if native_structure_contract is None: - print("Error: preserve mode contract is unavailable", file=sys.stderr) - return 1 - preserve_errors = native_structure_lock_errors( - template_specs, - structure_lock, - native_structure_contract, + prototype_errors = template_prototype_errors( + template_specs, + structure_lock, + ) + if prototype_errors: + print( + "Error: structured template output does not match page_layouts " + "prototypes:", + file=sys.stderr, + ) + for message in prototype_errors: + print(f" {message}", file=sys.stderr) + return 1 + + release_blocked = _release_blocked_graphics(native_files) + if release_blocked: + print( + "Error: invalid PPTX graphic status metadata cannot enter an export. " + "Correct the reported visual/route/native status attributes first.", + file=sys.stderr, + ) + for filename, marker_id, status in release_blocked[:20]: + print(f" {filename}: {marker_id} ({status})", file=sys.stderr) + if len(release_blocked) > 20: + print( + f" ... and {len(release_blocked) - 20} more", + file=sys.stderr, + ) + return 1 + + reconstruction_only = _reconstruction_only_graphics(native_files) + if reconstruction_only: + print( + "Warning: reconstruction-only PPTX chart placeholder(s) have no baked " + "preview. Default export keeps the placeholder; --native-objects " + "reconstructs entries that carry a valid active native marker.", + file=sys.stderr, + ) + for filename, marker_id, active_native in reconstruction_only[:20]: + route = "active native reconstruction" if active_native else "placeholder fallback" + print(f" {filename}: {marker_id} ({route})", file=sys.stderr) + if len(reconstruction_only) > 20: + print( + f" ... and {len(reconstruction_only) - 20} more", + file=sys.stderr, ) - if preserve_errors: - print( - "Error: PPTX structure does not match native_structure.json:", - file=sys.stderr, - ) - for message in preserve_errors: - print(f" {message}", file=sys.stderr) - return 1 if args.native_objects: + print( + "Warning: --native-objects is an editable-first replacement route. " + "Native charts/tables may normalize styling or omit SVG details that " + "are not represented by marker metadata; use the default export when " + "exact fallback artwork is required.", + file=sys.stderr, + ) fallbacks = _native_object_fallbacks(native_files) if fallbacks: print( @@ -748,6 +873,8 @@ Recorded narration: image_quality=args.image_quality, native_objects=args.native_objects, pptx_structure=pptx_structure, + structured_baseline=structured_baseline, + baseline_layout_specs=baseline_layout_specs, native_structure_contract=native_structure_contract, theme_font_spec=theme_font_spec, master_text_style_spec=master_text_style_spec, @@ -794,26 +921,6 @@ Recorded narration: print(f" [warn] svg_output backup skipped: {exc}") elif verbose: print(f" [info] svg_output/ not found, backup skipped") - if pptx_structure == 'preserve' and structure_lock is not None: - try: - preserve_sources = [ - project_path / 'spec_lock.md', - structure_lock.source_template, - structure_lock.native_structure, - ] - for source in preserve_sources: - if source is None or not source.is_file(): - continue - source_path = source.resolve() - relative = source_path.relative_to(project_path.resolve()) - destination = backup_dir / relative - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source_path, destination) - if verbose: - print(f" preserve contract backup: {backup_dir}") - except (OSError, ValueError) as exc: - if verbose: - print(f" [warn] preserve contract backup skipped: {exc}") return 0 if success else 1 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py index 542248e9..25a6b625 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py @@ -39,6 +39,7 @@ AUDIO_CONTENT_TYPES = { NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys()) AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px +AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU AUDIO_MARKER_PNG_BYTES = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG" "cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu" @@ -207,7 +208,14 @@ def _create_audio_pic_element( shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr")) transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm")) - ET.SubElement(transform, _qn(DRAWINGML_NS, "off"), {"x": "0", "y": "0"}) + ET.SubElement( + transform, + _qn(DRAWINGML_NS, "off"), + { + "x": str(AUDIO_MARKER_OFF_CANVAS_EMU), + "y": str(AUDIO_MARKER_OFF_CANVAS_EMU), + }, + ) ET.SubElement( transform, _qn(DRAWINGML_NS, "ext"), @@ -232,7 +240,7 @@ def create_audio_pic_xml( media_rid: str, poster_rid: str, ) -> str: - """Create a visible audio picture shape carrying narration media.""" + """Create an off-canvas audio picture shape carrying narration media.""" element = _create_audio_pic_element( shape_id, shape_name, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_structure.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_structure.py index f4b3f367..a9b9295d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_structure.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_structure.py @@ -2,7 +2,7 @@ """ PPT Master - Template Structure Metadata -Parse and validate explicit SVG metadata consumed by template-mode PPTX export. +Parse and validate explicit SVG metadata consumed by structured PPTX export. Usage: Imported by svg_to_pptx.pptx_package.builder and svg_quality_checker.py. @@ -36,9 +36,14 @@ _NON_VISUAL_TAGS = frozenset({"defs", "title", "desc", "metadata", "style"}) _STRUCTURE_ATTRS = frozenset({ "data-pptx-layer", "data-pptx-layout", + "data-pptx-layout-kind", "data-pptx-layout-name", + "data-pptx-master", + "data-pptx-master-name", "data-pptx-placeholder", + "data-pptx-placeholder-binding", "data-pptx-placeholder-bounds", + "data-pptx-placeholder-carrier", "data-pptx-placeholder-idx", "data-pptx-editable", }) @@ -87,12 +92,54 @@ _OBJECT_PLACEHOLDER_TAGS = frozenset({ "polyline", "text", "image", + "svg", + "use", }) _LAYOUT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") -_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$") +_MASTER_KEY_RE = _LAYOUT_KEY_RE +_LOCK_ROW_RE = re.compile( + r"^-\s+([A-Za-z0-9][A-Za-z0-9._-]*)\s*:\s*(.+?)\s*$" +) _LOCK_PAGE_RE = re.compile(r"^P(\d+)$") -PPTX_STRUCTURE_MODES = frozenset({"baseline", "template", "preserve", "flat"}) +PPTX_STRUCTURE_MODES = frozenset({"structured", "preserve", "flat"}) TEMPLATE_ADHERENCE_MODES = frozenset({"strict", "adaptive"}) +PLACEHOLDER_BINDING_MODES = frozenset({"carrier", "proxy"}) +_TEMPLATE_SKIN_ATTRS = frozenset({ + "color", + "fill", + "fill-opacity", + "filter", + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "opacity", + "paint-order", + "stop-color", + "stop-opacity", + "stroke", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "style", + "text-decoration", + "word-spacing", +}) +_CSS_RULE_RE = re.compile(r"(?s)([^{}]+)\{([^{}]*)\}") +_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +_CSS_ID_RE = re.compile(r"#([A-Za-z_][A-Za-z0-9_-]*)") +_CSS_CLASS_RE = re.compile(r"\.([A-Za-z_][A-Za-z0-9_-]*)") +_CSS_ATTR_RE = re.compile( + r"\[\s*([A-Za-z_:][A-Za-z0-9_.:-]*)" + r"(?:\s*(?:[~|^$*]?=)\s*(['\"]?)([^\]'\"]+)\2)?\s*\]" +) +_CSS_URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE) +_CSS_TAG_RE = re.compile(r"(?:^|[\s>+~])([A-Za-z_][A-Za-z0-9_-]*|\*)") NATIVE_STRUCTURE_SCHEMA = "ppt-master.native-structure.v1" OOXML_UINT32_MAX = (1 << 32) - 1 @@ -108,6 +155,25 @@ class PptxLayoutReference: slide_num: int layout_key: str layout_name: str | None = None + master_key: str | None = None + + +@dataclass(frozen=True) +class PptxMasterReference: + """One named Master declared by the structured project lock.""" + + master_key: str + master_name: str + + +@dataclass(frozen=True) +class PptxPrototypeReference: + """One spec_lock page-to-template-SVG prototype declaration.""" + + slide_num: int + template_basename: str + svg_path: Path + replication_mode: str | None = None @dataclass(frozen=True) @@ -116,7 +182,9 @@ class PptxStructureLock: mode: str template_adherence: str | None = None + masters: tuple[PptxMasterReference, ...] = () layouts: tuple[PptxLayoutReference, ...] = () + prototypes: tuple[PptxPrototypeReference, ...] = () source_template: Path | None = None native_structure: Path | None = None @@ -172,6 +240,8 @@ class TemplateElementSpec: placeholder: str | None = None placeholder_bounds: tuple[float, float, float, float] | None = None placeholder_idx: int | None = None + placeholder_binding: str | None = None + placeholder_carrier_tag: str | None = None is_background: bool = False def contract_signature(self) -> tuple[object, ...]: @@ -183,6 +253,8 @@ class TemplateElementSpec: self.placeholder, self.placeholder_bounds, self.placeholder_idx, + self.placeholder_binding, + self.placeholder_carrier_tag, self.is_background, ) @@ -193,6 +265,8 @@ class TemplateSlideSpec: slide_num: int svg_path: Path + master_key: str + master_name: str layout_key: str layout_name: str elements: tuple[TemplateElementSpec, ...] @@ -218,6 +292,11 @@ class TemplateSlideSpec: ) +def is_proxy_placeholder(item: TemplateElementSpec) -> bool: + """Return whether a visible composite slot uses an invisible binding proxy.""" + return item.placeholder_binding == "proxy" + + @dataclass(frozen=True) class TemplatePlaceholderBinding: """Resolved PowerPoint identity for one template placeholder.""" @@ -378,8 +457,31 @@ def _portable_project_file( return resolved +def _template_replication_mode(template_dir: Path) -> str | None: + """Read the optional replication mode from template design frontmatter.""" + spec_path = template_dir / "design_spec.md" + try: + lines = spec_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None + if not lines or lines[0].strip() != "---": + return None + for line in lines[1:]: + stripped = line.strip() + if stripped == "---": + return None + match = re.fullmatch( + r"replication_mode\s*:\s*[\"']?(standard|fidelity|mirror)[\"']?", + stripped, + flags=re.IGNORECASE, + ) + if match: + return match.group(1).lower() + return None + + def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None: - """Load optional pptx_structure/pptx_layouts sections from spec_lock.md.""" + """Load optional native-structure sections from spec_lock.md.""" lock_path = project_path / "spec_lock.md" if not lock_path.is_file(): return None @@ -396,15 +498,35 @@ def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None: current_section = line[3:].strip() sections.setdefault(current_section, []) continue - if current_section not in {"pptx_structure", "pptx_layouts"}: + if current_section not in { + "pptx_structure", + "pptx_masters", + "pptx_layouts", + "page_layouts", + }: continue match = _LOCK_ROW_RE.fullmatch(line) if match: sections[current_section].append((match.group(1), match.group(2))) structure_rows = sections.get("pptx_structure", []) + master_rows = sections.get("pptx_masters", []) layout_rows = sections.get("pptx_layouts", []) - if not structure_rows and not layout_rows: + prototype_rows = sections.get("page_layouts", []) + structure_section_present = "pptx_structure" in sections + master_section_present = "pptx_masters" in sections + layout_section_present = "pptx_layouts" in sections + prototype_section_present = "page_layouts" in sections + if ( + not structure_rows + and not master_rows + and not layout_rows + and not prototype_rows + and not structure_section_present + and not master_section_present + and not layout_section_present + and not prototype_section_present + ): return None mode_rows = [value.strip().lower() for key, value in structure_rows if key == "mode"] if len(mode_rows) != 1: @@ -438,7 +560,17 @@ def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None: if mode == "preserve" and template_adherence == "adaptive": raise TemplateStructureError( "spec_lock.md preserve mode requires template_adherence: strict; " - "adaptive template use must export through template mode" + "adaptive template use must export through structured mode" + ) + if template_adherence and mode not in {"structured", "preserve"}: + raise TemplateStructureError( + "spec_lock.md template_adherence is allowed only in structured or " + "preserve mode" + ) + if any(key == "layout_strategy" for key, _value in structure_rows): + raise TemplateStructureError( + "spec_lock.md pptx_structure.layout_strategy is obsolete; SVG pages " + "must declare their final structured Master/Layout contract directly" ) source_rows = [ @@ -473,6 +605,97 @@ def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None: "when pptx_structure.mode is preserve" ) + masters: list[PptxMasterReference] = [] + seen_master_keys: set[str] = set() + for master_key, raw_name in master_rows: + master_name = raw_name.strip() + if not _MASTER_KEY_RE.fullmatch(master_key): + raise TemplateStructureError( + f"spec_lock.md has invalid Master key {master_key!r}; use 1-64 " + "ASCII letters, digits, dots, underscores, or hyphens" + ) + if master_key in seen_master_keys: + raise TemplateStructureError( + f"spec_lock.md pptx_masters repeats Master key {master_key!r}" + ) + if not master_name: + raise TemplateStructureError( + f"spec_lock.md Master {master_key!r} has an empty name" + ) + seen_master_keys.add(master_key) + masters.append(PptxMasterReference(master_key, master_name)) + + if mode == "structured": + if not master_rows: + raise TemplateStructureError( + "spec_lock.md structured mode requires a non-empty pptx_masters section" + ) + elif master_section_present: + raise TemplateStructureError( + "spec_lock.md pptx_masters is allowed only when " + "pptx_structure.mode is structured" + ) + + prototypes: list[PptxPrototypeReference] = [] + seen_prototype_slides: set[int] = set() + template_dir = (project_path / "templates").resolve() + template_replication_mode = _template_replication_mode(template_dir) + if mode != "structured" and prototype_rows: + raise TemplateStructureError( + "spec_lock.md page_layouts is allowed only when pptx_structure.mode " + "is structured" + ) + for page_key, raw_value in prototype_rows: + page_match = _LOCK_PAGE_RE.fullmatch(page_key) + if not page_match or int(page_match.group(1)) <= 0: + raise TemplateStructureError( + f"spec_lock.md page_layouts key {page_key!r} must be P" + ) + slide_num = int(page_match.group(1)) + if slide_num in seen_prototype_slides: + raise TemplateStructureError( + f"spec_lock.md page_layouts repeats page P{slide_num:02d}" + ) + seen_prototype_slides.add(slide_num) + raw_basename = raw_value.strip() + basename = ( + raw_basename[:-4] + if raw_basename.lower().endswith(".svg") + else raw_basename + ) + if ( + not basename + or basename in {".", ".."} + or "/" in basename + or "\\" in basename + or any(ord(char) < 0x20 for char in basename) + ): + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} has invalid template SVG basename " + f"{raw_basename!r}" + ) + svg_path = (template_dir / f"{basename}.svg").resolve() + if svg_path.parent != template_dir or not svg_path.is_file(): + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} references missing template SVG " + f"templates/{basename}.svg" + ) + prototypes.append(PptxPrototypeReference( + slide_num=slide_num, + template_basename=basename, + svg_path=svg_path, + replication_mode=template_replication_mode, + )) + + if mode == "structured" and template_adherence and not prototypes: + raise TemplateStructureError( + "spec_lock.md structured template use requires one page_layouts row per page" + ) + if prototypes and not template_adherence: + raise TemplateStructureError( + "spec_lock.md page_layouts requires template_adherence: strict or adaptive" + ) + references: list[PptxLayoutReference] = [] seen_slides: set[int] = set() for page_key, raw_value in layout_rows: @@ -487,37 +710,86 @@ def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None: f"spec_lock.md pptx_layouts repeats page P{slide_num:02d}" ) seen_slides.add(slide_num) - value_parts = raw_value.split("|", 1) - layout_key = value_parts[0].strip() - layout_name = value_parts[1].strip() if len(value_parts) == 2 else None + parts = [part.strip() for part in raw_value.split("|")] + master_key: str | None = None + if mode == "structured": + if len(parts) != 3 or not all(parts): + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} structured mapping must be " + "' | | '" + ) + master_key, layout_key, layout_name = parts + if not _MASTER_KEY_RE.fullmatch(master_key): + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} has invalid Master key " + f"{master_key!r}" + ) + if master_key not in seen_master_keys: + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} references undeclared Master " + f"{master_key!r}" + ) + else: + if len(parts) not in {1, 2} or not parts[0]: + raise TemplateStructureError( + f"spec_lock.md P{slide_num:02d} preserve mapping must be " + "'' or ' | '" + ) + layout_key = parts[0] + layout_name = parts[1] if len(parts) == 2 else None if not _LAYOUT_KEY_RE.fullmatch(layout_key): raise TemplateStructureError( f"spec_lock.md P{slide_num:02d} has invalid layout key " f"{layout_key!r}" ) - if len(value_parts) == 2 and not layout_name: - raise TemplateStructureError( - f"spec_lock.md P{slide_num:02d} has an empty layout name" - ) references.append(PptxLayoutReference( slide_num=slide_num, layout_key=layout_key, layout_name=layout_name, + master_key=master_key, )) - if mode in {"template", "preserve"} and not references: + if mode in {"structured", "preserve"} and not references: raise TemplateStructureError( f"spec_lock.md {mode} mode requires one pptx_layouts row per page" ) - if mode not in {"template", "preserve"} and references: + if mode == "structured" and layout_section_present and not layout_rows: raise TemplateStructureError( - "spec_lock.md pptx_layouts is allowed only when pptx_structure.mode " - "is template or preserve" + "spec_lock.md structured pptx_layouts section cannot be empty" + ) + layout_contracts: dict[str, tuple[str | None, str | None]] = {} + for reference in references: + contract = (reference.master_key, reference.layout_name) + previous = layout_contracts.setdefault(reference.layout_key, contract) + if previous != contract: + raise TemplateStructureError( + f"spec_lock.md layout key {reference.layout_key!r} must be globally " + "unique to one Master and one Layout name" + ) + if mode == "structured": + unused_masters = sorted( + seen_master_keys - { + reference.master_key + for reference in references + if reference.master_key is not None + } + ) + if unused_masters: + raise TemplateStructureError( + "spec_lock.md pptx_masters contains unreferenced Master key(s): " + + ", ".join(unused_masters) + ) + if mode == "flat" and layout_section_present: + raise TemplateStructureError( + "spec_lock.md pptx_layouts section is not allowed when " + "pptx_structure.mode is flat" ) return PptxStructureLock( mode=mode, template_adherence=template_adherence, + masters=tuple(masters), layouts=tuple(sorted(references, key=lambda item: item.slide_num)), + prototypes=tuple(sorted(prototypes, key=lambda item: item.slide_num)), source_template=source_template, native_structure=native_structure, ) @@ -745,44 +1017,53 @@ def _parse_placeholder_idx( return parsed -def _validate_placeholder_element( - elem: ET.Element, +def _validate_placeholder_carrier( + carrier: ET.Element, placeholder: str, *, svg_path: Path, element_id: str, ) -> None: - tag = _local_tag(elem) + tag = _local_tag(carrier) if placeholder in _TEXT_PLACEHOLDERS and tag != "text": raise TemplateStructureError( f"{svg_path.name}: {element_id} placeholder '{placeholder}' must be " - "declared on a direct element" + "carried by one direct child" ) if placeholder == "picture" and tag not in {"image", "svg"}: raise TemplateStructureError( f"{svg_path.name}: {element_id} picture placeholder must be declared " - "on a direct or nested crop element" + "with one direct or crop carrier" ) if placeholder == "media" and tag not in {"image", "svg"}: raise TemplateStructureError( f"{svg_path.name}: {element_id} media placeholder must be declared " - "on a direct or nested crop element" + "with one direct or crop carrier" ) if placeholder == "object" and tag not in _OBJECT_PLACEHOLDER_TAGS: raise TemplateStructureError( - f"{svg_path.name}: {element_id} object placeholder must resolve to " + f"{svg_path.name}: {element_id} object placeholder carrier must be " "one direct text, image, or basic SVG shape" ) if placeholder in {"chart", "table"}: - native_kind = (elem.get("data-pptx-native") or "").strip().lower() + native_kind = (carrier.get("data-pptx-native") or "").strip().lower() if tag != "g" or native_kind != placeholder: raise TemplateStructureError( f"{svg_path.name}: {element_id} placeholder '{placeholder}' must be " - f"a direct marker" + f"carried by one direct marker" ) -def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: +def _structure_attrs(elem: ET.Element) -> list[str]: + return sorted(attr for attr in _STRUCTURE_ATTRS if elem.get(attr) is not None) + + +def parse_template_slide( + svg_path: Path, + slide_num: int, + *, + structured: bool = True, +) -> TemplateSlideSpec: """Parse one SVG's explicit template layout and structure elements.""" try: root = ET.parse(svg_path).getroot() @@ -801,10 +1082,30 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: if _local_tag(root) != "svg": raise TemplateStructureError(f"{svg_path.name}: root element must be ") + master_key = (root.get("data-pptx-master") or "").strip() + master_name = (root.get("data-pptx-master-name") or "").strip() + if structured and not master_key: + raise TemplateStructureError( + f"{svg_path.name}: structured export requires root data-pptx-master" + ) + if structured and not master_name: + raise TemplateStructureError( + f"{svg_path.name}: structured export requires root data-pptx-master-name" + ) + if not master_key: + master_key = "preserved-source" + if not master_name: + master_name = "Preserved Source Master" + if not _MASTER_KEY_RE.fullmatch(master_key): + raise TemplateStructureError( + f"{svg_path.name}: invalid data-pptx-master {master_key!r}; use 1-64 " + "ASCII letters, digits, dots, underscores, or hyphens" + ) + layout_key = (root.get("data-pptx-layout") or "").strip() if not layout_key: raise TemplateStructureError( - f"{svg_path.name}: template export requires root data-pptx-layout" + f"{svg_path.name}: explicit Layout export requires root data-pptx-layout" ) if not _LAYOUT_KEY_RE.fullmatch(layout_key): raise TemplateStructureError( @@ -812,13 +1113,27 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: "ASCII letters, digits, dots, underscores, or hyphens" ) layout_name = (root.get("data-pptx-layout-name") or "").strip() + if structured and not layout_name: + raise TemplateStructureError( + f"{svg_path.name}: structured export requires root data-pptx-layout-name" + ) if not layout_name: layout_name = re.sub(r"[-_.]+", " ", layout_key).strip().title() or layout_key + if structured and root.get("data-pptx-layout-kind") is not None: + raise TemplateStructureError( + f"{svg_path.name}: data-pptx-layout-kind is obsolete; the root " + "Master/Layout identity is already final" + ) illegal_root_attrs = sorted( attr for attr in _STRUCTURE_ATTRS if ( - attr not in {"data-pptx-layout", "data-pptx-layout-name"} + attr not in { + "data-pptx-layout", + "data-pptx-layout-name", + "data-pptx-master", + "data-pptx-master-name", + } and root.get(attr) is not None ) ) @@ -835,22 +1150,10 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: duplicate_ids = sorted(element_id for element_id, count in id_counts.items() if count > 1) if duplicate_ids: raise TemplateStructureError( - f"{svg_path.name}: duplicate SVG id(s) are not allowed in template mode: " + f"{svg_path.name}: duplicate SVG id(s) are not allowed in explicit Layout mode: " + ", ".join(duplicate_ids) ) - direct_children = set(root) - for elem in root.iter(): - if elem is root or elem in direct_children: - continue - nested_attrs = sorted(attr for attr in _STRUCTURE_ATTRS if elem.get(attr) is not None) - if nested_attrs: - element_id = elem.get("id") or _local_tag(elem) or "" - raise TemplateStructureError( - f"{svg_path.name}: {element_id} uses template metadata below the SVG " - "root; structure metadata is allowed only on direct children" - ) - elements: list[TemplateElementSpec] = [] canvas = _svg_canvas(root) last_order_rank = -1 @@ -869,6 +1172,8 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: ) bounds_raw = elem.get("data-pptx-placeholder-bounds") placeholder_idx_raw = elem.get("data-pptx-placeholder-idx") + binding_raw = elem.get("data-pptx-placeholder-binding") + carrier_raw = elem.get("data-pptx-placeholder-carrier") editable_raw = elem.get("data-pptx-editable") is_background = _is_full_canvas_solid_rect(elem, canvas) effective_layer = layer or ("slide" if is_background else None) @@ -876,10 +1181,12 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: if ( elem.get("data-pptx-layout") is not None or elem.get("data-pptx-layout-name") is not None + or elem.get("data-pptx-master") is not None + or elem.get("data-pptx-master-name") is not None ): raise TemplateStructureError( - f"{svg_path.name}: data-pptx-layout and data-pptx-layout-name belong " - "on the root only" + f"{svg_path.name}: Master/Layout identity attributes belong on " + "the root only" ) if layer and layer not in _LAYERS: raise TemplateStructureError( @@ -910,6 +1217,11 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: f"{svg_path.name}: data-pptx-layer='slide' is allowed only on a " "direct full-canvas solid background rect" ) + if structured and layer in {"master", "layout"} and tag == "g": + raise TemplateStructureError( + f"{svg_path.name}: {element_id or tag} is a on the {layer} " + "layer; Master/Layout fixed elements must be root-level atoms" + ) if bounds_raw is not None and not placeholder: raise TemplateStructureError( f"{svg_path.name}: {element_id or tag} has placeholder bounds without " @@ -920,9 +1232,20 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: f"{svg_path.name}: {element_id or tag} has placeholder idx without " "data-pptx-placeholder" ) + if binding_raw is not None and not placeholder: + raise TemplateStructureError( + f"{svg_path.name}: {element_id or tag} has placeholder binding " + "without data-pptx-placeholder" + ) + if carrier_raw is not None: + raise TemplateStructureError( + f"{svg_path.name}: {element_id or tag} declares " + "data-pptx-placeholder-carrier on a root child; the marker belongs " + "on the direct child inside a placeholder " + ) if (effective_layer or placeholder) and not element_id: raise TemplateStructureError( - f"{svg_path.name}: direct <{tag}> with template metadata requires an id" + f"{svg_path.name}: direct <{tag}> with Layout metadata requires an id" ) if editable_raw is not None: if not effective_layer or editable_raw.strip().lower() != "false": @@ -947,24 +1270,144 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: ) last_order_rank = order_rank - if placeholder: - _validate_placeholder_element( - elem, - placeholder, - svg_path=svg_path, - element_id=element_id, - ) placeholder_bounds = _parse_placeholder_bounds( bounds_raw, svg_path=svg_path, element_id=element_id or tag, ) + if structured and placeholder and placeholder_bounds is None: + raise TemplateStructureError( + f"{svg_path.name}: Layout placeholder {element_id!r} requires " + "explicit data-pptx-placeholder-bounds; define the " + "reusable frame from the design zone, not the current text bounds" + ) placeholder_idx = _parse_placeholder_idx( placeholder_idx_raw, svg_path=svg_path, element_id=element_id or tag, ) + placeholder_binding: str | None = None + placeholder_carrier_tag: str | None = None + if placeholder and structured: + if tag != "g": + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} must be declared " + "on a root-level authoring boundary" + ) + wrapper_visual_attrs = sorted( + name + for name in elem.attrib + if name != "id" and not name.startswith("data-pptx-") + ) + if wrapper_visual_attrs: + raise TemplateStructureError( + f"{svg_path.name}: placeholder group {element_id!r} must be a " + "render-neutral authoring boundary; move these attributes to " + "its content: " + ", ".join(wrapper_visual_attrs) + ) + placeholder_binding = (binding_raw or "carrier").strip().lower() + if binding_raw is not None and not binding_raw.strip(): + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} has an empty " + "data-pptx-placeholder-binding" + ) + if placeholder_binding not in PLACEHOLDER_BINDING_MODES: + allowed = ", ".join(sorted(PLACEHOLDER_BINDING_MODES)) + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} binding must be " + f"one of: {allowed}" + ) + visual_children = [ + child for child in elem if _local_tag(child) not in _NON_VISUAL_TAGS + ] + carrier_children = [ + child + for child in visual_children + if (child.get("data-pptx-placeholder-carrier") or "") + .strip() + .lower() + == "true" + ] + for child in elem: + marker = child.get("data-pptx-placeholder-carrier") + if marker is not None and marker.strip().lower() != "true": + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} carrier marker " + "must be exactly 'true'" + ) + illegal_child_attrs = [ + attr + for attr in _structure_attrs(child) + if attr != "data-pptx-placeholder-carrier" + ] + if illegal_child_attrs: + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} child uses " + "nested structure metadata: " + ", ".join(illegal_child_attrs) + ) + for descendant in child.iter(): + if descendant is child: + continue + nested_attrs = _structure_attrs(descendant) + if nested_attrs: + nested_id = descendant.get("id") or _local_tag(descendant) + raise TemplateStructureError( + f"{svg_path.name}: {nested_id} uses nested structure " + "metadata: " + ", ".join(nested_attrs) + ) + if placeholder_binding == "proxy": + if placeholder != "object": + raise TemplateStructureError( + f"{svg_path.name}: placeholder {element_id!r} may use proxy " + "binding only with data-pptx-placeholder='object'" + ) + if carrier_children: + raise TemplateStructureError( + f"{svg_path.name}: proxy placeholder {element_id!r} must not " + "declare a carrier child" + ) + if not visual_children: + raise TemplateStructureError( + f"{svg_path.name}: proxy placeholder {element_id!r} must " + "contain visible Slide-local content" + ) + else: + if len(visual_children) != 1 or len(carrier_children) != 1: + raise TemplateStructureError( + f"{svg_path.name}: carrier placeholder {element_id!r} must " + "contain exactly one visual direct child and mark it " + "data-pptx-placeholder-carrier='true'" + ) + carrier = carrier_children[0] + placeholder_carrier_tag = _local_tag(carrier) + _validate_placeholder_carrier( + carrier, + placeholder, + svg_path=svg_path, + element_id=element_id, + ) + elif placeholder: + placeholder_binding = "carrier" + placeholder_carrier_tag = tag + _validate_placeholder_carrier( + elem, + placeholder, + svg_path=svg_path, + element_id=element_id, + ) + else: + for descendant in elem.iter(): + if descendant is elem: + continue + nested_attrs = _structure_attrs(descendant) + if nested_attrs: + nested_id = descendant.get("id") or _local_tag(descendant) + raise TemplateStructureError( + f"{svg_path.name}: {nested_id} uses structure metadata below " + "the SVG root: " + ", ".join(nested_attrs) + ) + if effective_layer or placeholder: elements.append(TemplateElementSpec( element_id=element_id, @@ -974,6 +1417,8 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: placeholder=placeholder, placeholder_bounds=placeholder_bounds, placeholder_idx=placeholder_idx, + placeholder_binding=placeholder_binding, + placeholder_carrier_tag=placeholder_carrier_tag, is_background=is_background, )) visual_order += 1 @@ -985,17 +1430,20 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec: ] if len(backgrounds) > 1: raise TemplateStructureError( - f"{svg_path.name}: template mode allows at most one {scope} " + f"{svg_path.name}: explicit Layout mode allows at most one {scope} " "solid background" ) - return TemplateSlideSpec( + spec = TemplateSlideSpec( slide_num=slide_num, svg_path=svg_path, + master_key=master_key, + master_name=master_name, layout_key=layout_key, layout_name=layout_name, elements=tuple(elements), ) + return spec def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]: @@ -1005,21 +1453,33 @@ def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]: for slide_num, svg_path in enumerate(svg_files, start=1) ] if not specs: - raise TemplateStructureError("Template export requires at least one SVG slide") - - expected_master = tuple( - item.contract_signature() for item in specs[0].master_elements - ) - for spec in specs[1:]: - actual_master = tuple( - item.contract_signature() for item in spec.master_elements + raise TemplateStructureError( + "Explicit Layout export requires at least one SVG slide" ) - if actual_master != expected_master: - raise TemplateStructureError( - f"{spec.svg_path.name}: master layer contract differs from " - f"{specs[0].svg_path.name}; every template slide must repeat the same " - "explicit master elements in the same order" + + by_master: dict[str, list[TemplateSlideSpec]] = {} + for spec in specs: + by_master.setdefault(spec.master_key, []).append(spec) + for master_key, master_specs in by_master.items(): + prototype = master_specs[0] + expected_master = tuple( + item.contract_signature() for item in prototype.master_elements + ) + for spec in master_specs[1:]: + if spec.master_name != prototype.master_name: + raise TemplateStructureError( + f"{spec.svg_path.name}: Master {master_key!r} uses name " + f"{spec.master_name!r}, expected {prototype.master_name!r}" + ) + actual_master = tuple( + item.contract_signature() for item in spec.master_elements ) + if actual_master != expected_master: + raise TemplateStructureError( + f"{spec.svg_path.name}: Master {master_key!r} contract differs " + f"from {prototype.svg_path.name}; slides sharing one Master must " + "repeat the same root-level atoms in the same order" + ) by_layout: dict[str, list[TemplateSlideSpec]] = {} for spec in specs: @@ -1033,6 +1493,12 @@ def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]: f"{spec.svg_path.name}: layout {layout_key!r} uses name " f"{spec.layout_name!r}, expected {prototype.layout_name!r}" ) + if spec.master_key != prototype.master_key: + raise TemplateStructureError( + f"{spec.svg_path.name}: globally unique layout {layout_key!r} " + f"belongs to Master {spec.master_key!r}, expected " + f"{prototype.master_key!r}" + ) if spec.layout_contract != prototype.layout_contract: raise TemplateStructureError( f"{spec.svg_path.name}: layout {layout_key!r} structure differs " @@ -1042,10 +1508,81 @@ def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]: return specs +def parse_optional_layout_slides( + svg_files: list[Path], +) -> list[TemplateSlideSpec] | None: + """Parse an all-or-none structured Layout contract, or return no metadata.""" + roots: list[tuple[Path, ET.Element]] = [] + has_structure_metadata = False + for svg_path in svg_files: + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError) as exc: + raise TemplateStructureError( + f"{svg_path.name}: unable to inspect SVG Layout metadata: {exc}" + ) from exc + roots.append((svg_path, root)) + has_structure_metadata = has_structure_metadata or any( + elem.get(attr) is not None + for elem in root.iter() + for attr in _STRUCTURE_ATTRS + ) + + if not has_structure_metadata: + return None + + missing_master_keys = [ + svg_path.name + for svg_path, root in roots + if not (root.get("data-pptx-master") or "").strip() + ] + missing_master_names = [ + svg_path.name + for svg_path, root in roots + if not (root.get("data-pptx-master-name") or "").strip() + ] + missing_keys = [ + svg_path.name + for svg_path, root in roots + if not (root.get("data-pptx-layout") or "").strip() + ] + missing_names = [ + svg_path.name + for svg_path, root in roots + if not (root.get("data-pptx-layout-name") or "").strip() + ] + if missing_master_keys or missing_master_names or missing_keys or missing_names: + missing_fields: list[str] = [] + if missing_master_keys: + missing_fields.append( + "data-pptx-master: " + ", ".join(missing_master_keys) + ) + if missing_master_names: + missing_fields.append( + "data-pptx-master-name: " + ", ".join(missing_master_names) + ) + if missing_keys: + missing_fields.append( + "data-pptx-layout: " + ", ".join(missing_keys) + ) + if missing_names: + missing_fields.append( + "data-pptx-layout-name: " + ", ".join(missing_names) + ) + raise TemplateStructureError( + "Explicit Layout metadata is all-or-none: once any SVG uses PPTX " + "structure metadata, every generated page root must declare " + "Master/Layout keys and names with non-empty values; " + "missing " + + "; ".join(missing_fields) + ) + return parse_template_slides(svg_files) + + def parse_preserve_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]: """Parse preserve-mode slides before source master grouping is known.""" specs = [ - parse_template_slide(svg_path, slide_num) + parse_template_slide(svg_path, slide_num, structured=False) for slide_num, svg_path in enumerate(svg_files, start=1) ] if not specs: @@ -1058,7 +1595,7 @@ def template_lock_errors( structure_lock: PptxStructureLock, ) -> list[str]: """Return mismatches between parsed SVG layouts and the project lock.""" - if structure_lock.mode not in {"template", "preserve"}: + if structure_lock.mode not in {"structured", "preserve"}: return [] errors: list[str] = [] references = { @@ -1095,6 +1632,757 @@ def template_lock_errors( f"does not match spec_lock P{spec.slide_num:02d} layout name " f"{reference.layout_name!r}" ) + if reference.master_key and spec.master_key != reference.master_key: + errors.append( + f"{spec.svg_path.name}: data-pptx-master={spec.master_key!r} " + f"does not match spec_lock P{spec.slide_num:02d} Master key " + f"{reference.master_key!r}" + ) + if structure_lock.mode == "structured": + master_names = { + master.master_key: master.master_name for master in structure_lock.masters + } + for spec in specs: + expected_name = master_names.get(spec.master_key) + if expected_name is not None and spec.master_name != expected_name: + errors.append( + f"{spec.svg_path.name}: data-pptx-master-name=" + f"{spec.master_name!r} does not match spec_lock Master " + f"{spec.master_key!r} name {expected_name!r}" + ) + return errors + + +def _signature_attr_value( + name: str, + value: str, + *, + svg_path: Path | None, + asset_identity: bool, +) -> str: + """Normalize portable asset references without weakening visual identity.""" + if name.rsplit("}", 1)[-1] != "href": + return value + if asset_identity: + if value.startswith("#") or "://" in value: + return value + if value.startswith("data:"): + return "data-sha256:" + hashlib.sha256( + value.encode("utf-8") + ).hexdigest() + if svg_path is None: + raise TemplateStructureError( + "literal asset comparison requires the source SVG path" + ) + asset_path = (svg_path.parent / value).resolve() + if not asset_path.is_file(): + raise TemplateStructureError( + f"{svg_path.name}: mirror asset reference does not resolve: " + f"{value!r}" + ) + return "file-sha256:" + _file_sha256(asset_path) + if value.startswith("data:") or "://" in value: + return value + return value.replace("\\", "/").rsplit("/", 1)[-1] + + +def _element_tree_signature( + elem: ET.Element, + *, + include_skin: bool = False, + include_text: bool = True, + svg_path: Path | None = None, + asset_identity: bool = False, + ignore_structure_attrs: bool = False, +) -> tuple[object, ...]: + """Return a stable structural or literal-visual SVG subtree signature.""" + text = (elem.text or "") if include_text else "" + if _local_tag(elem) not in {"text", "tspan"} and not text.strip(): + text = "" + attrs = tuple(sorted( + ( + name, + _signature_attr_value( + name, + value, + svg_path=svg_path, + asset_identity=asset_identity, + ), + ) + for name, value in elem.attrib.items() + if ( + not ( + ignore_structure_attrs + and name.rsplit("}", 1)[-1] in _STRUCTURE_ATTRS + ) + and ( + include_skin + or name.rsplit("}", 1)[-1] not in _TEMPLATE_SKIN_ATTRS + ) + ) + )) + return ( + elem.tag, + attrs, + text, + tuple( + _element_tree_signature( + child, + include_skin=include_skin, + include_text=include_text, + svg_path=svg_path, + asset_identity=asset_identity, + ignore_structure_attrs=ignore_structure_attrs, + ) + for child in elem + ), + ) + + +def _svg_reference_ids(elem: ET.Element) -> set[str]: + """Return fragment ids referenced anywhere in one SVG subtree.""" + references: set[str] = set() + for node in elem.iter(): + for name, value in node.attrib.items(): + if name.rsplit("}", 1)[-1] == "href" and value.startswith("#"): + references.add(value[1:]) + references.update( + match.group(2)[1:] + for match in _CSS_URL_RE.finditer(value) + if match.group(2).startswith("#") + ) + return references + + +def _font_family_names(raw_value: str) -> set[str]: + """Return normalized CSS font-family names from one declaration value.""" + return { + value.strip().strip("'\"") + for value in raw_value.split(",") + if value.strip().strip("'\"") + } + + +def _font_families_from_declarations(raw: str) -> set[str]: + """Return font families assigned by one inline or stylesheet declaration.""" + families: set[str] = set() + for match in re.finditer( + r"font-family\s*:\s*([^;{}]+)", + raw, + flags=re.IGNORECASE, + ): + families.update(_font_family_names(match.group(1))) + return families + + +def _scope_selector_tokens( + root: ET.Element, + elements: tuple[ET.Element, ...], +) -> tuple[set[str], set[str], set[str], list[dict[str, str]], set[str]]: + """Collect the small selector vocabulary needed to filter SVG CSS rules.""" + ids: set[str] = set() + classes: set[str] = set() + tags: set[str] = set() + attributes: list[dict[str, str]] = [] + font_families: set[str] = set() + nodes = [root] + for element in elements: + nodes.extend(element.iter()) + for node in nodes: + tags.add(_local_tag(node)) + node_id = (node.get("id") or "").strip() + if node_id: + ids.add(node_id) + classes.update((node.get("class") or "").split()) + local_attrs = { + name.rsplit("}", 1)[-1]: value + for name, value in node.attrib.items() + } + attributes.append(local_attrs) + if local_attrs.get("font-family"): + font_families.update( + _font_family_names(local_attrs["font-family"]) + ) + if local_attrs.get("style"): + font_families.update( + _font_families_from_declarations(local_attrs["style"]) + ) + return ids, classes, tags, attributes, font_families + + +def _css_selector_matches_scope( + selector: str, + *, + ids: set[str], + classes: set[str], + tags: set[str], + attributes: list[dict[str, str]], +) -> bool: + """Conservatively decide whether one simple SVG selector can affect scope.""" + selector_ids = set(_CSS_ID_RE.findall(selector)) + if selector_ids and not selector_ids.issubset(ids): + return False + selector_classes = set(_CSS_CLASS_RE.findall(selector)) + if selector_classes and not selector_classes.issubset(classes): + return False + for match in _CSS_ATTR_RE.finditer(selector): + attr_name = match.group(1).rsplit(":", 1)[-1] + expected = (match.group(3) or "").strip() + if not any( + attr_name in attrs + and (not expected or attrs[attr_name].strip() == expected) + for attrs in attributes + ): + return False + selector_tags = { + tag.lower() + for tag in _CSS_TAG_RE.findall(selector) + if tag != "*" + } + if selector_tags and not selector_tags.issubset( + {tag.lower() for tag in tags} + ): + return False + return True + + +def _css_asset_signature(value: str, svg_path: Path) -> str: + """Replace CSS URL assets with byte identities while retaining fragments.""" + def replace(match: re.Match[str]) -> str: + target = match.group(2).strip() + if target.startswith("#"): + return f"url({target})" + identity = _signature_attr_value( + "href", + target, + svg_path=svg_path, + asset_identity=True, + ) + return f"url({identity})" + + return _CSS_URL_RE.sub(replace, value) + + +def _normalize_css_declarations(raw: str, svg_path: Path) -> str: + """Normalize formatting-only CSS differences without changing cascade order.""" + declarations: list[str] = [] + for raw_declaration in raw.split(";"): + declaration = raw_declaration.strip() + if not declaration: + continue + if ":" not in declaration: + declarations.append(" ".join(declaration.split())) + continue + name, value = declaration.split(":", 1) + normalized_value = " ".join( + _css_asset_signature(value.strip(), svg_path).split() + ) + declarations.append(f"{name.strip().lower()}:{normalized_value}") + return ";".join(declarations) + + +def _scope_css_signature( + root: ET.Element, + elements: tuple[ET.Element, ...], + svg_path: Path, +) -> tuple[tuple[str, str], ...]: + """Return only stylesheet rules that can affect the selected visual scope.""" + ids, classes, tags, attributes, font_families = _scope_selector_tokens( + root, + elements, + ) + parsed_rules: list[tuple[str, str]] = [] + for style in root.iter(): + if _local_tag(style) != "style": + continue + css = _CSS_COMMENT_RE.sub("", style.text or "") + for match in _CSS_RULE_RE.finditer(css): + parsed_rules.append((match.group(1).strip(), match.group(2))) + + matched_selectors: dict[int, tuple[str, ...]] = {} + for index, (raw_selector, body) in enumerate(parsed_rules): + if raw_selector.startswith("@"): + continue + selectors = tuple( + " ".join(selector.split()) + for selector in raw_selector.split(",") + if _css_selector_matches_scope( + selector, + ids=ids, + classes=classes, + tags=tags, + attributes=attributes, + ) + ) + if selectors: + matched_selectors[index] = selectors + font_families.update(_font_families_from_declarations(body)) + + rules: list[tuple[str, str]] = [] + for index, (raw_selector, body) in enumerate(parsed_rules): + selectors = matched_selectors.get(index) + if selectors is None: + if not raw_selector.lower().startswith("@font-face"): + continue + declared_families = _font_families_from_declarations(body) + if not declared_families.intersection(font_families): + continue + selectors = ("@font-face",) + rules.append(( + ",".join(selectors), + _normalize_css_declarations(body, svg_path), + )) + return tuple(rules) + + +def _scope_visual_resources_signature( + root: ET.Element, + elements: tuple[ET.Element, ...], + svg_path: Path, +) -> tuple[object, ...]: + """Capture root inheritance, relevant CSS, and the referenced defs closure.""" + if not elements: + return () + root_attrs = tuple(sorted( + ( + name, + _signature_attr_value( + name, + value, + svg_path=svg_path, + asset_identity=True, + ), + ) + for name, value in root.attrib.items() + if ( + name.rsplit("}", 1)[-1] not in _STRUCTURE_ATTRS + and not name.rsplit("}", 1)[-1].startswith("data-") + ) + )) + css_rules = _scope_css_signature(root, elements, svg_path) + references: set[str] = set() + for element in elements: + references.update(_svg_reference_ids(element)) + for _selector, declarations in css_rules: + references.update( + match.group(2)[1:] + for match in _CSS_URL_RE.finditer(declarations) + if match.group(2).startswith("#") + ) + + definitions_by_id: dict[str, ET.Element] = {} + for definitions in root.iter(): + if _local_tag(definitions) != "defs": + continue + for definition in definitions.iter(): + definition_id = (definition.get("id") or "").strip() + if definition_id: + definitions_by_id[definition_id] = definition + + pending = list(references) + resolved: set[str] = set() + while pending: + reference = pending.pop() + if reference in resolved: + continue + resolved.add(reference) + definition = definitions_by_id.get(reference) + if definition is not None: + pending.extend(_svg_reference_ids(definition) - resolved) + definition_signatures = tuple( + ( + reference, + _element_tree_signature( + definitions_by_id[reference], + include_skin=True, + include_text=True, + svg_path=svg_path, + asset_identity=True, + ) if reference in definitions_by_id else ("missing", reference), + ) + for reference in sorted(resolved) + ) + return root_attrs, css_rules, definition_signatures + + +def _structure_subtree_signature( + svg_path: Path, + elements: tuple[TemplateElementSpec, ...], + *, + include_skin: bool = False, + include_text: bool = True, + asset_identity: bool = False, +) -> tuple[tuple[str, tuple[object, ...]], ...]: + """Read structural or literal-visual signatures for direct SVG children.""" + try: + root = ET.parse(svg_path).getroot() + materialize_inline_geometry_properties(root) + except (OSError, ET.ParseError, GeometryStyleError) as exc: + raise TemplateStructureError( + f"{svg_path.name}: unable to compare template prototype structure: {exc}" + ) from exc + direct_by_id = { + (child.get("id") or "").strip(): child + for child in root + if (child.get("id") or "").strip() + } + signatures: list[tuple[str, tuple[object, ...]]] = [] + for item in elements: + child = direct_by_id.get(item.element_id) + if child is None: + raise TemplateStructureError( + f"{svg_path.name}: structure element {item.element_id!r} is no " + "longer a direct SVG child" + ) + signatures.append(( + item.element_id, + _element_tree_signature( + child, + include_skin=include_skin, + include_text=include_text, + svg_path=svg_path, + asset_identity=asset_identity, + ), + )) + if include_skin: + selected = tuple( + direct_by_id[item.element_id] + for item in elements + if item.element_id in direct_by_id + ) + signatures.append(( + "__visual_resources__", + _scope_visual_resources_signature(root, selected, svg_path), + )) + return tuple(signatures) + + +def _mirror_ordinary_slide_ids(spec: TemplateSlideSpec) -> set[str]: + """Return stable ids that are ordinary Slide content in one mirror page.""" + try: + root = ET.parse(spec.svg_path).getroot() + except (OSError, ET.ParseError) as exc: + raise TemplateStructureError( + f"{spec.svg_path.name}: unable to inspect mirror page ownership: {exc}" + ) from exc + inherited_ids = { + item.element_id + for item in ( + *spec.master_elements, + *spec.layout_elements, + *spec.placeholders, + ) + } + return { + element_id + for child in root + if _local_tag(child) not in _NON_VISUAL_TAGS + if (element_id := (child.get("id") or "").strip()) + if element_id not in inherited_ids + } + + +def _mirror_slide_local_signature( + spec: TemplateSlideSpec, + protected_ids: set[str], +) -> tuple[tuple[object, ...], tuple[object, ...]]: + """Capture literal mirror visuals that are Slide-local on either page. + + Visible text values may change, but their element topology, attributes, + grouping, paint, geometry, and referenced asset bytes remain literal. + Structure metadata may change when adaptive template authoring assigns an + evolved Layout identity to the same stable SVG id. + """ + try: + root = ET.parse(spec.svg_path).getroot() + materialize_inline_geometry_properties(root) + except (OSError, ET.ParseError, GeometryStyleError) as exc: + raise TemplateStructureError( + f"{spec.svg_path.name}: unable to compare mirror page visuals: {exc}" + ) from exc + slide_elements: list[ET.Element] = [] + slide_visuals: list[tuple[object, ...]] = [] + for child in root: + tag = _local_tag(child) + if tag in _NON_VISUAL_TAGS: + continue + element_id = (child.get("id") or "").strip() + if element_id and element_id not in protected_ids: + continue + slide_elements.append(child) + slide_visuals.append( + _element_tree_signature( + child, + include_skin=True, + include_text=False, + svg_path=spec.svg_path, + asset_identity=True, + ignore_structure_attrs=True, + ) + ) + resources = _scope_visual_resources_signature( + root, + tuple(slide_elements), + spec.svg_path, + ) + return resources, tuple(slide_visuals) + + +def _prototype_placeholder_contract( + spec: TemplateSlideSpec, +) -> tuple[tuple[object, ...], ...]: + """Return the strict template placeholder contract without slide content.""" + return tuple(item.contract_signature() for item in spec.placeholders) + + +def template_prototype_errors( + specs: list[TemplateSlideSpec], + structure_lock: PptxStructureLock, + *, + require_complete_roster: bool = True, +) -> list[str]: + """Compare structured template pages with their selected SVG prototypes.""" + if structure_lock.mode != "structured" or not structure_lock.prototypes: + return [] + errors: list[str] = [] + prototypes = { + reference.slide_num: reference + for reference in structure_lock.prototypes + } + adherence = structure_lock.template_adherence or "strict" + actual_slides = {spec.slide_num for spec in specs} + prototype_slides = set(prototypes) + missing_prototypes = sorted(actual_slides - prototype_slides) + extra_prototypes = sorted(prototype_slides - actual_slides) + if missing_prototypes: + errors.append( + "spec_lock.md page_layouts is missing generated page(s): " + + ", ".join(f"P{slide_num:02d}" for slide_num in missing_prototypes) + ) + if require_complete_roster and extra_prototypes: + errors.append( + "spec_lock.md page_layouts references absent page(s): " + + ", ".join(f"P{slide_num:02d}" for slide_num in extra_prototypes) + ) + for spec in specs: + reference = prototypes.get(spec.slide_num) + if reference is None: + errors.append( + f"{spec.svg_path.name}: spec_lock.md page_layouts is missing " + f"prototype P{spec.slide_num:02d}" + ) + continue + try: + prototype = parse_template_slide(reference.svg_path, spec.slide_num) + except TemplateStructureError as exc: + errors.append(str(exc)) + continue + + try: + literal_visual = reference.replication_mode == "mirror" + expected_master_structure = _structure_subtree_signature( + prototype.svg_path, + prototype.master_elements, + include_skin=literal_visual, + asset_identity=literal_visual, + ) + actual_master_structure = _structure_subtree_signature( + spec.svg_path, + spec.master_elements, + include_skin=literal_visual, + asset_identity=literal_visual, + ) + except TemplateStructureError as exc: + errors.append(str(exc)) + continue + if ( + spec.master_key != prototype.master_key + or spec.master_name != prototype.master_name + or tuple(item.contract_signature() for item in spec.master_elements) + != tuple( + item.contract_signature() for item in prototype.master_elements + ) + or actual_master_structure != expected_master_structure + ): + errors.append( + f"{spec.svg_path.name}: template Master structure differs " + f"from prototype {reference.svg_path.name}; strict and adaptive " + "routes must retain its ids, topology, geometry, and content" + + (" including mirror visual styling" if literal_visual else "") + ) + + if literal_visual: + try: + protected_slide_ids = ( + _mirror_ordinary_slide_ids(prototype) + | _mirror_ordinary_slide_ids(spec) + ) + expected_slide_visual = _mirror_slide_local_signature( + prototype, + protected_slide_ids, + ) + actual_slide_visual = _mirror_slide_local_signature( + spec, + protected_slide_ids, + ) + except TemplateStructureError as exc: + errors.append(str(exc)) + continue + if actual_slide_visual != expected_slide_visual: + errors.append( + f"{spec.svg_path.name}: mirror Slide-local non-text visuals " + f"differ from prototype {reference.svg_path.name}; preserve " + "grouping, geometry, paint, effects, and referenced asset " + "identity, changing only visible text content" + ) + + try: + expected_layout_structure = _structure_subtree_signature( + prototype.svg_path, + prototype.layout_elements, + include_skin=literal_visual, + asset_identity=literal_visual, + ) + actual_layout_structure = _structure_subtree_signature( + spec.svg_path, + spec.layout_elements, + include_skin=literal_visual, + asset_identity=literal_visual, + ) + if literal_visual: + expected_placeholder_visual = _structure_subtree_signature( + prototype.svg_path, + prototype.placeholders, + include_skin=True, + include_text=False, + asset_identity=True, + ) + actual_placeholder_visual = _structure_subtree_signature( + spec.svg_path, + spec.placeholders, + include_skin=True, + include_text=False, + asset_identity=True, + ) + else: + expected_placeholder_visual = () + actual_placeholder_visual = () + except TemplateStructureError as exc: + errors.append(str(exc)) + continue + + placeholder_contract_same = ( + _prototype_placeholder_contract(spec) + == _prototype_placeholder_contract(prototype) + ) + layout_contract_same = ( + tuple(item.contract_signature() for item in spec.layout_elements) + == tuple( + item.contract_signature() for item in prototype.layout_elements + ) + and actual_layout_structure == expected_layout_structure + ) + placeholder_visual_same = ( + actual_placeholder_visual == expected_placeholder_visual + ) + reusable_contract_same = ( + placeholder_contract_same + and layout_contract_same + and placeholder_visual_same + ) + + reuses_prototype_key = spec.layout_key == prototype.layout_key + if adherence == "adaptive" and not reuses_prototype_key: + if spec.layout_name == prototype.layout_name: + errors.append( + f"{spec.svg_path.name}: adaptive template authoring created " + f"new layout key {spec.layout_key!r} but reused prototype picker " + f"name {prototype.layout_name!r}; assign a new key and name to " + "the evolved Layout contract" + ) + if reusable_contract_same: + errors.append( + f"{spec.svg_path.name}: adaptive template authoring changed " + "only the Layout key/name while the reusable static, " + "placeholder, and default-bounds contract is unchanged; " + f"reuse prototype identity {prototype.layout_key!r} / " + f"{prototype.layout_name!r}" + ) + continue + + missing_bounds = [ + item.element_id + for item in prototype.placeholders + if item.placeholder_bounds is None + ] + if missing_bounds: + if adherence == "strict": + errors.append( + f"{reference.svg_path.name}: deferred strict template " + "authoring requires explicit data-pptx-placeholder-bounds " + "on every prototype placeholder; missing: " + + ", ".join(missing_bounds) + ) + else: + errors.append( + f"{spec.svg_path.name}: adaptive output reused prototype layout " + f"key {prototype.layout_key!r}, but that prototype lacks explicit " + "placeholder bounds; assign a new key and name to the evolved " + "Layout contract" + ) + continue + if spec.layout_key != prototype.layout_key: + errors.append( + f"{spec.svg_path.name}: strict template use must keep " + f"prototype layout key {prototype.layout_key!r}, found " + f"{spec.layout_key!r}" + ) + if spec.layout_name != prototype.layout_name: + if adherence == "strict": + errors.append( + f"{spec.svg_path.name}: strict template use must keep " + f"prototype layout name {prototype.layout_name!r}, found " + f"{spec.layout_name!r}" + ) + else: + errors.append( + f"{spec.svg_path.name}: adaptive output reused prototype layout " + f"key {prototype.layout_key!r} but changed its picker name from " + f"{prototype.layout_name!r} to {spec.layout_name!r}; assign a " + "new key and name to the evolved Layout contract" + ) + if not placeholder_contract_same: + if adherence == "strict": + errors.append( + f"{spec.svg_path.name}: strict placeholder id/type/index/default-" + f"bounds contract differs from prototype " + f"{reference.svg_path.name}" + ) + else: + errors.append( + f"{spec.svg_path.name}: adaptive output reused prototype layout " + f"key {prototype.layout_key!r} but changed its placeholder " + "contract; assign a new key and name" + ) + if literal_visual and not placeholder_visual_same: + errors.append( + f"{spec.svg_path.name}: mirror placeholder geometry or visual " + f"styling differs from prototype {reference.svg_path.name}; " + "only visible text content may change under the reused Layout" + ) + if not layout_contract_same: + qualifier = "mirror visual/structural" if literal_visual else "structural" + if adherence == "strict": + errors.append( + f"{spec.svg_path.name}: strict Layout {qualifier} contract " + f"differs from prototype {reference.svg_path.name}" + ) + else: + errors.append( + f"{spec.svg_path.name}: adaptive output reused prototype layout " + f"key {prototype.layout_key!r} but changed its {qualifier} " + "contract; assign a new key and name" + ) return errors @@ -1217,10 +2505,82 @@ def native_structure_lock_errors( return errors +def _placement_lint_errors(svg_path: Path) -> list[str]: + """Enumerate every placement/paint-order violation in one pass. + + ``parse_template_slide`` fails fast on the first error, which discloses + violations one whole fix-cycle at a time. The quality checker runs this + pre-lint first so a single run reports every offender of the two + highest-frequency classes: structure metadata below the root, and + template paint-order violations. + """ + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError): + return [] + if _local_tag(root) != "svg": + return [] + errors: list[str] = [] + direct_children = set(root) + allowed_carriers = { + carrier + for slot in root + if (slot.get("data-pptx-placeholder") or "").strip() + for carrier in slot + if carrier.get("data-pptx-placeholder-carrier") is not None + } + for elem in root.iter(): + if elem is root or elem in direct_children: + continue + attrs = _structure_attrs(elem) + if elem in allowed_carriers: + attrs = [ + attr for attr in attrs + if attr != "data-pptx-placeholder-carrier" + ] + if attrs: + element_id = elem.get("id") or _local_tag(elem) or "" + errors.append( + f"{svg_path.name}: {element_id} uses template metadata below the SVG " + "root; only a direct slot child may declare its carrier marker" + ) + canvas = _svg_canvas(root) + last_order_rank = -1 + for elem in root: + tag = _local_tag(elem) + if tag in _NON_VISUAL_TAGS: + continue + layer = (elem.get("data-pptx-layer") or "").strip().lower() or None + if layer not in _LAYERS: + layer = None + is_background = _is_full_canvas_solid_rect(elem, canvas) + effective_layer = layer or ("slide" if is_background else None) + if is_background and effective_layer is not None: + order_rank = {"master": 0, "layout": 1, "slide": 2}[effective_layer] + elif effective_layer == "master": + order_rank = 3 + elif effective_layer == "layout": + order_rank = 4 + else: + order_rank = 5 + if order_rank < last_order_rank: + errors.append( + f"{svg_path.name}: {elem.get('id') or tag} violates template paint " + "order; use Master background, Layout background, Slide background, " + "Master shapes, Layout shapes, then Slide content/placeholders" + ) + continue + last_order_rank = order_rank + return errors + + def validate_template_svg(svg_path: Path) -> list[str]: """Return per-file template metadata errors for quality-check integration.""" + errors = _placement_lint_errors(svg_path) try: parse_template_slide(svg_path, 1) except TemplateStructureError as exc: - return [str(exc)] - return [] + message = str(exc) + if message not in errors: + errors.append(message) + return errors diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_validation.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_validation.py index 591415de..f7499ac3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_validation.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/template_validation.py @@ -2,7 +2,7 @@ """ PPT Master - Template Package Validation -Read a generated template-mode PPTX back and verify its reusable Layout graph. +Read a generated structured PPTX back and verify its reusable Master/Layout graph. Usage: Imported by svg_to_pptx.pptx_package.builder. @@ -19,6 +19,7 @@ from __future__ import annotations import posixpath import sys import zipfile +from collections import Counter from dataclasses import dataclass from pathlib import Path from urllib.parse import unquote, urlsplit @@ -29,7 +30,7 @@ if __name__ == "__main__": print(__doc__) raise SystemExit(0) print( - "Use this validator through the template-mode SVG-to-PPTX exporter.", + "Use this validator through the structured SVG-to-PPTX exporter.", file=sys.stderr, ) raise SystemExit(1) @@ -40,6 +41,7 @@ from .template_structure import ( TemplatePlaceholderBinding, TemplateSlideSpec, TemplateStructureError, + is_proxy_placeholder, template_placeholder_bindings, ) @@ -50,13 +52,30 @@ REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" SLIDE_LAYOUT_REL_TYPE = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" ) +SLIDE_REL_TYPE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" +) SLIDE_MASTER_REL_TYPE = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" ) SLIDE_LAYOUT_CONTENT_TYPE = ( "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" ) +SLIDE_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" +) +SLIDE_MASTER_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" +) PRESENTATION_COLLECTION_ID_MIN = 1 << 31 +PRESENTATION_SLIDE_ID_MIN = 256 +_TOP_LEVEL_VISIBLE_TAGS = frozenset({ + f"{{{PML_NS}}}sp", + f"{{{PML_NS}}}pic", + f"{{{PML_NS}}}graphicFrame", + f"{{{PML_NS}}}grpSp", + f"{{{PML_NS}}}cxnSp", +}) @dataclass(frozen=True) @@ -137,6 +156,140 @@ class _PackageReader: return result +def _top_level_shape_names( + root: ET.Element, + context: str, + errors: list[str], +) -> tuple[str, ...]: + """Return deterministic names for every visible top-level OOXML shape.""" + c_sld = root.find(f"{{{PML_NS}}}cSld") + shape_tree = ( + c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None + ) + if shape_tree is None: + errors.append(f"{context} has no p:cSld/p:spTree") + return () + names: list[str] = [] + for child in shape_tree: + if child.tag not in _TOP_LEVEL_VISIBLE_TAGS: + continue + c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None) + name = c_nv_pr.get("name") if c_nv_pr is not None else None + if not name: + errors.append(f"{context} contains a top-level shape without a name") + continue + names.append(name) + return tuple(names) + + +def _validate_named_shape_roster( + root: ET.Element, + expected_names: set[str] | tuple[str, ...] | list[str], + context: str, + errors: list[str], + *, + exact: bool, + ordered: bool = False, +) -> None: + """Validate reusable structure and ordinary Slide carriers by stable name.""" + expected_sequence = tuple(expected_names) + expected_set = set(expected_sequence) + actual_names = _top_level_shape_names(root, context, errors) + counts = Counter(actual_names) + duplicates = sorted(name for name, count in counts.items() if count > 1) + if duplicates: + errors.append( + f"{context} repeats structured shape name(s): " + ", ".join(duplicates) + ) + actual_set = set(actual_names) + missing = sorted(expected_set - actual_set) + if missing: + errors.append( + f"{context} is missing structured shape(s): " + ", ".join(missing) + ) + if exact: + unexpected = sorted(actual_set - expected_set) + if unexpected: + errors.append( + f"{context} contains unexpected shape(s): " + + ", ".join(unexpected) + ) + if ordered and not missing: + actual_sequence = ( + actual_names + if exact + else tuple(name for name in actual_names if name in expected_set) + ) + if actual_sequence != expected_sequence: + errors.append( + f"{context} structured shape order is {actual_sequence}, " + f"expected {expected_sequence}" + ) + + +def _top_level_shape_by_name( + root: ET.Element, + name: str, +) -> ET.Element | None: + c_sld = root.find(f"{{{PML_NS}}}cSld") + shape_tree = ( + c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None + ) + if shape_tree is None: + return None + for child in shape_tree: + if child.tag not in _TOP_LEVEL_VISIBLE_TAGS: + continue + c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None) + if c_nv_pr is not None and c_nv_pr.get("name") == name: + return child + return None + + +def _has_explicit_background(root: ET.Element) -> bool: + c_sld = root.find(f"{{{PML_NS}}}cSld") + return c_sld is not None and c_sld.find(f"{{{PML_NS}}}bg") is not None + + +def _xml_payload_signature(elem: ET.Element) -> tuple[object, ...]: + """Return a namespace-stable exact XML payload signature.""" + return ( + elem.tag, + tuple(sorted(elem.attrib.items())), + (elem.text or "").strip(), + tuple(_xml_payload_signature(child) for child in elem), + ) + + +def _background_payload_signatures( + root: ET.Element, +) -> tuple[tuple[object, ...], ...]: + c_sld = root.find(f"{{{PML_NS}}}cSld") + backgrounds = ( + c_sld.findall(f"{{{PML_NS}}}bg") + if c_sld is not None + else [] + ) + return tuple(_xml_payload_signature(background) for background in backgrounds) + + +def _expected_background_signature( + background_xml: str, +) -> tuple[object, ...]: + """Parse a standalone p:bg fragment regardless of its serialized prefix.""" + try: + wrapper = ET.fromstring( + f'' + f"{background_xml}" + ) + except ET.ParseError as exc: + raise ValueError(f"invalid expected p:bg payload: {exc}") from exc + children = list(wrapper) + if len(children) != 1 or children[0].tag != f"{{{PML_NS}}}bg": + raise ValueError("expected background payload must contain exactly one p:bg") + return _xml_payload_signature(children[0]) + + def _relationships_part_for(source_part: str) -> str: directory = posixpath.dirname(source_part) filename = posixpath.basename(source_part) @@ -461,6 +614,8 @@ def _validate_registered_part_roster( entries: list[ET.Element], expected_targets: set[str], context: str, + *, + min_id: int = PRESENTATION_COLLECTION_ID_MIN, ) -> set[int]: relationships = [ relationship @@ -513,10 +668,76 @@ def _validate_registered_part_roster( entries, context, reader.errors, - min_value=PRESENTATION_COLLECTION_ID_MIN, + min_value=min_id, ) +def _numbered_part_family( + names: frozenset[str], + directory: str, + stem: str, +) -> set[str]: + """Return exact numbered XML parts such as slide12.xml in one directory.""" + prefix = f"{directory}/{stem}" + parts: set[str] = set() + for name in names: + if not name.startswith(prefix) or not name.endswith(".xml"): + continue + number = name[len(prefix):-4] + if ( + number.isdigit() + and int(number) > 0 + and posixpath.dirname(name) == directory + ): + parts.add(name) + return parts + + +def _validate_physical_part_roster( + reader: _PackageReader, + expected_parts: set[str], + directory: str, + stem: str, + context: str, +) -> None: + actual_parts = _numbered_part_family(reader.names, directory, stem) + if actual_parts != expected_parts: + reader.errors.append( + f"{context} physical part roster differs; " + f"missing={sorted(expected_parts - actual_parts)}, " + f"extra={sorted(actual_parts - expected_parts)}" + ) + + +def _validate_content_type_part_roster( + overrides: dict[str, list[str]], + expected_parts: set[str], + directory: str, + stem: str, + content_type: str, + errors: list[str], + context: str, +) -> None: + family = _numbered_part_family( + frozenset(name.lstrip("/") for name in overrides), + directory, + stem, + ) + if family != expected_parts: + errors.append( + f"[Content_Types].xml {context} Override roster differs; " + f"missing={sorted(expected_parts - family)}, " + f"extra={sorted(family - expected_parts)}" + ) + for part in sorted(expected_parts): + values = overrides.get(f"/{part}", []) + if values != [content_type]: + errors.append( + f"[Content_Types].xml must declare {part} exactly once as " + f"{content_type}" + ) + + def _validate_presentation_master_registration( reader: _PackageReader, master_parts: set[str], @@ -538,13 +759,43 @@ def _validate_presentation_master_registration( ) +def _validate_presentation_slide_registration( + reader: _PackageReader, + slide_parts: set[str], +) -> None: + presentation_part = "ppt/presentation.xml" + presentation_root = reader.xml(presentation_part) + if presentation_root is None: + return + slide_id_entries = presentation_root.findall( + f"{{{PML_NS}}}sldIdLst/{{{PML_NS}}}sldId" + ) + _validate_registered_part_roster( + reader, + presentation_part, + SLIDE_REL_TYPE, + slide_id_entries, + slide_parts, + "Presentation p:sldIdLst", + min_id=PRESENTATION_SLIDE_ID_MIN, + ) + + def validate_pptx_template_package( pptx_path: str | Path, specs: list[TemplateSlideSpec], + *, + expected_backgrounds: dict[str, str | None] | None = None, + expected_shape_rosters: dict[str, tuple[str, ...]] | None = None, ) -> None: - """Validate a finished template-mode PPTX against its explicit SVG contract.""" + """Validate a finished structured PPTX against its explicit SVG contract. + + Export supplies pre-packaging background and shape expectations for exact + serialization read-back. Callers that only have the finished package still + receive the portable structure, ownership, and relationship checks. + """ if not specs: - raise ValueError("template package validation requires at least one slide spec") + raise ValueError("structured package validation requires at least one slide spec") specs_by_layout: dict[str, list[TemplateSlideSpec]] = {} bindings_by_layout: dict[str, tuple[TemplatePlaceholderBinding, ...]] = {} @@ -564,6 +815,40 @@ def validate_pptx_template_package( with zipfile.ZipFile(path) as package: reader = _PackageReader(package, errors) overrides = _content_type_overrides(reader) + if expected_backgrounds is not None: + for part, background_xml in sorted(expected_backgrounds.items()): + root = reader.xml(part) + if root is None: + continue + if background_xml is None: + expected_signatures: tuple[tuple[object, ...], ...] = () + else: + try: + expected_signatures = ( + _expected_background_signature(background_xml), + ) + except ValueError as exc: + errors.append(f"{part} {exc}") + continue + actual_signatures = _background_payload_signatures(root) + if actual_signatures != expected_signatures: + errors.append( + f"{part} p:bg count/payload differs from the exact " + "structured-export expectation" + ) + if expected_shape_rosters is not None: + for part, roster in sorted(expected_shape_rosters.items()): + root = reader.xml(part) + if root is None: + continue + _validate_named_shape_roster( + root, + roster, + part, + errors, + exact=True, + ordered=True, + ) layout_parts_by_key: dict[str, str] = {} keys_by_layout_part: dict[str, str] = {} slide_roots: dict[int, ET.Element] = {} @@ -612,7 +897,11 @@ def validate_pptx_template_package( used_master_parts: set[str] = set() layout_parts_by_master: dict[str, set[str]] = {} + master_specs_by_part: dict[str, TemplateSlideSpec] = {} + master_parts_by_key: dict[str, str] = {} + keys_by_master_part: dict[str, str] = {} for layout_key, layout_specs in specs_by_layout.items(): + prototype = layout_specs[0] layout_part = layout_parts_by_key.get(layout_key) if layout_part is None: errors.append(f"layout key {layout_key!r} has no resolved Layout part") @@ -661,20 +950,178 @@ def validate_pptx_template_package( f"Layout {layout_key!r}", errors, ) + expected_layout_names = tuple( + f"{item.element_id} Layout" + if item.layer == "layout" + else f"{item.element_id} Placeholder" + for item in prototype.elements + if ( + (item.layer == "layout" and not item.is_background) + or item.placeholder + ) + ) + _validate_named_shape_roster( + layout_root, + expected_layout_names, + f"Layout {layout_key!r}", + errors, + exact=True, + ordered=True, + ) + expected_layout_background = any( + item.is_background for item in prototype.layout_elements + ) + actual_layout_background = _has_explicit_background(layout_root) + if actual_layout_background != expected_layout_background: + errors.append( + f"Layout {layout_key!r} background ownership is " + f"{actual_layout_background}, expected " + f"{expected_layout_background} from the SVG contract" + ) + if ( + expected_layout_background + and expected_backgrounds is not None + and layout_part not in expected_backgrounds + ): + errors.append( + f"Layout {layout_key!r} has no pre-promotion p:bg payload " + "for exact read-back" + ) slide_placeholders: dict[int, dict[int, _Placeholder]] = {} for spec in layout_specs: slide_root = slide_roots.get(spec.slide_num) if slide_root is None: continue + expected_slide_bindings = bindings slide_placeholders[spec.slide_num] = _validate_placeholder_roster( slide_root, - bindings, + expected_slide_bindings, f"Slide {spec.slide_num}", errors, is_layout=False, ) + expected_slide_background = any( + item.layer == "slide" and item.is_background + for item in spec.elements + ) + actual_slide_background = _has_explicit_background(slide_root) + if actual_slide_background != expected_slide_background: + errors.append( + f"Slide {spec.slide_num} background ownership is " + f"{actual_slide_background}, expected " + f"{expected_slide_background} from the SVG contract" + ) + slide_part = f"ppt/slides/slide{spec.slide_num}.xml" + if ( + expected_slide_background + and expected_backgrounds is not None + and slide_part not in expected_backgrounds + ): + errors.append( + f"Slide {spec.slide_num} has no pre-promotion p:bg " + "payload for exact read-back" + ) + expected_carrier_names = [ + ( + f"{item.element_id} Proxy Content" + if is_proxy_placeholder(item) + else f"{item.element_id} Placeholder Carrier" + ) + for item in spec.placeholders + ] + expected_carrier_names.extend( + "Placeholder Binding " + f"{binding.placeholder_type} {binding.effective_idx}" + for binding in bindings + if is_proxy_placeholder(binding.element) + ) + _validate_named_shape_roster( + slide_root, + expected_carrier_names, + f"Slide {spec.slide_num}", + errors, + exact=False, + ordered=True, + ) + bindings_by_element = { + binding.element.element_id: binding + for binding in bindings + } + for item in spec.placeholders: + proxy_binding = is_proxy_placeholder(item) + carrier_name = ( + f"{item.element_id} Proxy Content" + if proxy_binding + else f"{item.element_id} Placeholder Carrier" + ) + carrier = _top_level_shape_by_name(slide_root, carrier_name) + carrier_placeholder = ( + carrier.find(f".//{{{PML_NS}}}ph") + if carrier is not None + else None + ) + if proxy_binding: + if carrier_placeholder is not None: + errors.append( + f"Slide {spec.slide_num} proxy content " + f"{carrier_name!r} must remain ordinary" + ) + elif carrier is not None and carrier_placeholder is None: + errors.append( + f"Slide {spec.slide_num} carrier " + f"{carrier_name!r} must own its p:ph binding" + ) + if not proxy_binding: + continue + placeholder_binding = bindings_by_element[item.element_id] + binding_name = ( + "Placeholder Binding " + f"{placeholder_binding.placeholder_type} " + f"{placeholder_binding.effective_idx}" + ) + binding_shape = _top_level_shape_by_name( + slide_root, + binding_name, + ) + if binding_shape is None: + continue + c_nv_pr = next( + binding_shape.iter(f"{{{PML_NS}}}cNvPr"), + None, + ) + if ( + c_nv_pr is None + or c_nv_pr.get("hidden", "0").lower() + not in {"1", "true"} + ): + errors.append( + f"Slide {spec.slide_num} binding proxy " + f"{binding_name!r} must be hidden" + ) + if binding_shape.find(f".//{{{PML_NS}}}ph") is None: + errors.append( + f"Slide {spec.slide_num} binding proxy " + f"{binding_name!r} has no p:ph" + ) + alpha_values = { + alpha.get("val") + for alpha in binding_shape.findall( + f".//{{{DML_NS}}}srgbClr/{{{DML_NS}}}alpha" + ) + } + proxy_text = "".join( + node.text or "" + for node in binding_shape.findall( + f".//{{{DML_NS}}}t" + ) + ) + if "0" not in alpha_values or proxy_text != "\u200b": + errors.append( + f"Slide {spec.slide_num} binding proxy " + f"{binding_name!r} must contain exactly one fully " + "transparent zero-width run" + ) - prototype = layout_specs[0] prototype_placeholders = slide_placeholders.get( prototype.slide_num, {}, @@ -683,7 +1130,9 @@ def validate_pptx_template_package( idx = binding.effective_idx layout_placeholder = layout_placeholders.get(idx) prototype_placeholder = prototype_placeholders.get(idx) - if layout_placeholder is None or prototype_placeholder is None: + if is_proxy_placeholder(binding.element): + prototype_placeholder = None + if layout_placeholder is None: continue context = ( f"Layout {layout_key!r} placeholder " @@ -699,13 +1148,19 @@ def validate_pptx_template_package( round(value * EMU_PER_PX) for value in binding.element.placeholder_bounds ) - else: + elif prototype_placeholder is not None: expected_bounds = _shape_bounds( prototype_placeholder.shape, f"Slide {prototype.slide_num} placeholder " f"{binding.element.element_id!r}", errors, ) + else: + expected_bounds = None + errors.append( + f"{context} has neither explicit bounds nor a bound " + "prototype Slide placeholder" + ) if ( actual_bounds is not None and expected_bounds is not None @@ -716,22 +1171,31 @@ def validate_pptx_template_package( f"{expected_bounds}" ) - prototype_size = _first_run_size(prototype_placeholder.shape) - if prototype_size is not None: - prompt_size = _first_run_size(layout_placeholder.shape) + prompt_size = _first_run_size(layout_placeholder.shape) + default_size = _level_one_default_size( + layout_placeholder.shape + ) + if prototype_placeholder is not None: + prototype_size = _first_run_size( + prototype_placeholder.shape + ) + if prototype_size is None: + continue if prompt_size != prototype_size: errors.append( f"{context} prompt size is {prompt_size!r}, expected " f"{prototype_size!r}" ) - default_size = _level_one_default_size( - layout_placeholder.shape - ) if default_size != prototype_size: errors.append( f"{context} level-1 default size is " f"{default_size!r}, expected {prototype_size!r}" ) + elif prompt_size is not None and default_size != prompt_size: + errors.append( + f"{context} level-1 default size is {default_size!r}, " + f"expected its prompt size {prompt_size!r}" + ) master_target = _single_relationship_target( reader, @@ -752,6 +1216,32 @@ def validate_pptx_template_package( continue reader.xml(master_part) used_master_parts.add(master_part) + previous_master_part = master_parts_by_key.setdefault( + prototype.master_key, + master_part, + ) + if previous_master_part != master_part: + errors.append( + f"Master key {prototype.master_key!r} targets both " + f"{previous_master_part} and {master_part}" + ) + previous_master_key = keys_by_master_part.setdefault( + master_part, + prototype.master_key, + ) + if previous_master_key != prototype.master_key: + errors.append( + f"Master keys {previous_master_key!r} and " + f"{prototype.master_key!r} both target {master_part}" + ) + previous_master_spec = master_specs_by_part.setdefault( + master_part, + prototype, + ) + if previous_master_spec.master_key != prototype.master_key: + errors.append( + f"{master_part} is shared by conflicting SVG Master contracts" + ) layout_parts_by_master.setdefault(master_part, set()).add( layout_part ) @@ -761,6 +1251,52 @@ def validate_pptx_template_package( master_root = reader.xml(master_part) if master_root is None: continue + master_spec = master_specs_by_part.get(master_part) + if master_spec is not None: + master_c_sld = master_root.find(f"{{{PML_NS}}}cSld") + actual_master_name = ( + master_c_sld.get("name") + if master_c_sld is not None + else None + ) + if actual_master_name != master_spec.master_name: + errors.append( + f"Master {master_part} has picker name " + f"{actual_master_name!r}, expected " + f"{master_spec.master_name!r}" + ) + _validate_named_shape_roster( + master_root, + tuple( + f"{item.element_id} Master" + for item in master_spec.master_elements + if not item.is_background + ), + f"Master {master_part}", + errors, + exact=True, + ordered=True, + ) + expected_master_background = any( + item.is_background + for item in master_spec.master_elements + ) + if ( + expected_master_background + and not _has_explicit_background(master_root) + ): + errors.append( + f"Master {master_part} is missing its explicit background" + ) + if ( + expected_master_background + and expected_backgrounds is not None + and master_part not in expected_backgrounds + ): + errors.append( + f"Master {master_part} has no pre-promotion p:bg payload " + "for exact read-back" + ) layout_id_entries = master_root.findall( f"{{{PML_NS}}}sldLayoutIdLst/" f"{{{PML_NS}}}sldLayoutId" @@ -784,10 +1320,64 @@ def validate_pptx_template_package( f"{previous_owner} and {master_part}" ) + expected_slide_parts = { + f"ppt/slides/slide{spec.slide_num}.xml" + for spec in specs + } + expected_layout_parts = set(layout_parts_by_key.values()) + _validate_physical_part_roster( + reader, + expected_slide_parts, + "ppt/slides", + "slide", + "Slide", + ) + _validate_physical_part_roster( + reader, + expected_layout_parts, + "ppt/slideLayouts", + "slideLayout", + "Layout", + ) + _validate_physical_part_roster( + reader, + used_master_parts, + "ppt/slideMasters", + "slideMaster", + "Master", + ) + _validate_content_type_part_roster( + overrides, + expected_slide_parts, + "ppt/slides", + "slide", + SLIDE_CONTENT_TYPE, + errors, + "Slide", + ) + _validate_content_type_part_roster( + overrides, + expected_layout_parts, + "ppt/slideLayouts", + "slideLayout", + SLIDE_LAYOUT_CONTENT_TYPE, + errors, + "Layout", + ) + _validate_content_type_part_roster( + overrides, + used_master_parts, + "ppt/slideMasters", + "slideMaster", + SLIDE_MASTER_CONTENT_TYPE, + errors, + "Master", + ) + _validate_presentation_slide_registration(reader, expected_slide_parts) _validate_presentation_master_registration(reader, used_master_parts) except (OSError, zipfile.BadZipFile) as exc: raise ValueError(f"cannot read template PPTX package {path}: {exc}") from exc if errors: details = "\n".join(f" - {error}" for error in errors) - raise ValueError(f"template package read-back failed:\n{details}") + raise ValueError(f"structured package read-back failed:\n{details}") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py index 14165185..9d8860d2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/analyzer.py @@ -9,18 +9,24 @@ from xml.etree import ElementTree as ET from .chart_read import empty_chart_data, read_chart_data from .diagram_read import read_smartart_diagrams +from .edit_safety import ( + _chart_edit_capability, + _chart_frames, + _chart_reference, + _table_cell_merge_info, + _table_merge_topology, + _unsupported_chart_capability, +) from .ooxml import ( CHART_REL_TYPE, NS, SlideRef, - _chart_containers, _container_geometry, _emu_to_px, _normalize_part, _paragraph_texts, _parse_slide_refs, _read_xml, - _qn, _shape_identity, _slide_relationships, _table_containers, @@ -36,16 +42,37 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str, tables: list[dict[str, Any]] = [] for order, container in enumerate(_table_containers(slide_root), start=1): shape_id, _shape_name = _shape_identity(container, order) + table = container.find(".//a:tbl", NS) + if table is None: + continue + merge_topology = _table_merge_topology(table) + merge_anchors = { + (int(item["row"]), int(item["col"])): item.get("anchor") + for item in merge_topology["slave_cells"] + } rows: list[dict[str, Any]] = [] max_columns = 0 - for row_index, row in enumerate(container.findall(".//a:tbl/a:tr", NS)): + for row_index, row in enumerate(table.findall("a:tr", NS)): cells: list[dict[str, Any]] = [] for col_index, cell in enumerate(row.findall("a:tc", NS)): + merge_info = _table_cell_merge_info(cell) + merge_anchor = merge_anchors.get((row_index, col_index)) + if merge_anchor is not None: + merge_info["merge_anchor"] = merge_anchor + merge_info["anchor_row"] = merge_anchor["row"] + merge_info["anchor_col"] = merge_anchor["col"] + elif merge_info["is_merge_slave"]: + merge_info["anchor_row"] = None + merge_info["anchor_col"] = None + else: + merge_info["anchor_row"] = row_index + merge_info["anchor_col"] = col_index cells.append( { "row": row_index, "col": col_index, "text": "\n".join(_paragraph_texts(cell)), + **merge_info, } ) max_columns = max(max_columns, len(cells)) @@ -56,6 +83,7 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str, "row_count": len(rows), "column_count": max_columns, "rows": rows, + "merge_topology": merge_topology, } ) return tables @@ -64,19 +92,47 @@ def _analyze_tables(slide_root: ET.Element, source_slide: int) -> list[dict[str, def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: SlideRef) -> list[dict[str, Any]]: charts: list[dict[str, Any]] = [] relationships = _slide_relationships(zf, slide_ref.rels_name) - for order, container in enumerate(_chart_containers(slide_root), start=1): + for order, container in enumerate(_chart_frames(slide_root), start=1): shape_id, _shape_name = _shape_identity(container, order) - chart = container.find(".//c:chart", NS) - rel_id = chart.attrib.get(_qn(NS["r"], "id")) if chart is not None else "" + chart_kind, rel_id = _chart_reference(container) payload: dict[str, Any] = {"chart_id": f"s{slide_ref.index:02d}_ch{shape_id}"} payload.update(empty_chart_data()) + payload["chart_kind"] = chart_kind + if chart_kind == "chartex": + payload["chart_type"] = "chartEx" + payload["plot_types"] = ["chartEx"] + payload["edit_capability"] = _unsupported_chart_capability( + "chart_edit_chartex_unsupported", + "template-fill chart edits do not support ChartEx", + ) + charts.append(payload) + continue + if chart_kind != "classic": + payload["edit_capability"] = _unsupported_chart_capability( + "chart_edit_plot_type_unsupported", + "template-fill chart edits require a classic DrawingML chart reference", + ) + charts.append(payload) + continue + rel = relationships.get(rel_id) if rel and rel.get("type") == CHART_REL_TYPE: chart_part = _normalize_part(rel["target"], slide_ref.part_name) try: - payload.update(read_chart_data(_read_xml(zf, chart_part))) + chart_root = _read_xml(zf, chart_part) + payload.update(read_chart_data(chart_root)) + payload["edit_capability"] = _chart_edit_capability(chart_root) except RuntimeError: payload.update(empty_chart_data()) + payload["edit_capability"] = _unsupported_chart_capability( + "chart_edit_part_unavailable", + "template-fill could not read the classic chart part", + ) + else: + payload["edit_capability"] = _unsupported_chart_capability( + "chart_edit_relationship_unsupported", + "template-fill chart edits require a classic chart relationship", + ) charts.append(payload) return charts diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/chart_fill.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/chart_fill.py index 9748391d..d3c33257 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/chart_fill.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/chart_fill.py @@ -10,10 +10,16 @@ from __future__ import annotations import io import re +import sys import zipfile from typing import Any from xml.etree import ElementTree as ET +from .edit_safety import ( + _chart_frames, + _chart_reference, + _require_supported_chart_edit, +) from .ooxml import ( CHART_CONTENT_TYPE, CHART_REL_TYPE, @@ -21,7 +27,6 @@ from .ooxml import ( PACKAGE_REL_TYPE, REL_NS, XLSX_CONTENT_TYPE, - _chart_containers, _normalize_part, _qn, _rels_name_for_part, @@ -34,11 +39,15 @@ from .selectors import _chart_selectors def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]: maps: dict[str, dict[str, str]] = {} - for order, container in enumerate(_chart_containers(slide_root), start=1): + for order, container in enumerate(_chart_frames(slide_root), start=1): shape_id, shape_name = _shape_identity(container, order) - chart = container.find(".//c:chart", NS) - rel_id = chart.attrib.get(_qn(NS["r"], "id")) if chart is not None else "" - info = {"shape_id": shape_id, "shape_name": shape_name, "rel_id": rel_id or ""} + chart_kind, rel_id = _chart_reference(container) + info = { + "shape_id": shape_id, + "shape_name": shape_name, + "rel_id": rel_id, + "chart_kind": chart_kind, + } maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info maps[f"shape_id:{shape_id}"] = info if shape_name: @@ -168,6 +177,13 @@ def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) - def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None: + capability = _require_supported_chart_edit(chart_root) + for warning in capability.get("warnings", []): + if not isinstance(warning, dict): + continue + code = warning.get("code") or "chart_edit_category_flattened" + message = warning.get("message") or "chart categories will be flattened" + print(f" Warning: {message} [{code}]", file=sys.stderr) categories = [str(item) for item in chart_edit.get("categories", [])] series_payload = chart_edit.get("series", []) if not categories or not isinstance(series_payload, list) or not series_payload: @@ -346,6 +362,17 @@ def _apply_chart_edits_to_slide_package( continue errors.append(", ".join(selectors) or "") continue + chart_kind = chart_info.get("chart_kind", "") + if chart_kind != "classic": + code = ( + "chart_edit_chartex_unsupported" + if chart_kind == "chartex" + else "chart_edit_plot_type_unsupported" + ) + raise RuntimeError( + "template-fill chart edits require a supported classic chart " + f"[{code}]" + ) rel_id = chart_info.get("rel_id", "") rel = _find_relationship(rels_root, rel_id) if rel is None: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py index 5488485f..8213f361 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/checker.py @@ -5,6 +5,7 @@ from __future__ import annotations import unicodedata from typing import Any +from .edit_safety import _is_verified_category_capability from .selectors import ( _chart_selectors, _replacement_selectors, @@ -55,6 +56,78 @@ def _chart_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, An return lookup +def _table_cell_lookup(table: dict[str, Any]) -> dict[tuple[int, int], dict[str, Any]]: + lookup: dict[tuple[int, int], dict[str, Any]] = {} + for row in table.get("rows", []): + for cell in row.get("cells", []): + if not isinstance(cell, dict): + continue + try: + key = (int(cell.get("row", -1)), int(cell.get("col", -1))) + except (TypeError, ValueError): + continue + lookup[key] = cell + return lookup + + +def _table_merge_slave_lookup( + table: dict[str, Any], +) -> dict[tuple[int, int], dict[str, int] | None]: + lookup: dict[tuple[int, int], dict[str, int] | None] = {} + topology = table.get("merge_topology") + if isinstance(topology, dict): + for slave in topology.get("slave_cells", []): + if not isinstance(slave, dict): + continue + try: + key = (int(slave.get("row", -1)), int(slave.get("col", -1))) + except (TypeError, ValueError): + continue + anchor = slave.get("anchor") + lookup[key] = anchor if isinstance(anchor, dict) else None + + for key, cell in _table_cell_lookup(table).items(): + if cell.get("is_merge_slave") is True or cell.get("merge_role") == "slave": + anchor = cell.get("merge_anchor") + lookup.setdefault(key, anchor if isinstance(anchor, dict) else None) + return lookup + + +def _chart_edit_capability_review( + chart: dict[str, Any], +) -> tuple[tuple[str, str] | None, list[tuple[str, str]]]: + capability = chart.get("edit_capability") + if not isinstance(capability, dict): + return None, [ + ( + "chart_edit_capability_unknown", + "chart edit capability is missing; runtime will inspect the actual " + "chart XML before mutation, and re-running template analysis is recommended", + ) + ] + if capability.get("supported") is not True: + return ( + str(capability.get("code") or "chart_edit_capability_unknown"), + str(capability.get("message") or "chart edit capability is unsupported"), + ), [] + if not _is_verified_category_capability(capability): + return ( + "chart_edit_capability_unknown", + "chart edit capability is not the verified single-plot c:cat/c:val model", + ), [] + + capability_warnings: list[tuple[str, str]] = [] + raw_warnings = capability.get("warnings") + if isinstance(raw_warnings, list): + for item in raw_warnings: + if not isinstance(item, dict): + continue + code = str(item.get("code") or "chart_edit_category_flattened") + message = str(item.get("message") or "chart categories will be flattened") + capability_warnings.append((code, message)) + return None, capability_warnings + + def _visual_width(text: str) -> float: """Estimate rendered text width in Latin-character units. @@ -339,10 +412,18 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: continue row_count = int(table.get("row_count") or 0) column_count = int(table.get("column_count") or 0) + table_cells = _table_cell_lookup(table) + merge_slaves = _table_merge_slave_lookup(table) for cell in cells: row = int(cell.get("row", -1)) col = int(cell.get("col", -1)) - if row < 0 or col < 0 or row >= row_count or col >= column_count: + if ( + row < 0 + or col < 0 + or row >= row_count + or col >= column_count + or (table_cells and (row, col) not in table_cells) + ): results.append( { "status": "ERROR", @@ -355,6 +436,32 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: ) summary["error"] += 1 continue + if (row, col) in merge_slaves: + anchor = merge_slaves[(row, col)] + anchor_hint = "" + if anchor is not None: + anchor_hint = ( + f"; edit merge anchor row={anchor.get('row')} " + f"col={anchor.get('col')} instead" + ) + results.append( + { + "status": "ERROR", + "code": "table_cell_is_merge_slave", + "plan_slide": slide_index, + "source_slide": source_slide, + "selector": selectors[0] if selectors else "", + "table_id": table.get("table_id"), + "row": row, + "col": col, + "message": ( + f"table cell row={row} col={col} is a merged-cell slave" + f"{anchor_hint}" + ), + } + ) + summary["error"] += 1 + continue summary["ok"] += 1 results.append( { @@ -397,23 +504,35 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: ) summary["error"] += 1 continue - if len(chart.get("plot_types") or []) > 1: + capability_error, capability_warnings = _chart_edit_capability_review(chart) + if capability_error is not None: + error_code, error_message = capability_error results.append( { "status": "ERROR", - "code": "chart_combo_unsupported", + "code": error_code, "plan_slide": slide_index, "source_slide": source_slide, "selector": selectors[0] if selectors else "", "chart_id": chart.get("chart_id"), - "message": ( - "template-fill chart edits do not support multi-plot / combination charts; " - "use beautify/main pipeline to redraw the chart, or leave the native chart untouched" - ), + "message": error_message, } ) summary["error"] += 1 continue + for warning_code, warning_message in capability_warnings: + results.append( + { + "status": "WARN", + "code": warning_code, + "plan_slide": slide_index, + "source_slide": source_slide, + "selector": selectors[0] if selectors else "", + "chart_id": chart.get("chart_id"), + "message": warning_message, + } + ) + summary["warn"] += 1 categories = chart_edit.get("categories", []) series = chart_edit.get("series", []) if not isinstance(categories, list) or not isinstance(series, list) or not series: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/edit_safety.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/edit_safety.py new file mode 100644 index 00000000..e3146910 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/edit_safety.py @@ -0,0 +1,294 @@ +"""Classify template-fill chart and table edits before package mutation.""" + +from __future__ import annotations + +from typing import Any +from xml.etree import ElementTree as ET + +from .ooxml import NS, _qn + +_XY_PLOTS = frozenset({"bubbleChart", "scatterChart"}) + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _tag_namespace(tag: str) -> str: + if not tag.startswith("{") or "}" not in tag: + return "" + return tag[1:].split("}", 1)[0] + + +def _chart_reference(frame: ET.Element) -> tuple[str, str]: + """Return ``(classic|chartex|unknown, relationship id)`` for a chart frame.""" + graphic_data = frame.find(".//a:graphicData", NS) + if graphic_data is None: + return "", "" + + for node in graphic_data.iter(): + if _local_name(node.tag) != "chart": + continue + namespace = _tag_namespace(node.tag) + rel_id = node.attrib.get(_qn(NS["r"], "id"), "") + if namespace == NS["c"]: + return "classic", rel_id + if "chartex" in namespace.lower(): + return "chartex", rel_id + return "unknown", rel_id + + uri = graphic_data.attrib.get("uri", "").lower() + if "chartex" in uri: + return "chartex", "" + if "chart" in uri: + return "unknown", "" + return "", "" + + +def _chart_frames(slide_root: ET.Element) -> list[ET.Element]: + """Return classic, ChartEx, and unknown chart graphic frames.""" + return [ + frame + for frame in slide_root.findall(".//p:graphicFrame", NS) + if _chart_reference(frame)[0] + ] + + +def _unsupported_chart_capability( + code: str, + message: str, + *, + plot_type: str | None = None, + plot_count: int = 0, + data_model: str = "unknown", +) -> dict[str, Any]: + return { + "supported": False, + "code": code, + "message": message, + "plot_type": plot_type, + "plot_count": plot_count, + "data_model": data_model, + } + + +def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]: + """Classify whether the category cache writer can safely edit a chart part.""" + root_namespace = _tag_namespace(chart_root.tag) + if "chartex" in root_namespace.lower(): + return _unsupported_chart_capability( + "chart_edit_chartex_unsupported", + "template-fill chart edits do not support ChartEx", + ) + if root_namespace != NS["c"]: + return _unsupported_chart_capability( + "chart_edit_plot_type_unsupported", + "template-fill chart edits require a classic DrawingML chart part", + ) + + plot_area = chart_root.find(".//c:plotArea", NS) + if plot_area is None: + return _unsupported_chart_capability( + "chart_edit_plot_type_unsupported", + "template-fill chart edits require a classic chart plotArea", + ) + + plot_nodes = [ + child + for child in list(plot_area) + if _tag_namespace(child.tag) == NS["c"] and _local_name(child.tag).endswith("Chart") + ] + if len(plot_nodes) > 1: + return _unsupported_chart_capability( + "chart_edit_multi_plot_unsupported", + "template-fill chart edits do not support multi-plot or combination charts", + plot_count=len(plot_nodes), + ) + if not plot_nodes: + return _unsupported_chart_capability( + "chart_edit_plot_type_unsupported", + "template-fill chart edits require exactly one recognized chart plot", + ) + + plot = plot_nodes[0] + plot_type = _local_name(plot.tag) + if plot_type == "scatterChart": + return _unsupported_chart_capability( + "chart_edit_scatter_unsupported", + "template-fill chart edits do not support scatter xVal/yVal data", + plot_type=plot_type, + plot_count=1, + data_model="xy", + ) + if plot_type == "bubbleChart": + return _unsupported_chart_capability( + "chart_edit_bubble_unsupported", + "template-fill chart edits do not support bubble xVal/yVal/bubbleSize data", + plot_type=plot_type, + plot_count=1, + data_model="bubble", + ) + series_nodes = plot.findall("c:ser", NS) + if not series_nodes: + return _unsupported_chart_capability( + "chart_edit_no_series", + "template-fill chart edits require at least one editable series", + plot_type=plot_type, + plot_count=1, + data_model="category", + ) + for series in series_nodes: + if any( + series.find(f"c:{tag}", NS) is not None + for tag in ("xVal", "yVal", "bubbleSize") + ): + return _unsupported_chart_capability( + "chart_edit_data_model_unsupported", + "template-fill chart edits require c:cat/c:val series", + plot_type=plot_type, + plot_count=1, + ) + category = series.find("c:cat", NS) + values = series.find("c:val", NS) + if category is None or values is None: + return _unsupported_chart_capability( + "chart_edit_data_model_unsupported", + "template-fill chart edits require c:cat/c:val on every series", + plot_type=plot_type, + plot_count=1, + ) + capability_warnings: list[dict[str, str]] = [] + if plot_area.find("c:dateAx", NS) is not None: + capability_warnings.append( + { + "code": "chart_edit_date_axis_flattened", + "message": ( + "template-fill will flatten date-axis categories to the " + "replacement single-level category cache" + ), + } + ) + if any( + series.find("c:cat/c:multiLvlStrRef", NS) is not None + for series in series_nodes + ): + capability_warnings.append( + { + "code": "chart_edit_multilevel_categories_flattened", + "message": ( + "template-fill will flatten multi-level categories to the " + "replacement single-level category cache" + ), + } + ) + + return { + "supported": True, + "code": "chart_edit_category_single_plot", + "message": "single classic plot uses c:cat/c:val series", + "plot_type": plot_type, + "plot_count": 1, + "data_model": "category", + "warnings": capability_warnings, + } + + +def _is_verified_category_capability(capability: Any) -> bool: + """Return whether an analyzer capability matches the runtime structural gate.""" + return bool( + isinstance(capability, dict) + and capability.get("supported") is True + and capability.get("code") == "chart_edit_category_single_plot" + and capability.get("data_model") == "category" + and capability.get("plot_count") == 1 + and isinstance(capability.get("plot_type"), str) + and bool(capability.get("plot_type")) + and capability.get("plot_type") not in _XY_PLOTS + ) + + +def _require_supported_chart_edit(chart_root: ET.Element) -> dict[str, Any]: + """Raise before mutation unless ``chart_root`` uses the verified category model.""" + capability = _chart_edit_capability(chart_root) + if not _is_verified_category_capability(capability): + code = capability.get("code") or "chart_edit_capability_unknown" + message = capability.get("message") or "chart edit capability is unknown" + raise RuntimeError(f"{message} [{code}]") + return capability + + +def _ooxml_bool(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "on", "true"} + + +def _positive_span(value: str | None) -> int: + try: + return max(int(value or "1"), 1) + except ValueError: + return 1 + + +def _table_cell_merge_info(cell: ET.Element) -> dict[str, Any]: + """Return the merge role encoded directly on one physical ``a:tc`` cell.""" + h_merge = _ooxml_bool(cell.attrib.get("hMerge")) + v_merge = _ooxml_bool(cell.attrib.get("vMerge")) + row_span = _positive_span(cell.attrib.get("rowSpan")) + col_span = _positive_span(cell.attrib.get("gridSpan")) + is_merge_slave = h_merge or v_merge + is_merge_anchor = not is_merge_slave and (row_span > 1 or col_span > 1) + merge_role = "slave" if is_merge_slave else "anchor" if is_merge_anchor else "none" + return { + "merge_role": merge_role, + "is_merge_anchor": is_merge_anchor, + "is_merge_slave": is_merge_slave, + "row_span": row_span, + "col_span": col_span, + "h_merge": h_merge, + "v_merge": v_merge, + } + + +def _table_merge_topology(table: ET.Element) -> dict[str, Any]: + """Describe merge anchors and physical slave cells in a DrawingML table.""" + cell_states: list[tuple[int, int, dict[str, Any]]] = [] + anchors: list[dict[str, int]] = [] + for row_index, row in enumerate(table.findall("a:tr", NS)): + for col_index, cell in enumerate(row.findall("a:tc", NS)): + info = _table_cell_merge_info(cell) + cell_states.append((row_index, col_index, info)) + if info["is_merge_anchor"]: + anchors.append( + { + "row": row_index, + "col": col_index, + "row_span": info["row_span"], + "col_span": info["col_span"], + } + ) + + covered_by: dict[tuple[int, int], dict[str, int]] = {} + for anchor in anchors: + for row_index in range(anchor["row"], anchor["row"] + anchor["row_span"]): + for col_index in range(anchor["col"], anchor["col"] + anchor["col_span"]): + if (row_index, col_index) == (anchor["row"], anchor["col"]): + continue + covered_by.setdefault( + (row_index, col_index), + {"row": anchor["row"], "col": anchor["col"]}, + ) + + slave_cells: list[dict[str, Any]] = [] + for row_index, col_index, info in cell_states: + if not info["is_merge_slave"]: + continue + slave: dict[str, Any] = {"row": row_index, "col": col_index} + anchor = covered_by.get((row_index, col_index)) + if anchor is not None: + slave["anchor"] = anchor + slave_cells.append(slave) + + return { + "has_merges": bool(anchors or slave_cells), + "anchors": anchors, + "slave_cells": slave_cells, + } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/ooxml.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/ooxml.py index 4cb2812e..5dbdc6ec 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/ooxml.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/ooxml.py @@ -75,6 +75,13 @@ def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element: def _xml_bytes(root: ET.Element) -> bytes: + root_namespace = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else "" + if root_namespace in {REL_NS, CT_NS}: + # OPC relationship/content-type roots conventionally use the default + # namespace. ElementTree's global prefix registry can be changed while + # parsing source parts; restore the package-root form before writing so + # strict consumers such as LibreOffice accept the generated package. + ET.register_namespace("", root_namespace) return ET.tostring(root, encoding="utf-8", xml_declaration=True) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/table_fill.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/table_fill.py index 5b51110e..7d43605c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/table_fill.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/table_fill.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any from xml.etree import ElementTree as ET +from .edit_safety import _table_cell_merge_info from .ooxml import NS, _shape_identity, _table_containers from .selectors import _table_cell_text, _table_selectors from .text_fill import _set_container_text @@ -32,6 +33,7 @@ def _apply_table_edits_to_slide( ) -> None: maps = _table_key_maps(slide_root, source_slide) errors: list[str] = [] + pending_edits: list[tuple[ET.Element, str]] = [] for table_edit in table_edits: selectors = _table_selectors(table_edit) table_frame = next((maps[key] for key in selectors if key in maps), None) @@ -54,6 +56,16 @@ def _apply_table_edits_to_slide( if col_index < 0 or col_index >= len(row_cells): errors.append(f"{selectors[0] if selectors else ''} row={row_index} col={col_index}") continue - _set_container_text(row_cells[col_index], _table_cell_text(cell_edit)) + target_cell = row_cells[col_index] + if _table_cell_merge_info(target_cell)["is_merge_slave"]: + target = selectors[0] if selectors else "
" + errors.append( + f"{target} row={row_index} col={col_index} is a merged-cell slave " + "[table_cell_is_merge_slave]; edit the merge anchor instead" + ) + continue + pending_edits.append((target_cell, _table_cell_text(cell_edit))) if errors: raise RuntimeError(f"Invalid table edit target(s) on slide {source_slide}: {'; '.join(errors)}") + for target_cell, text in pending_edits: + _set_container_text(target_cell, text) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_preview_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_preview_pptx.py new file mode 100644 index 00000000..0c984de0 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_preview_pptx.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +PPT Master - Template Preview PPTX Exporter + +Export every SVG prototype in a template workspace as one structured review deck. + +Usage: + python3 scripts/template_preview_pptx.py [-o output.pptx] + +Examples: + python3 scripts/template_preview_pptx.py projects/my_template + python3 scripts/template_preview_pptx.py templates/decks/my_template -o review.pptx + python3 scripts/template_preview_pptx.py templates/decks/legacy --visual-only + +Dependencies: + python-pptx +""" + +from __future__ import annotations + +import argparse +import math +import re +import statistics +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + +from console_encoding import configure_utf8_stdio + + +configure_utf8_stdio() + +from pptx import Presentation # noqa: E402 + +from svg_to_pptx.drawingml.theme_fonts import ( # noqa: E402 + MasterTextStyleSpec, +) +from svg_to_pptx.drawingml.utils import font_px_to_hpt # noqa: E402 +from svg_to_pptx.pptx_package.builder import ( # noqa: E402 + create_pptx_with_native_svg, +) + + +_FRONTMATTER_ID_RE = re.compile( + r"^(?:template_id|deck_id|layout_id)\s*:\s*(.+?)\s*$", + re.MULTILINE, +) +_FONT_SIZE_RE = re.compile(r"^([0-9]+(?:\.[0-9]+)?)(?:px)?$") +_FILENAME_UNSAFE_RE = re.compile(r"[\\/:*?\"<>|\x00-\x1f]+") +_TITLE_PLACEHOLDERS = frozenset({"title", "subtitle"}) +_BODY_PLACEHOLDERS = frozenset({ + "body", + "date", + "footer", + "slide-number", +}) +_DEFAULT_TITLE_PX = 40.0 +_DEFAULT_BODY_PX = 24.0 + + +def _resolve_workspace(path: Path) -> tuple[Path, Path]: + """Resolve one workspace root and its canonical template-source directory.""" + candidate = path.expanduser().resolve() + nested_spec = candidate / "templates" / "design_spec.md" + if nested_spec.is_file(): + return candidate, candidate / "templates" + + direct_spec = candidate / "design_spec.md" + if direct_spec.is_file(): + if candidate.name == "templates" and (candidate.parent / "exports").is_dir(): + return candidate.parent, candidate + return candidate, candidate + + raise ValueError( + "template workspace must contain templates/design_spec.md " + "(current structure) or design_spec.md (legacy flat package)" + ) + + +def _template_id(spec_path: Path, workspace: Path) -> str: + """Read a portable template id, falling back to the workspace directory name.""" + text = spec_path.read_text(encoding="utf-8") + match = _FRONTMATTER_ID_RE.search(text) + raw = match.group(1).strip().strip("'\"") if match else workspace.name + safe = _FILENAME_UNSAFE_RE.sub("_", raw).strip(" ._") + return safe or "template" + + +def _style_property(style: str, name: str) -> str | None: + """Return one inline CSS declaration value.""" + for declaration in style.split(";"): + key, separator, value = declaration.partition(":") + if separator and key.strip().lower() == name: + return value.strip() + return None + + +def _font_size_px(element: ET.Element) -> float | None: + """Read one finite positive SVG font size in px.""" + raw = element.get("font-size") + if raw is None: + raw = _style_property(element.get("style", ""), "font-size") + if raw is None: + return None + match = _FONT_SIZE_RE.fullmatch(raw.strip()) + if match is None: + return None + value = float(match.group(1)) + return value if math.isfinite(value) and value > 0 else None + + +def _carrier_sizes(svg_files: list[Path]) -> tuple[list[float], list[float]]: + """Collect authored title/body sizes from semantic placeholder carriers.""" + title_sizes: list[float] = [] + body_sizes: list[float] = [] + for svg_path in svg_files: + root = ET.parse(svg_path).getroot() + for slot in root.iter(): + placeholder = slot.get("data-pptx-placeholder") + if placeholder not in _TITLE_PLACEHOLDERS | _BODY_PLACEHOLDERS: + continue + for carrier in slot.iter(): + if carrier.get("data-pptx-placeholder-carrier") != "true": + continue + size = _font_size_px(carrier) + if size is None: + continue + target = title_sizes if placeholder in _TITLE_PLACEHOLDERS else body_sizes + target.append(size) + return title_sizes, body_sizes + + +def _master_text_style(svg_files: list[Path]) -> tuple[MasterTextStyleSpec, float, float]: + """Build review-only Master text defaults without requiring a project lock.""" + title_sizes, body_sizes = _carrier_sizes(svg_files) + title_px = float(statistics.median(title_sizes)) if title_sizes else _DEFAULT_TITLE_PX + body_px = float(statistics.median(body_sizes)) if body_sizes else _DEFAULT_BODY_PX + return ( + MasterTextStyleSpec( + title_hpt=font_px_to_hpt(title_px), + body_hpt=font_px_to_hpt(body_px), + ), + title_px, + body_px, + ) + + +def _verify_output(output_path: Path) -> tuple[int, int, int]: + """Reopen the review deck and return slide/master/layout counts.""" + presentation = Presentation(str(output_path)) + master_count = len(presentation.slide_masters) + layout_count = sum(len(master.slide_layouts) for master in presentation.slide_masters) + return len(presentation.slides), master_count, layout_count + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Export a complete template workspace as a structured PPTX review deck." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "template_workspace", + help=( + "Workspace containing templates/design_spec.md; legacy flat template " + "directories are also accepted." + ), + ) + parser.add_argument( + "-o", + "--output", + help=( + "Output PPTX path. Default: " + "/exports/_template_preview.pptx" + ), + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace an existing review PPTX after an intentional re-export.", + ) + parser.add_argument( + "--visual-only", + action="store_true", + help=( + "Export a legacy SVG roster as slide-local DrawingML for visual review. " + "This does not validate or claim a reusable Master/Layout contract." + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + workspace, template_dir = _resolve_workspace(Path(args.template_workspace)) + svg_files = sorted(template_dir.glob("*.svg")) + if not svg_files: + raise ValueError(f"template directory has no SVG prototypes: {template_dir}") + + template_id = _template_id(template_dir / "design_spec.md", workspace) + output_path = ( + Path(args.output).expanduser().resolve() + if args.output + else workspace / "exports" / f"{template_id}_template_preview.pptx" + ) + if output_path.suffix.lower() != ".pptx": + raise ValueError(f"output must use a .pptx extension: {output_path}") + if output_path.exists() and not args.force: + raise ValueError( + f"output already exists: {output_path}; use --force to replace it" + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + text_style: MasterTextStyleSpec | None = None + if not args.visual_only: + text_style, title_px, body_px = _master_text_style(svg_files) + + print("PPT Master - Template Preview PPTX Exporter") + print(f" Workspace: {workspace}") + print(f" Template source: {template_dir}") + print(f" SVG prototypes: {len(svg_files)}") + if args.visual_only: + print(" Review mode: visual-only legacy compatibility") + else: + print(f" Review Master defaults: title {title_px:g}px, body {body_px:g}px") + print(f" Output: {output_path}") + + success = create_pptx_with_native_svg( + svg_files=svg_files, + output_path=output_path, + canvas_format=None, + verbose=True, + transition=None, + enable_notes=False, + animation=None, + image_optimize=False, + native_objects=True, + pptx_structure="flat" if args.visual_only else "structured", + master_text_style_spec=text_style, + ) + if not success or not output_path.is_file(): + print("Error: template preview export did not produce a PPTX", file=sys.stderr) + return 1 + + slide_count, master_count, layout_count = _verify_output(output_path) + if slide_count != len(svg_files): + print( + "Error: review PPTX slide count does not match the template SVG roster " + f"({slide_count} != {len(svg_files)})", + file=sys.stderr, + ) + return 1 + + label = "Visual-only template preview" if args.visual_only else "Template preview" + print( + f"[OK] {label} verified: " + f"{slide_count} slides, {master_count} master(s), {layout_count} layout(s)" + ) + print(output_path) + return 0 + except (OSError, ET.ParseError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/README.md index e68d32cb..e6f91d9f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/README.md @@ -21,9 +21,11 @@ The `layouts/` directory contains pre-built page layout templates organized by d - **Human browsing**: [layouts/README.md](./layouts/README.md) - **Slim lookup (discovery only)**: [layouts/layouts_index.json](./layouts/layouts_index.json) — used to answer "what templates exist?". Step 3 triggers on an explicit directory path supplied by the user, not on names from this index. +Every brand/layout/deck package uses one workspace routing contract. Whether created under this library or under `projects/`, source files live in `templates/`, bitmaps in `images/`, runtime icons in `icons/`, and on-demand review files in `exports/`. Empty optional directories are omitted, so a normal checked-in workspace has no `exports/`. Library `exports/` directories are Git-ignored, and Step 3 never copies them. Existing flat packages remain readable; flat placement alone does not imply legacy Master/Layout metadata. + ## Brand Identity Presets -The `brands/` directory holds brand-only templates: identity bundles (color / typography / logo / voice / icon style) without an SVG page roster. Brands follow the **same explicit-path trigger rule as layout templates** — at SKILL.md Step 3 the user supplies the brand directory path to apply it; bare brand names never trigger. Both layout and brand inputs land in the same project directory (`/templates/`). When supplied together, Step 3 fuses them into a single `design_spec.md` (brand wins on identity tokens, layout wins on page structure) — see `SKILL.md` Step 3 for the precedence table. +The `brands/` directory holds brand-only templates: identity bundles (color / typography / logo / voice / icon style) without an SVG page roster. Brands follow the **same explicit-path trigger and workspace routing as layout templates** — at SKILL.md Step 3 the user supplies the workspace root; bare brand names never trigger. `templates/`, `images/`, and `icons/` map to their matching project peers. When supplied together, Step 3 fuses them into one `design_spec.md` (brand wins on identity tokens, layout wins on page structure) — see `SKILL.md` Step 3 for the precedence table. A brand is structurally a layout template minus its page roster. Use a brand when the user wants identity locking with free page layout; use a layout template when fixed page structures are also required. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/README.md index fc2acc4d..ec696647 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/README.md @@ -2,15 +2,15 @@ This directory holds **brand-only templates**: identity bundles (color / typography / logo / voice / icon style) without an SVG page roster. Strategist locks the brand's identity segment as truth; Executor designs pages freely under those constraints. -Brand is one of three template kinds in the library — alongside [`layouts/`](../layouts/) (structure-only) and [`decks/`](../decks/) (full-PPT replica). Full data model: [`docs/zh/templates-architecture.md`](../../../../docs/zh/templates-architecture.md). +Brand is one of three template kinds in the library — alongside [`layouts/`](../layouts/) (structure-only) and [`decks/`](../decks/) (complete identity + structure). Full data model: [`docs/zh/templates-architecture.md`](../../../../docs/zh/templates-architecture.md). ## How brands are consumed -Brand application follows the **same explicit-path rule as all template kinds** at SKILL.md Step 3, and lands in the **same project directory** (`/templates/`): +Brand application follows the **same explicit-path rule and workspace routing as all template kinds** at SKILL.md Step 3: | User input at SKILL.md Step 3 | Behavior | |---|---| -| An explicit brand directory path (e.g. `templates/brands/anthropic/`) | Copy `design_spec.md` + logo files + asset subdirectories into `/templates/`; Strategist locks identity segment | +| An explicit brand workspace path (e.g. `templates/brands/anthropic/`) | Resolve `templates/design_spec.md`; stage `templates/` plus any existing `images/` and `icons/` into the matching project directories; Strategist locks the identity segment | | Bare brand name only ("use anthropic brand"), brand mention without path, or silence | Skip — same mechanical rule as all template kinds: bare names never trigger | | Brand path + layout path | Fuse into one `design_spec.md` — brand owns identity segment (color / typography / logo / voice / icon style); layout owns structure segment (canvas / page roster). See `SKILL.md` Step 3. | | Brand path + deck path | Fuse — brand identity overrides deck identity; structure + middle segments come from deck | @@ -29,25 +29,24 @@ Read skills/ppt-master/workflows/create-brand.md Three input paths are supported: brand asset (logo / brand site URL / branded PPTX / brand PDF), verbal spec dictated in chat, or empty skeleton for the user to fill in later. -## Package structure +## Workspace structure -Every brand directory is self-contained: +Every brand uses the same workspace routing as layout and deck templates. Brand identity remains roster-free; omit empty optional directories instead of adding placeholder files. ``` templates/brands// -├── design_spec.md # required — brand identity spec (7 sections) -├── logo. # optional — primary brand logo (single-lockup brands) -│ …or… -├── _wordmark. # optional — wordmark variant (dual-lockup brands) -├── _mark. # optional — symbol / icon variant (dual-lockup brands) -├── images/ # optional — branded photos -├── illustrations/ # optional — branded illustrations -└── icons/ # optional — branded icon overrides +├── templates/ +│ └── design_spec.md # required — brand identity spec +├── images/ # optional — logos and visual assets +│ ├── logo. # optional — primary logo +│ └── _wordmark.svg # optional — alternate lockups and visual assets +├── icons/ # optional — branded icon overrides +└── exports/ # normally absent; real local derived artifacts only; Git-ignored ``` -Logo filenames are descriptive, not contractual — `design_spec.md` §IV lists the exact files and the contexts in which each is used. Single-lockup brands typically ship one `logo.`; dual-lockup brands (e.g. Google's wordmark + G mark) ship separately named files. +Logo filenames are descriptive, not contractual — `templates/design_spec.md` §IV lists exact `../images/...` paths and usage contexts. Single-lockup brands typically ship one logo; dual-lockup brands ship separately named files. -`design_spec.md` carries a YAML frontmatter block with `kind: brand` and is the single source of truth for the brand identity. The six required sections are: I Brand Overview / II Color Scheme / III Typography / IV Logo / V Voice & Tone / VI Icon Style. +`templates/design_spec.md` carries a YAML frontmatter block with `kind: brand` and is the single source of truth for the brand identity. The six required sections are: I Brand Overview / II Color Scheme / III Typography / IV Logo / V Voice & Tone / VI Icon Style. ## Discovery index diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/anthropic_claude_lockup.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/anthropic_claude_lockup.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/anthropic_claude_lockup.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/anthropic_claude_lockup.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/anthropic_mark.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/anthropic_mark.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/anthropic_mark.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/anthropic_mark.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/claude_wordmark.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/claude_wordmark.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/claude_wordmark.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/images/claude_wordmark.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/templates/design_spec.md similarity index 84% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/templates/design_spec.md index f844b44f..46378345 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/design_spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/anthropic/templates/design_spec.md @@ -48,9 +48,9 @@ Anthropic uses a six-petal star **mark** that is most commonly seen locked up wi | File | Form | Usage | |---|---|---| -| `./anthropic_claude_lockup.svg` | Mark + "Claude" wordmark (112×24) | Cover hero, ending sign-off, any moment when the brand needs to read as Claude-the-product at a glance | -| `./anthropic_mark.svg` | Square orange six-petal mark (24×24) | Header / footer corners, page-number neighbors, tight badges, any small-size moment where the lockup would become illegible — also the recommended choice when the deck is about Anthropic the company rather than Claude the product | -| `./claude_wordmark.svg` | "Claude" wordmark alone (82×24) | When the visual context already establishes the brand and only the product name needs to be reinforced | +| `../images/anthropic_claude_lockup.svg` | Mark + "Claude" wordmark (112×24) | Cover hero, ending sign-off, any moment when the brand needs to read as Claude-the-product at a glance | +| `../images/anthropic_mark.svg` | Square orange six-petal mark (24×24) | Header / footer corners, page-number neighbors, tight badges, any small-size moment where the lockup would become illegible — also the recommended choice when the deck is about Anthropic the company rather than Claude the product | +| `../images/claude_wordmark.svg` | "Claude" wordmark alone (82×24) | When the visual context already establishes the brand and only the product name needs to be reinforced | - Cover: prefer the lockup when the deck is Claude-facing; prefer the mark alone when the deck is Anthropic-company-facing - Per-page: optional — only when one of these genuinely fits the layout; do not stamp every page diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/google_g_logo.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/images/google_g_logo.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/google_g_logo.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/images/google_g_logo.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/google_wordmark.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/images/google_wordmark.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/google_wordmark.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/images/google_wordmark.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/templates/design_spec.md similarity index 88% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/templates/design_spec.md index 6159c149..bc6a36bb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/design_spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/google/templates/design_spec.md @@ -45,8 +45,8 @@ Google uses a dual-lockup brand system — pick by context, never combine on the | File | Form | Usage | |---|---|---| -| `./google_wordmark.svg` | Full "Google" wordmark (272×92) | Cover hero, ending sign-off, any moment the full brand reads at a glance | -| `./google_g_logo.svg` | Square multi-color "G" mark (24×24) | Header / footer corners, page-number neighbors, tight badges, any small-size moment where the wordmark would become illegible | +| `../images/google_wordmark.svg` | Full "Google" wordmark (272×92) | Cover hero, ending sign-off, any moment the full brand reads at a glance | +| `../images/google_g_logo.svg` | Square multi-color "G" mark (24×24) | Header / footer corners, page-number neighbors, tight badges, any small-size moment where the wordmark would become illegible | - Cover: prefer wordmark - Per-page: optional — only when wordmark or G mark genuinely fits the layout; do not stamp every page diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/中国水务logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/中国水务logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/中国水务logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/中国水务logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/华东院logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/华东院logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/华东院logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/华东院logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/水电三局logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/水电三局logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/水电三局logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/水电三局logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/电建logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/电建logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/电建logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/images/电建logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/templates/design_spec.md similarity index 85% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/templates/design_spec.md index 436db3e4..0e21fd32 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/design_spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中国电建/templates/design_spec.md @@ -48,10 +48,10 @@ POWERCHINA templates may use the group brand logo or subsidiary logos depending | File | Form | Usage | |---|---|---| -| `./电建logo.png` | POWERCHINA symbol / primary mark (234x236) | Cover, ending, and compact brand moments where the group identity is required | -| `./中国水务logo.png` | China Water logo lockup (1108x190) | Subsidiary-led water-business reports; cover or ending lockup | -| `./华东院logo.png` | East China Institute logo lockup (1438x300) | Institute-led technical reports and engineering proposals | -| `./水电三局logo.png` | Sinohydro Bureau 3 logo lockup (629x75) | Bureau-led project reports and construction updates | +| `../images/电建logo.png` | POWERCHINA symbol / primary mark (234x236) | Cover, ending, and compact brand moments where the group identity is required | +| `../images/中国水务logo.png` | China Water logo lockup (1108x190) | Subsidiary-led water-business reports; cover or ending lockup | +| `../images/华东院logo.png` | East China Institute logo lockup (1438x300) | Institute-led technical reports and engineering proposals | +| `../images/水电三局logo.png` | Sinohydro Bureau 3 logo lockup (629x75) | Bureau-led project reports and construction updates | - Cover / ending: use the selected primary logo at readable size. - Per-page header: optional; use a compact logo only when it does not crowd the title bar. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/右上角 logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/images/右上角 logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/右上角 logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/images/右上角 logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/大型 logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/images/大型 logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/大型 logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/images/大型 logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/templates/design_spec.md similarity index 92% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/templates/design_spec.md index 4ea6217c..fc051b03 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/design_spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/brands/中汽研/templates/design_spec.md @@ -49,8 +49,8 @@ CATARC uses separate large and header lockups. Choose the large logo for brand m | File | Form | Usage | |---|---|---| -| `./大型 logo.png` | Large CATARC lockup (592x238) | Cover hero, chapter/ending brand moments, and dark page lockups | -| `./右上角 logo.png` | Compact header logo (113x50) | Upper-right page header, title bar, and navigation areas | +| `../images/大型 logo.png` | Large CATARC lockup (592x238) | Cover hero, chapter/ending brand moments, and dark page lockups | +| `../images/右上角 logo.png` | Compact header logo (113x50) | Upper-right page header, title bar, and navigation areas | - Cover / ending: use `大型 logo.png` where the brand must read strongly. - Content pages: use `右上角 logo.png` only when the layout has enough title-bar space. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/charts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/charts/README.md index ad3e833a..47bc8b3b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/charts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/charts/README.md @@ -16,16 +16,20 @@ See [`CHART_STYLE_GUIDE.md`](./CHART_STYLE_GUIDE.md) for color palette, typograp Supported data chart templates include a `` marker by default, and pure text-grid table templates include a `` marker the same way. The default SVG export path is unchanged: the fallback vector artwork is exported exactly as drawn. When `svg_to_pptx.py --native-objects` is enabled, that marked fallback group is replaced with an editable PowerPoint chart or table using the JSON metadata inside the child `` node. -Native marker authoring is default for supported data charts and text-grid tables; native object activation is opt-in via `--native-objects`. Markers must include explicit `name`, `x`, `y`, `width`, and `height` fields so the editable frame aligns with the fallback drawing. Keep legends, explanatory cards, and source notes outside the marker when they should remain as separate editable shapes. Tables with merged, spanning, or graphical cells (harvey balls, rating dots, avatars) stay unmarked on the SVG fallback route. +`--native-objects` is an explicit editable-first opt-in and may be lossy or visually normalized. Marker-local value labels, center KPIs, callouts, quadrant notes, fixed axis ranges, and custom binning/splits may not survive native replacement unless represented by the payload. ChartEx palette entries do survive when supplied through valid payload colors, but this does not preserve every ChartEx style detail. Detectable information-loss risks should be reported as warnings, not handled by disabling an otherwise supported native marker. Review those warnings and compare the native export with the default SVG export before delivery. -| Family | Native-marker templates | Use when | +Native marker authoring remains active for all 23 currently supported data-chart templates: + +| Family | Active native-marker templates | Native output | |---|---|---| -| Category comparison | `column_chart`, `horizontal_bar_chart`, `grouped_bar_chart`, `stacked_bar_chart` | Comparing named categories, rankings, or stacked totals | -| Time trend | `line_chart`, `area_chart`, `stacked_area_chart`, `dual_axis_line_chart` | Showing direction, volume, cumulative share, or two-axis trends | -| Part-to-whole | `pie_chart`, `donut_chart`, `pie_of_pie_chart`, `bar_of_pie_chart`, `treemap_chart`, `sunburst_chart` | Showing share, long-tail split, or hierarchy composition | -| Distribution and relationship | `scatter_chart`, `bubble_chart`, `histogram_chart`, `pareto_chart`, `box_plot_chart` | Showing correlation, spread, frequency, defects, or statistical ranges | -| Specialty business charts | `waterfall_chart`, `funnel_chart`, `stock_chart`, `radar_chart` | Showing bridges, conversion stages, OHLC movement, or multi-axis capability profiles | -| Text-grid tables | `basic_table`, `financial_statement_table` | Presenting rectangular text/number grids that should become editable PowerPoint tables | +| Category comparison | `column_chart`, `horizontal_bar_chart`, `grouped_bar_chart`, `stacked_bar_chart` | Classic category charts | +| Time trend | `line_chart`, `area_chart`, `stacked_area_chart`, `dual_axis_line_chart` | Classic line/area/combo charts | +| Part-to-whole | `pie_chart`, `donut_chart`, `pie_of_pie_chart`, `bar_of_pie_chart`, `treemap_chart`, `sunburst_chart` | Classic pie-family or ChartEx hierarchy charts | +| Distribution and relationship | `scatter_chart`, `bubble_chart`, `histogram_chart`, `pareto_chart`, `box_plot_chart` | Classic XY or ChartEx distribution charts | +| Specialty business charts | `waterfall_chart`, `funnel_chart`, `stock_chart`, `radar_chart` | ChartEx or classic specialty charts | +| Text-grid tables | `basic_table`, `financial_statement_table` | Native DrawingML tables | + +Markers must include explicit `name`, `x`, `y`, `width`, and `height` fields so the editable frame aligns with the fallback drawing. Keep legends, explanatory cards, source notes, center KPIs, and custom callouts outside the marker when they must remain as separate editable shapes; otherwise accept and review the native-export warning. Canonical rectangular merged text cells may use anchor-only `row_span` / `col_span` metadata with blank covered cells; nonrectangular merges and graphical cells (harvey balls, rating dots, avatars) stay unmarked on the SVG fallback route. Per-side borders, plain multi-paragraph cells, and the closed run-rich paragraph schema use the contracts in [`shared-standards.md`](../../references/shared-standards.md#native-pptx-table--chart-markers-opt-in). Relationship-bearing text, structural line breaks, fields, tabs, bullets, and arbitrary rich-text OOXML stay on the SVG fallback route. ## Usage diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/README.md index 7c1b4ee8..83f369f2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/README.md @@ -1,6 +1,6 @@ # Deck Templates -**Deck = full-PPT replica.** Each deck reverse-engineers a specific organization's branded presentation and bundles its **identity + structure + middle** segments into one atomic asset. Use a deck when you want the complete look of a particular institution (color, typography, logo, page structure, voice) preserved as a whole. +**Deck = full identity + structure reference.** Each deck reverse-engineers a specific organization's branded presentation and bundles its **identity + structure + middle** segments into one atomic asset. Use a deck when you want the complete look of a particular institution (color, typography, logo, page structure, voice) preserved as a coherent authored system. Single source of truth for what decks exist: [`decks_index.json`](./decks_index.json) (`deck_id → { summary, canvas_format, page_count, primary_color }`). This README explains the kind; it does **not** enumerate decks. @@ -10,7 +10,7 @@ Full data model: [`docs/zh/templates-architecture.md`](../../../../docs/zh/templ ## Trigger rule -Deck selection is **opt-in by explicit path**. The main workflow defaults to free design. A deck is only used when the user gives an explicit directory path in their initial message (e.g. `skills/ppt-master/templates/decks/招商银行/`). Bare names do not trigger. See [`SKILL.md`](../../SKILL.md) Step 3. +Deck selection is **opt-in by explicit path**. The main workflow defaults to free design. For a current package, the user supplies the explicit workspace root (for example, `skills/ppt-master/templates/decks//`), not its inner `templates/` directory. A legacy-flat deck root containing `design_spec.md` remains a compatible input. Bare names do not trigger. See [`SKILL.md`](../../SKILL.md) Step 3. `decks_index.json` is a **discovery aid**, not a trigger — it lets the AI answer "what decks exist?" by listing ids and paths. Listing alone never advances the pipeline. @@ -54,12 +54,44 @@ When the user gives a deck path **with** a brand path or layout path, identity / --- +## Standard workspace contract + +New deck creation uses the same portable workspace routing in both output scopes: + +```text +/ +├── templates/ +│ ├── design_spec.md +│ ├── 01_cover.svg +│ ├── 02_chapter.svg +│ ├── 03_content.svg +│ └── 04_ending.svg +├── images/ # Optional; omit when unused +├── icons/ # Optional; omit when unused +└── exports/ # Optional, on-demand review output; Git-ignored + └── _template_preview.pptx +``` + +| Scope | `` | Difference | +|---|---|---| +| Library | `skills/ppt-master/templates/decks//` | Register in `decks_index.json` | +| Project | `projects//` | Do not register globally | + +Bitmaps belong in `images/`; extracted runtime icons belong in `icons/`; template sources and any validation icon copy belong in `templates/`. Omit empty optional directories instead of adding placeholder files. Generate a preview PPTX only when local PowerPoint review is requested; it is derived output, library `exports/` is Git-ignored, and template application never copies it. + +`standard` and `fidelity` author new SVG documents and a new Master/Layout/slot contract. `mirror` restores the source roster, Master/Layout identities and parentage, placeholder facts, and supported visuals without semantic synthesis. Fixed Master/Layout group wrappers are mechanically expanded into direct atoms because structural layers cannot be ``; this normalization must preserve ownership, paint order, and appearance. + +**Legacy compatibility**: Existing flat packages with `design_spec.md` and SVGs at their root remain readable. Flat placement alone does not trigger [`restore-pptx-structure`](../../workflows/restore-pptx-structure.md); restoration is required only when SVG Master/Layout/slot metadata is absent or legacy. New `create-template` outputs always use the workspace contract above. + +--- + ## Creating a new deck 1. Run [`workflows/create-template.md`](../../workflows/create-template.md) (default kind is `deck`) -2. Resulting directory lands under `templates/decks//` -3. Validate: `python3 skills/ppt-master/scripts/svg_quality_checker.py templates/decks/ --template-mode --format ppt169` -4. Register: `python3 skills/ppt-master/scripts/register_template.py --kind deck` +2. Choose the workspace root: library `skills/ppt-master/templates/decks//` or project `projects//` +3. Validate: `python3 skills/ppt-master/scripts/svg_quality_checker.py "/templates" --template-mode --format ppt169` +4. Optional — when PowerPoint review is requested, export the review deck: `python3 skills/ppt-master/scripts/template_preview_pptx.py ""` +5. For library scope only, register: `python3 skills/ppt-master/scripts/register_template.py --kind deck` The register step updates [`decks_index.json`](./decks_index.json) — the single source of truth for deck discovery. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/footer_ribbon.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/footer_ribbon.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/footer_ribbon.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/footer_ribbon.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/header_brand.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/header_brand.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/header_brand.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/header_brand.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/skyline_bg.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/skyline_bg.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/skyline_bg.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/skyline_bg.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/slogan_red.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/slogan_red.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/slogan_red.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/slogan_red.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/top_emblem.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/top_emblem.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/top_emblem.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/images/top_emblem.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/01_cover.svg similarity index 78% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/01_cover.svg index 083b8a63..1563b2ef 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/01_cover.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/01_cover.svg @@ -5,13 +5,13 @@ - - + + - - + + {{TITLE}} {{SUBTITLE}} diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_chapter.svg similarity index 90% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_chapter.svg index 6ef815b3..e244dbf7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_chapter.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_chapter.svg @@ -2,7 +2,7 @@ - + {{CHAPTER_NUM}} {{CHAPTER_TITLE}} @@ -25,5 +25,5 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_toc.svg similarity index 97% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_toc.svg index 4db96446..c495b5cf 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/02_toc.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/02_toc.svg @@ -6,7 +6,7 @@ 目录 - + 中国电信模板 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/03_content.svg similarity index 94% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/03_content.svg index 6710bb4d..675f5ee5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/03_content.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/03_content.svg @@ -8,7 +8,7 @@ {{SECTION_NAME}} {{PAGE_TITLE}} - + {{CONTENT_AREA}} diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/04_ending.svg similarity index 72% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/04_ending.svg index 0e0ad5a0..95675141 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/04_ending.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/04_ending.svg @@ -2,7 +2,7 @@ - + {{THANK_YOU}} {{ENDING_SUBTITLE}} @@ -10,9 +10,9 @@ - - + + - + {{PAGE_NUM}} diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电信/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/中国水务logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/中国水务logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/中国水务logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/中国水务logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/华东院logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/华东院logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/华东院logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/华东院logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/水电三局logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/水电三局logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/水电三局logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/水电三局logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/电建logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/电建logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/电建logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/images/电建logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中国电建/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/右上角 logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/images/右上角 logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/右上角 logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/images/右上角 logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/大型 logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/images/大型 logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/大型 logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/images/大型 logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/中汽研/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/cover_bg.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/cover_bg.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/cover_bg.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/cover_bg.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_crm_banner.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_crm_banner.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_crm_banner.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_crm_banner.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_dark.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_dark.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_dark.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_dark.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_white.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_white.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/logo_white.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/logo_white.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/page_header_bg.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/page_header_bg.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/page_header_bg.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/page_header_bg.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/ref_content_bg.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/ref_content_bg.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/ref_content_bg.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/images/ref_content_bg.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/01_cover.svg similarity index 94% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/01_cover.svg index 4164fe84..bb764c15 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/01_cover.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/01_cover.svg @@ -1,7 +1,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_chapter.svg similarity index 96% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_chapter.svg index 682c155c..349070c8 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_chapter.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_chapter.svg @@ -7,7 +7,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_toc.svg similarity index 95% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_toc.svg index fba944ba..9b43633b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/02_toc.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/02_toc.svg @@ -9,7 +9,7 @@ - + @@ -44,4 +44,3 @@ - diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/03_content.svg similarity index 97% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/03_content.svg index e3569cf7..7985cf26 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/03_content.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/03_content.svg @@ -9,7 +9,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/04_ending.svg similarity index 95% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/04_ending.svg index 06ecdc14..93a13184 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/04_ending.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/04_ending.svg @@ -1,7 +1,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/招商银行/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/重庆大学logo.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/images/重庆大学logo.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/重庆大学logo.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/images/重庆大学logo.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/重庆大学logo2.png b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/images/重庆大学logo2.png similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/重庆大学logo2.png rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/images/重庆大学logo2.png diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/01_cover.svg similarity index 96% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/01_cover.svg index 94e7839b..9db14ee0 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/01_cover.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/01_cover.svg @@ -29,7 +29,7 @@ - + CQU diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_chapter.svg similarity index 95% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_chapter.svg index 52eb3491..058304ed 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_chapter.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_chapter.svg @@ -45,7 +45,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_toc.svg similarity index 97% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_toc.svg index 2a3533a1..28047c0c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/02_toc.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/02_toc.svg @@ -23,7 +23,7 @@ {{TOC_TITLE}} - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/03_content.svg similarity index 95% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/03_content.svg index 92e1d3e2..1d5454e8 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/03_content.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/03_content.svg @@ -20,7 +20,7 @@ {{PAGE_TITLE}} - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/04_ending.svg similarity index 95% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/04_ending.svg index 001bd4e5..1bc4ed66 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/04_ending.svg +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/04_ending.svg @@ -21,7 +21,7 @@ - + diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/decks/重庆大学/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/design_spec_reference.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/design_spec_reference.md index 45f83eb8..3438a601 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/design_spec_reference.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/design_spec_reference.md @@ -306,6 +306,8 @@ Catalog read: 76 templates ## IX. Content Outline +> **Native Layout boundary**: Each `Layout` line below describes visual composition intent and feeds the planning-time structured mapping. Before SVG generation, Strategist writes the Master roster and exactly one ` | | ` row per page. Free-design/brand-only pages use that structure from their first draft; template pages also select an input prototype through `page_layouts`. Strict preserves the prototype contract. Adaptive keeps its Master and may assign a new Layout key during page authoring only when fixed Layout atoms or slot topology/bounds change. Legacy prototypes first run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md); no deferred distillation or immediate-compatibility fallback exists. + ### Part 1: [Chapter Name] #### Slide 01 - Cover diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/README.md index 39344659..6471286c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/README.md @@ -10,7 +10,7 @@ Full data model: [`docs/zh/templates-architecture.md`](../../../../docs/zh/templ ## Trigger rule -Layout selection is **opt-in by explicit path**. The main workflow defaults to free design. A layout is only used when the user gives an explicit directory path in their initial message (e.g. `skills/ppt-master/templates/layouts/academic_defense/`). Bare names do not trigger. See [`SKILL.md`](../../SKILL.md) Step 3. +Layout selection is **opt-in by explicit path**. The main workflow defaults to free design. For a current package, the user supplies the explicit workspace root (for example, `skills/ppt-master/templates/layouts//`), not its inner `templates/` directory. A legacy-flat layout root containing `design_spec.md` remains a compatible input. Bare names do not trigger. See [`SKILL.md`](../../SKILL.md) Step 3. `layouts_index.json` is a **discovery aid**, not a trigger — it lets the AI answer "what layouts exist?" by listing ids and paths. Listing alone never advances the pipeline. @@ -43,18 +43,36 @@ Layouts may include additional supporting sections (Layout Patterns, Spacing Gui --- -## Standard file set per layout directory +## Standard workspace contract -| Filename | Required | Purpose | -|----------|----------|---------| -| `design_spec.md` | Yes | Layout schema spec (frontmatter + structure sections) | -| `01_cover.svg` | Yes | Cover page | -| `02_toc.svg` | Optional | Table of contents | -| `02_chapter.svg` | Yes | Chapter page | -| `03_content.svg` | Yes | Content page | -| `04_ending.svg` | Yes | Ending page | +New layout creation uses the same portable workspace routing in both output scopes: -All SVGs use `viewBox="0 0 1280 720"` for ppt169. +```text +/ +├── templates/ +│ ├── design_spec.md +│ ├── 01_cover.svg +│ ├── 02_chapter.svg +│ ├── 03_content.svg +│ └── 04_ending.svg +├── images/ # Optional; omit when unused +├── icons/ # Optional; omit when unused +└── exports/ # Optional, on-demand review output; Git-ignored + └── _template_preview.pptx +``` + +| Scope | `` | Difference | +|---|---|---| +| Library | `skills/ppt-master/templates/layouts//` | Register in `layouts_index.json` | +| Project | `projects//` | Do not register globally | + +`02_toc.svg` and other roster variants remain optional. All SVGs use `viewBox="0 0 1280 720"` for ppt169. Bitmaps belong in `images/`; extracted runtime icons belong in `icons/`; template sources and any validation icon copy belong in `templates/`. + +Omit empty optional directories instead of adding placeholder files. Generate a preview PPTX only when local PowerPoint review is requested; it is derived output, library `exports/` is Git-ignored, and template application never copies it. + +`standard` and `fidelity` author new SVG documents and a new Master/Layout/slot contract. `mirror` restores the source roster, Master/Layout identities and parentage, placeholder facts, and supported visuals without semantic synthesis. Fixed Master/Layout group wrappers are mechanically expanded into direct atoms because structural layers cannot be ``; this normalization must preserve ownership, paint order, and appearance. + +**Legacy compatibility**: Existing flat packages with `design_spec.md` and SVGs at their root remain readable. Flat placement alone does not trigger [`restore-pptx-structure`](../../workflows/restore-pptx-structure.md); restoration is required only when SVG Master/Layout/slot metadata is absent or legacy. New `create-template` outputs always use the workspace contract above. --- @@ -67,9 +85,10 @@ Templates use `{{PLACEHOLDER}}` to mark replaceable content. New layouts should ## Creating a new layout 1. Run [`workflows/create-template.md`](../../workflows/create-template.md) (default produces a deck; explicit "structure only / no identity" option produces a layout) -2. Resulting directory lands under `templates/layouts//` -3. Validate: `python3 skills/ppt-master/scripts/svg_quality_checker.py templates/layouts/ --template-mode --format ppt169` -4. Register: `python3 skills/ppt-master/scripts/register_template.py --kind layout` +2. Choose the workspace root: library `skills/ppt-master/templates/layouts//` or project `projects//` +3. Validate: `python3 skills/ppt-master/scripts/svg_quality_checker.py "/templates" --template-mode --format ppt169` +4. Optional — when PowerPoint review is requested, export the review deck: `python3 skills/ppt-master/scripts/template_preview_pptx.py ""` +5. For library scope only, register: `python3 skills/ppt-master/scripts/register_template.py --kind layout` The register step updates [`layouts_index.json`](./layouts_index.json) — the single source of truth for layout discovery. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/academic_defense/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/reference_style.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/reference_style.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/reference_style.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/ai_ops/templates/reference_style.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_blue/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/government_red/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/medical_university/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/pixel_retro/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/01_cover.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/01_cover.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/01_cover.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/01_cover.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/02_chapter.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/02_chapter.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/02_chapter.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/02_chapter.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/02_toc.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/02_toc.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/02_toc.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/02_toc.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/03_content.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/03_content.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/03_content.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/03_content.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/04_ending.svg b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/04_ending.svg similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/04_ending.svg rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/04_ending.svg diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/design_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/design_spec.md similarity index 100% rename from plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/design_spec.md rename to plugins/codex/plugins/ppt-master/skills/ppt-master/templates/layouts/psychology_attachment/templates/design_spec.md diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md index ac064acf..ed2a305d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md @@ -37,7 +37,7 @@ > Strategist: fill only colors actually used. Add extra rows as needed; delete unused rows rather than leave as `#......`. > -> **PowerPoint theme roles.** Native `baseline` / `template` export maps `bg` / `background` / `master_bg` → `lt1`, `secondary_bg` / `bg_secondary` → `lt2`, `text` / `body_text` → `dk1`, `text_secondary` → `dk2`, `primary` → `accent1`, `accent` → `accent2`, `secondary_accent` → `accent3`, and `border` → `accent4`. The first two additional non-black/non-white roles become `accent5` / `accent6`; remaining colors stay fixed. Mapping is usage-aware, so a background HEX is not automatically reused for inverse text. Preserve mode keeps the imported source theme; flat mode keeps concrete colors for diagnostics. +> **PowerPoint theme roles.** Structured export maps `bg` / `background` / `master_bg` → `lt1`, `secondary_bg` / `bg_secondary` → `lt2`, `text` / `body_text` → `dk1`, `text_secondary` → `dk2`, `primary` → `accent1`, `accent` → `accent2`, `secondary_accent` → `accent3`, and `border` → `accent4`. The first two additional non-black/non-white roles become `accent5` / `accent6`; remaining colors stay fixed. Mapping is usage-aware, so a background HEX is not automatically reused for inverse text. > > **`image_rendering` and `image_palette`** — required only when `images` section below contains `ai`-sourced files. Values MUST be valid names from `references/image-renderings/_index.md` and `references/image-palettes/_index.md`, **or** the literal string `custom`. Image_Generator reads these and applies them deck-wide. Omit both rows when the deck has no AI-generated images. > @@ -75,9 +75,9 @@ > > **Size slots are anchors, not a closed menu.** Common slots (`title` / `subtitle` / `annotation`) cover frequent cases. Add role-specific slots (e.g. `cover_title: 88`, `hero_number: 56`, `subheading: 32`, `lead: 30`, `footnote: 16`, `chart_annotation: 16`) for the roles the deck actually uses — common for cover-heavy decks, consulting-style hero numbers, dense pages. **Mandatory — scan `§IX` and declare a slot for every role that recurs across pages, not just the four defaults.** A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** and **page numbers / source credits / footnotes** → declare `lead` and `footnote` for them. `subheading` and `lead` sit between `subtitle` and `body` (their bands overlap `subtitle`) — pick by role, not size — and the core-message `lead` is a **primary** line, **always ≥ `body`**, never smaller. Leaving a recurring lead / footnote undeclared forces the Executor to improvise an unlocked size (and a core line improvised below `body` inverts the hierarchy). **Structural roles (title / body / subtitle / annotation / footnote) render at their locked size on every page — one role, one size, deck-wide.** Intermediate in-band sizes are for special / feature elements only (hero number, display title, one-off emphasis); declare a recurring one as its own slot so it stays consistent too. > -> **Template Master defaults**: `pptx_structure.mode: template` requires both unitless `title` and `body`. Native export writes `title` into every Master `titleStyle` default and `body` into every `bodyStyle` / `otherStyle` default, changing only `a:defRPr@sz`. Direct page-run sizes and role-specific Layout placeholder prototype sizes remain unchanged. Other structure modes do not rewrite Master text-style sizes. +> **Structured Master defaults**: `pptx_structure.mode: structured` requires unitless `title` and `body`. Native export writes `title` into every Master `titleStyle` default and `body` into every `bodyStyle` / `otherStyle` default, changing only `a:defRPr@sz`. Direct page-run sizes and role-specific Layout placeholder prototype sizes remain unchanged. > -> **⚠️ PPT-safe stack discipline (HARD rule).** Native `baseline` / `template` export maps `title_family` to the PowerPoint theme major font and `body_family` (or `font_family`) to the theme minor font. Runs whose resolved face matches either role use `+mj-*` / `+mn-*` theme tokens; other role families remain concrete per-run typefaces. Every exported Latin / EA face MUST therefore resolve to cross-platform pre-installed fonts: `"Microsoft YaHei"` / `SimSun` / `Arial` / `"Times New Roman"` / `Consolas`. Stacks that resolve to non-preinstalled typefaces (Inter / Google Fonts / brand typefaces) may be used only when the Design Spec notes the font-install or embedding requirement. `preserve` keeps the source template theme; `flat` keeps concrete fonts for diagnostics. +> **⚠️ PPT-safe stack discipline (HARD rule).** Structured export maps `title_family` to the PowerPoint theme major font and `body_family` (or `font_family`) to the theme minor font. Runs whose resolved face matches either role use `+mj-*` / `+mn-*` theme tokens; other role families remain concrete per-run typefaces. Every exported Latin / EA face MUST therefore resolve to cross-platform pre-installed fonts: `"Microsoft YaHei"` / `SimSun` / `Arial` / `"Times New Roman"` / `Consolas`. Stacks that resolve to non-preinstalled typefaces (Inter / Google Fonts / brand typefaces) may be used only when the Design Spec notes the font-install or embedding requirement. > > **Stack length discipline.** 3-4 fonts per stack is the sweet spot. Converter only writes the **first** Latin and **first** CJK font into PPTX — everything after is silently dropped. macOS-only families (`Songti SC`, `Menlo`, `Monaco`, `Helvetica`) are auto-mapped to Windows equivalents via `FONT_FALLBACK_WIN` (see `scripts/svg_to_pptx/drawingml/utils.py`); stacking both is redundant. Lead with Windows-preinstalled fonts (`Microsoft YaHei` / `SimSun` / `Arial` / `Georgia` / `Consolas`); keep at most **one** macOS-exclusive family (typically `"PingFang SC"`) as a browser-preview nicety. @@ -125,58 +125,60 @@ > **Missing or empty section** → Executor falls back to `dense` for every page (legacy pre-rhythm behavior). Remove the section only for legacy decks; new decks MUST fill it. ## pptx_structure -- mode: baseline +- mode: structured -> One deck-wide native PowerPoint structure policy. New projects always include this section. +> One deck-wide native PowerPoint structure policy. New projects always use `structured`; Master/Layout structure exists before the first SVG is drawn. > > When Step 3 loaded a deck/layout template, add exactly one of: > ``` > - template_adherence: adaptive > - template_adherence: strict > ``` -> Omit the row for free design and brand-only templates. Both values require one `page_layouts` and one `pptx_layouts` row per page. `strict` keeps the selected Layout contract; `adaptive` may create a new explicit Layout under the same template Master. +> Omit the row for free design and brand-only templates. Both values require one `page_layouts` row per page. Existing legacy template SVGs that lack the current root Master identity, grouped slot/carrier contract, or positive bounds must run [`restore-pptx-structure`](../workflows/restore-pptx-structure.md) before they can be selected. > -> - `baseline` — default for free design and brand-only routes. Preserve conservative shared Master/background/chrome behavior, then assign Cover/Agenda/Section/Closing/Content from root `data-pptx-page-role`. It may also promote exact family-wide leading structurally marked chrome into a Layout. Marker-free legacy SVGs retain filename/id fallback. Actual content stays slide-local; no placeholders or visual-similarity inference are authored. -> - `template` — required whenever Step 3 loaded a deck/layout template. Requires complete `page_layouts` and `pptx_layouts` sections plus explicit SVG structure metadata on every generated page. -> - `preserve` — legacy strict-only compatibility for an existing project that already ships `native_structure.json` + `source_template.pptx`. Do not select it for newly created templates. -> - `flat` — diagnostic escape hatch. Do not lock this in a normal project; pass it on the CLI when comparing slide-local output. -> -> Preserve example: +> Template example: > ``` -> - mode: preserve -> - template_adherence: strict -> - source_template: templates/source_template.pptx -> - native_structure: templates/native_structure.json +> - mode: structured +> - template_adherence: adaptive > ``` +## pptx_masters +- master-default: Default Master + +> One row per Master: `: `. Free design defaults to one Master. Add another only when the design genuinely needs it or the source PPTX carries multiple Masters. Keys are deck-unique and stable. + ## pptx_layouts +- P01: master-default | cover-hero-split | Cover — Hero Split +- P02: master-default | kpi-band-trio | KPI Band Trio +- P03: master-default | content-two-column | Two Column +- P04: master-default | content-two-column | Two Column -> Emit this section only when `pptx_structure.mode` is `template` or legacy `preserve`. Include exactly one row per generated page. Value format: ` | `. Strict template use copies the selected SVG key/name; adaptive use may declare a new stable key/name. Preserve uses the legacy native contract. +> Include exactly one row per page before SVG generation. Value format: ` | | `. > -> Example: -> ``` -> - P01: cover | Cover -> - P02: title-content | Title and Content -> - P03: title-content | Title and Content -> - P04: section | Section Header -> ``` +> The page SVG repeats all three identities on its root through `data-pptx-master`, `data-pptx-master-name`, `data-pptx-layout`, and `data-pptx-layout-name`. A Layout key belongs to exactly one Master and must be globally unique even when two source Masters use the same picker name. > -> Reuse one key only when pages share the same static layout layer and placeholder contract. Do not generate one unique key per slide merely because every page has different content. A one-off cover/section layout is valid; content differences stay slide-local. +> Strategist plans the initial family roster. Executor writes the declared structure while drawing each page. If adaptive template use genuinely changes the reusable framing or slot topology/bounds, create a new Layout key and update this mapping immediately; never silently mutate a reused key. +> +> Reuse one `(master_key, layout_key)` only when its ordered Layout atoms and slot ids/types/indices/bounds/binding modes are identical. Current text, imagery, crop, or Slide-local geometry does not define Layout identity. +> +> **Different reusable composition → different key.** Name keys after the composition (`timeline-spine`, `kpi-band-trio`), never after PowerPoint stock roles or page topics. Distinct compositions collapsing into stock-role keys (`title-content` for eight different pages) and one shared skeleton splitting into per-topic keys (`allocation-fengqing` / `allocation-luoping` over identical framing) both produce Layout rosters that do not match the deck; the quality checker flags the second as duplicate Layouts. +> +> A Layout may have zero slots. Do not create an empty `utility` kind or a full-page fake `object` slot; the named Layout and its fixed atoms are sufficient. ## page_layouts - P01: 01_cover - P03: 02a_chapter - P04: 03a_content_abstract -> For a deck/layout template route, include one entry per page. Key: `P` matching §IX. Value: the template SVG basename without extension. Strict inherits it unchanged; adaptive uses it as the architecture reference and may output a new `pptx_layouts` key. +> For a deck/layout template route, include one entry per page. Key: `P` matching §IX. Value: the template SVG basename without extension. This is the input prototype mapping; `pptx_layouts` is the output mapping and is present at the same time. Strict preserves the prototype Master/Layout/slot contract. Adaptive retains its Master contract and may explicitly assign a new Layout key while authoring. Non-mirror skin follows the project lock. > > **No entry for a page** is valid only when the entire route is free design or brand-only. It is an error in template mode. > -> **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible shell. Adaptive mode may start from a neutral content template and create a new explicit Layout; strict mode must choose an existing compatible Layout or revise the outline. +> **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible shell. Adaptive mode may start from a neutral content template and finalize a new explicit Layout after design; strict mode must choose an existing compatible Layout or revise the outline. > -> **Whole section omitted** → free design or brand-only route. +> **Whole section omitted** → free design or brand-only route. Template strict/adaptive routes require complete `page_layouts`. A legacy package that cannot satisfy the current structured contract must migrate before use; it never enters a compatibility branch inside normal generation. > -> **Strategist source**: copy the per-page SVG choices from `design_spec.md §VI Page Roster` (or §IX outline if Roster is absent). Names must match files in `templates/` exactly — typos cause silent fallback to free design. +> **Strategist source**: record each project-page choice in project `design_spec.md §IX Content Outline`, using the copied template package's `templates/design_spec.md §V Page Roster` descriptions as the roster authority. Basenames must match files in `templates/` exactly. A typo is a blocking contract error: stop before drawing and report it; never fall back to free design inside template mode. ## page_charts - P05: column_chart diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-brand.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-brand.md index 8d86b90c..7e92864d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-brand.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-brand.md @@ -1,10 +1,10 @@ --- -description: Generate a brand-only template under `templates/brands//` — a design_spec.md that captures the user's colors / typography / logo / voice / icon style without an SVG page roster, so subsequent PPT projects inherit brand identity while keeping page layout free. +description: Generate a brand-only workspace under `templates/brands//` that captures colors, typography, logos, voice, and icon style without an SVG page roster. --- # Create Brand Workflow -> Standalone preset-creation workflow. Output is a brand template package at `skills/ppt-master/templates/brands//`. Structurally a brand is a template minus the SVG page roster — Strategist locks the brand's color / typography / logo / voice as truth; Executor designs pages freely under those constraints. +> Standalone preset-creation workflow. Output is a complete brand workspace at `skills/ppt-master/templates/brands//`. A brand keeps the common workspace shape but omits the SVG page roster: Strategist locks identity while Executor designs pages freely. This workflow edits the global brand library, not any specific `projects//`. Consumption follows the same explicit-path rule as layout templates (see [Downstream consumption](#downstream-consumption-informational) at the end). @@ -14,7 +14,7 @@ This workflow edits the global brand library, not any specific `projects//`. | User signal | Action | |---|---| -| "set up brand" / "extract brand from this logo" / 建立品牌 / 做品牌规范 | Run this workflow | +| "set up brand" / "extract brand from this logo" | Run this workflow | | User provides a brand asset (logo / brand site URL / branded PPTX / brand PDF) and wants it locked across future projects | Run this workflow | | User mentions brand color or font once for a single deck only | Skip — handle inline via Strategist h.5 | | `templates/brands//` already exists | Ask: update / replace / use a new id — never silently overwrite | @@ -66,7 +66,7 @@ Whatever the user gives is `[user]`-labelled. Skip Step 3 unless the user explic ## Step 2C: Empty skeleton -Write `templates/brands//design_spec.md` with the full schema, every value as a TODO comment. Tell the user where the file is. No further prompting — the user owns it from here. +Write `templates/brands//templates/design_spec.md` with the full schema, every value as a TODO comment. Tell the user where the file is. No further prompting — the user owns it from here. --- @@ -85,15 +85,15 @@ For fields not covered by the asset, ask the user in a single bundled message. S --- -## Step 4: Materialize the brand package +## Step 4: Materialize the brand workspace -Create the package directory: +Create the required template source directory. Create `images/`, `icons/`, or `exports/` only when writing a real asset or derived artifact; never add placeholder files for empty directories. ```bash -mkdir -p "skills/ppt-master/templates/brands/" +mkdir -p "skills/ppt-master/templates/brands//templates" ``` -### Mandatory: `design_spec.md` +### Mandatory: `templates/design_spec.md` ```markdown --- @@ -132,7 +132,7 @@ primary_color: "#XXXXXX" | mono | | optional | ## IV. Logo -- File: `./logo.` (relative to this design_spec.md) +- File: `../images/logo.` (relative to this design_spec.md) - Usage: cover-only \| every-page \| never ## V. Voice & Tone @@ -145,28 +145,27 @@ primary_color: "#XXXXXX" - Preference: linear \| filled \| duotone # optional, drives icon library search ## VII. Visual Assets (optional) -- Images: `./images/` # branded photos, prioritised before AI image generation -- Illustrations: `./illustrations/` # branded illustrations, prioritised before AI generation -- Icons: `./icons/` # branded icon overrides; falls back to `templates/icons/` +- Images and illustrations: `../images/` # brand visual assets +- Icons: `../icons/` # branded icon overrides ``` **Section scope rules**: -- Layout / canvas / spacing / radius / shadow / page roster / signature design elements are OUT of brand scope. Those live in layout / deck templates (`templates/layouts//design_spec.md` or `templates/decks//design_spec.md`) or `shared-standards.md`. Do NOT add those sections here. +- Layout / canvas / spacing / radius / shadow / page roster / signature design elements are OUT of brand scope. Those live in layout / deck workspaces (`templates/layouts//templates/design_spec.md` or `templates/decks//templates/design_spec.md`) or `shared-standards.md`. Do NOT add those sections here. - HEX must be `#RRGGBB` - Font names are free strings; not validated against locally installed fonts - §VII is fully optional — list only directories that actually exist ### Optional: logo file -If the user provided a logo, copy it to `templates/brands//logo.` (preserve source extension). +If the user provided a logo, copy it to `templates/brands//images/logo.` and preserve its source extension. Use descriptive filenames when several lockups exist. ### Optional: visual asset directories -If the user provided brand photo / illustration / icon folders, copy them as `images/` / `illustrations/` / `icons/` under `templates/brands//`. Reference each in §VII of `design_spec.md`. Skip the entire section if none exist. +Copy brand photos and illustrations into the workspace `images/` directory. Copy branded icon overrides into `icons/`. Reference only directories and assets that actually exist in §VII of `templates/design_spec.md`. ### NOT created here -No SVG page roster. No canvas spec. No signature design elements. If the user later wants brand-anchored page templates, run `create-template.md` with this brand as a style reference. +No SVG page roster, canvas spec, signature design elements, or preview PPTX. Do not create an empty `exports/` directory. If the user later wants brand-anchored page templates, run `create-template.md` with this brand as a style reference. --- @@ -189,16 +188,16 @@ Emit the confirmation card: ```markdown ## ✅ Brand Saved - Path: `skills/ppt-master/templates/brands//` -- Files: design_spec.md{, logo.}{, images/}{, illustrations/}{, icons/} +- Files: templates/design_spec.md{, images/logo.}{, images/*}{, icons/*} - Fields locked: - Provenance: How to use in a project: -- Include the brand directory path in your initial Step 3 input — e.g. "做一个 Q4 总结 PPT, 用 skills/ppt-master/templates/brands// 这个品牌" +- Include the brand workspace path in the initial Step 3 input, for example: "Create a Q4 summary using skills/ppt-master/templates/brands//" - Same explicit-path rule as layout templates: bare brand names never trigger - May be supplied together with a layout template path; Step 3 fuses both into a single `design_spec.md` (brand wins on identity tokens, layout wins on page structure) — see `SKILL.md` Step 3 - To list available brands: open `templates/brands/brands_index.json` -- To edit: modify `templates/brands//design_spec.md` directly, then re-run `python3 skills/ppt-master/scripts/register_template.py --kind brand ` +- To edit: modify `templates/brands//templates/design_spec.md`, then re-run `python3 skills/ppt-master/scripts/register_template.py --kind brand ` ``` --- @@ -209,7 +208,7 @@ Brand application happens in [`SKILL.md` Step 3](../SKILL.md) under the **same e | User input at SKILL.md Step 3 | Behavior | |---|---| -| Explicit brand directory path supplied | Step 3 copies the brand into `/templates/` (same target as a layout template); Strategist locks color / typography / logo / voice as truth at Step 4 | +| Explicit brand workspace path supplied | Step 3 resolves `templates/design_spec.md`, stages `templates/`, `images/`, and `icons/` into their project peers, and locks color / typography / logo / voice as truth at Step 4 | | Bare brand name, brand mention without path, or silence | Skip — no auto-application based on directory count or any other implicit signal | | Both a brand path and a layout template path supplied in the same message | Step 3 fuses both into a single `design_spec.md` inside `/templates/` (brand wins on identity tokens, layout wins on page structure). See `SKILL.md` Step 3 for the field-precedence table and the two conflict gates that may surface a clarifying question | @@ -220,7 +219,7 @@ Brand application happens in [`SKILL.md` Step 3](../SKILL.md) under the **same e ## Notes 1. **Brand is identity, not layout** — colors / typography / logo / voice / icon style only. Page roster, canvas spec, and signature design elements belong to layout templates; do not duplicate them here. -2. **Self-contained package** — all brand assets (logo, images, illustrations, icons) live inside `templates/brands//`. Nothing leaks to workspace root or `projects/`. +2. **Common workspace routing** — brand assets use the same `templates/`, `images/`, `icons/`, and `exports/` routes as other template kinds; empty optional directories are omitted. 3. **No script dependency** — Step 2A reuses existing converters plus AI inline reading. A dedicated `brand_extract.py` is not introduced unless future user feedback demands batch processing or precise color picking from raster logos. 4. **Multi-brand support** — `templates/brands/` accepts any number of brands; agency / freelancer / multi-client workflows are natural. 5. **Precedence rule** — when a brand and a layout template both apply, Step 3 fuses them into one `design_spec.md`: brand wins on color / typography / logo / voice / icon style; layout wins on canvas / page roster / spacing / font-size hierarchy / signature visual elements. See `SKILL.md` Step 3 for the full precedence table. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-template.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-template.md index 31051945..9240fd83 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-template.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/create-template.md @@ -6,13 +6,15 @@ description: Generate a new layout or deck template based on existing project fi > **Role invoked**: [Template_Designer](../references/template-designer.md) -Generate a complete layout/deck template package for either the **global template library** or one initialized project. +Generate one complete layout/deck template workspace under either the **global template library** or `projects/`. -**Default — library scope**: Preserve the existing reusable-library behavior. Write `skills/ppt-master/templates///` and register it in the matching discovery index. +**Default — library scope**: Write `skills/ppt-master/templates///` and register it in the matching discovery index. -**Project scope**: Write a thin template bundle directly into `/templates/` for the named project. Do not add a nested template-ID directory and do not register any global index. +**Project scope**: Write the same portable workspace routing at `/` and do not register any global index. -> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable template contract at the selected scope. To generate a deck from a library package, return its directory path to the main SKILL.md pipeline. A project-scoped package is already installed at that project's Step 3 path and is consumed in place. +**Hard rule — one workspace routing contract**: Output scope changes only the workspace parent and index registration. Both scopes use required `templates/`, optional `images/` / `icons/`, and optional on-demand `exports/`, with the same relative asset references and validation command. Never add placeholder files only to retain an empty directory. Do not maintain a library-only self-contained-flat package branch or a project-only thin-bundle branch. + +> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable template contract; an optional PPTX may be generated later for local review. To generate a deck from the workspace, return its root path to the main SKILL.md pipeline. A project-scoped workspace is already installed at that project's Step 3 path and is consumed in place. > **Companion workflow**: identity-only locking (colors / typography / logo / voice without SVG pages) is handled by [`create-brand.md`](./create-brand.md). Use that when the user wants brand identity but free page layout; use this when fixed page structures are required. @@ -22,8 +24,8 @@ This workflow produces one of two kinds of templates depending on whether the so | Kind | When | Library-scope output dir | What `design_spec.md` writes | |---|---|---|---| -| **deck** (default) | Source is a specific organization's branded PPT (e.g. company report, university defense template); the visual identity is part of the replica | `templates/decks//` | Full segments: identity + structure + middle | -| **layout** | Source is a generic stylistic template (no specific brand); only the structural skeleton should be reusable; color / typography decided per-deck downstream | `templates/layouts//` | Structure segments only (canvas / page structure / page types / SVG roster); identity segment omitted | +| **deck** (default) | Source is a specific organization's branded PPT (e.g. company report, university defense template); the visual identity is part of the replica | `templates/decks//templates/` | Full segments: identity + structure + middle | +| **layout** | Source is a generic stylistic template (no specific brand); only the structural skeleton should be reusable; color / typography decided per-deck downstream | `templates/layouts//templates/` | Structure segments only (canvas / page structure / page types / SVG roster); identity segment omitted | Default to **deck** unless the user explicitly says "structure only" / "layout only" / "no brand identity". When in doubt, lean deck — losing identity later is easy; reconstructing it from a layout-mode strip is not. See [`docs/zh/templates-architecture.md`](../../../docs/zh/templates-architecture.md) for the full kind / schema / fusion model. @@ -31,23 +33,35 @@ Default to **deck** unless the user explicitly says "structure only" / "layout o Output scope is a workflow execution choice, not a new template kind or PPTX structure mode. Surface it in the Step 2 brief; do not invent a CLI flag or persist `output_scope` / `target_project` into portable `design_spec.md` frontmatter. -| Scope | Final output | Asset routing | Registration | +| Scope | `` | Template source | Registration | |---|---|---|---| -| `library` (default) | `skills/ppt-master/templates///` | Keep the existing self-contained package: SVG/spec/assets under the package, including `icons/` when present | Run `register_template.py` against the matching global index | -| `project` | `/templates/` **root** | `design_spec.md`, template SVGs, and non-bitmap package assets → `templates/`; bitmaps → `images/` and SVG references → `../images/`; extracted icon assets → both `templates/icons/` (package/validation copy) and `icons/` (runtime copy) | Do not update any global index; bundle belongs only to this target project | +| `library` (default) | `skills/ppt-master/templates///` | `/templates/` | Run `register_template.py` against the matching global index | +| `project` | `/` | `/templates/` | Do not update any global index | -For `project`, `target_project` is required and must be an existing project initialized by `project_manager.py init`. Before the first final-output write, run one complete preflight: +Both scopes write this contract: -1. Confirm the target project and its `templates/`, `images/`, and `icons/` directories exist. -2. Confirm `/templates/` is empty. -3. Resolve every final bitmap and extracted-icon filename, then confirm none would overwrite an existing file in `/images/` or `/icons/`. +```text +/ +├── templates/ # design_spec.md, template SVGs, templates/icons/ when used +├── images/ # optional; every bitmap; SVG href is ../images/ +├── icons/ # optional; runtime copy of extracted vector assets +└── exports/ # optional; on-demand _template_preview.pptx +``` -Any failed check aborts before writing `design_spec.md`, SVGs, images, or icons. Do not merge into a non-empty template root and do not overwrite a name conflict. Temporary Step 1 analysis workspaces remain allowed because they are not final project outputs. +The review PPTX is derived evidence, not a source template asset. Do not create `exports/` unless a review deck is requested. Template application reads `templates/` plus any existing `images/` and `icons/`; it never copies or consumes `exports/`. Library `exports/` directories are Git-ignored. + +For `project`, `target_project` is required and must be an existing project initialized by `project_manager.py init`. Before the first final-output write, run one complete preflight. Apply the same collision checks to a library workspace; the only difference is that its root is under the global kind directory: + +1. Resolve `` from the confirmed scope and confirm its required `templates/` destination plus any needed `images/` / `icons/` destinations. +2. Confirm `/templates/` is empty. +3. Resolve every final bitmap and extracted-icon filename, then confirm none would overwrite an existing file in `images/`, `icons/`, or `templates/icons/`. Check the review-PPTX destination only when preview export was requested. + +Any failed check aborts before writing `design_spec.md`, SVGs, images, icons, or the review PPTX. Do not merge into a non-empty template source and do not overwrite a name conflict. Temporary Step 1 analysis workspaces remain allowed because they are not final outputs. ## Process Overview ``` -Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Preflight + Invoke Template_Designer -> Validate Assets -> [Register Library Index] -> Output +Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Preflight + Invoke Template_Designer -> Validate Assets -> [Optional Review PPTX] -> [Register Library Index] -> Output ``` The first three steps derive the brief from facts, not guesses. **No final template directory may be created and no template SVG / `design_spec.md` may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates produced by `pptx_template_import.py` (typically under `/tmp/pptx_template_import/`) are explicitly **not** subject to this gate — they are temporary workspaces feeding Step 2. @@ -63,16 +77,22 @@ Branch by the type of reference source the user supplied. This step produces ana | Type | What the user supplied | Tool / read path | Replication modes available | |------|-------------------------|------------------|------------------------------| | **A** `.pptx` reference | A `.pptx` file path | `pptx_template_import.py` → `manifest.json` + `native_structure.json` + `source_template.pptx` + layered/flat SVGs + `assets/` | `standard` / `fidelity` / `mirror` | -| **B** Existing SVG assets | `projects//svg_output/`, `templates/layouts/`, or a loose `.svg` folder | `ls` + `Read` each `*.svg`; plus `design_spec.md` / `spec_lock.md` if present | `standard` / `fidelity` (AI visual clustering) / `mirror` (direct 1:1 copy) | +| **B** Existing SVG assets | `projects//svg_output/`, a current template workspace root, a legacy flat template root, or a loose `.svg` folder | Normalize the source directory, create a lightweight projection with `svg_authoring_view.py`, then read projected page SVGs; also read companion `design_spec.md` / `spec_lock.md` when present | `standard` / `fidelity`; `mirror` only when the source already carries a complete explicit Master/Layout/placeholder/native-object contract | | **C** Image / visual references | Screenshot folder, single image, PDF pages | `ls` + `Read` each file (multimodal visual recognition) | `standard` only | | **D** No reference source | Verbal description only ("McKinsey style", "tech blue", "dark minimal") | — | `standard` only | -`fidelity` and `mirror` are not available for type C / D — visual references and verbal-only briefs cannot drive page-by-page replication. Type A is the canonical path: `manifest.json` page-type candidates and the layered `svg/` workspace anchor cluster detection (fidelity) and verbatim copy (mirror) with factual data. Type B is supported with caveats: +`fidelity` and `mirror` are not available for type C / D — visual references and verbal-only briefs cannot drive page-by-page replication. Type A is the canonical mirror path: `manifest.json`, `native_structure.json`, layered lossless `svg/`, complete-page lossless `svg-flat/`, and inheritance facts are the restoration authority. In `standard` / `fidelity`, those files supply factual canvas/asset/visual references only and do not define output topology. -- **mirror on type B** — direct 1:1 copy. B's SVGs are already self-contained (one file per page, equivalent to `svg-flat/slide_*.svg`). Page-type for the `_.svg` filename is read from the source filename when it follows the PPT Master naming convention (`01_cover.svg` → `cover`, `03a_content_two_col.svg` → `content`); fall back to `content` otherwise. Particularly natural when the source is `templates/layouts/` and the user wants to fork an existing template. -- **fidelity on type B** — clustering relies on the AI's visual judgement of the SVGs; there is no `manifest.json.pageTypeCandidates` to anchor it. Variant count and grouping are more subjective and may need iteration. If the input is already a PPT Master template (`templates/layouts/`), parse the existing variant filenames (`03a_content_two_col` etc.) as authoritative cluster hints rather than re-clustering visually. +**Type B source normalization**: when the supplied root contains `templates/design_spec.md`, use `