Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-10T16:01:26Z"
|
||||
"syncedAt": "2026-07-11T16:01:50Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "46681d90f0fd92bb22fbf38b43b507faad47a812",
|
||||
"commit": "1bd2fd585aac793ca2589e6f18f17a412fd11005",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-10T16:00:00Z"
|
||||
"syncedAt": "2026-07-11T15:59:59Z"
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ English | [中文](./README_CN.md)
|
||||
|
||||
---
|
||||
|
||||
Drop in your source material, and the deck you get back is **more than just editable**: it has native slide transitions and entrance animations, speaker notes that can become audio narration, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — a complete deck you can present as-is and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
|
||||
Drop in your source material, and the deck you get back is **more than just editable**: it has native slide transitions plus opt-in entrance animations (off by default), speaker notes that can become audio narration, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — a complete deck you can present as-is and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
|
||||
|
||||
## Product Positioning
|
||||
|
||||
@@ -306,7 +306,7 @@ AI: Sure. Let's confirm the design spec:
|
||||
|
||||
The AI handles everything — content analysis, visual design, SVG generation, and PPTX export.
|
||||
|
||||
> **Output:** Native-shapes `.pptx` (directly editable) saved to `exports/<name>_<timestamp>.pptx`. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. Pass `--svg-snapshot` to additionally emit an SVG-image preview pptx alongside the native pptx in `exports/` (see [FAQ](./docs/faq.md)). Requires Office 2016+. By default charts and tables export as SVG-derived shapes (pixel-consistent across PowerPoint / Keynote / WPS); pass `--native-objects` to instead emit them as **real editable PowerPoint chart / table objects backed by data** (rendering may vary across apps), saved as `exports/<name>_<timestamp>_native_charts.pptx`.
|
||||
> **Output:** The SVG pipeline has one PPTX export route: PPT Master's converter reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. Step 7 still always runs `finalize_svg.py`, producing self-contained files in `svg_final/` for visual inspection or manual insertion as SVG pictures; PowerPoint's manual **Convert to Shape** command is outside the supported contract. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. By default charts and tables export as SVG-derived shapes (pixel-consistent across PowerPoint / Keynote / WPS); pass `--native-objects` to instead emit them as **real editable PowerPoint chart / table objects backed by data** (rendering may vary across apps), saved as `exports/<name>_<timestamp>_native_charts.pptx`.
|
||||
|
||||
> **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md).
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "3f3f2418163ccb438d010f3acbce4a3651b758a5",
|
||||
"commit": "dbf1e363abb4dba36f7c883ee7ba1495073394d0",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-07-10T16:00:00Z"
|
||||
"syncedAt": "2026-07-11T15:59:59Z"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
|
||||
| Any route that authors or regenerates slide visuals through SVG | `svg_output/` is the complete page-design source: every visible text, image, shape, chart/table fallback, and layout element that should appear on the exported slide is present in that page SVG or referenced by it. |
|
||||
| Templates, `design_spec.md`, and `spec_lock.md` | Authoring/control inputs. They guide SVG creation but MUST NOT supply visible slide content that is absent from the completed SVG during export. |
|
||||
| Semantic SVG markers | Minimal rendering-neutral compiler hints used only after existing Layout/Layer/Placeholder/Native metadata has been considered. They never replace native SVG geometry, text, styles, grouping, or asset references. |
|
||||
| SVG-to-PPTX export | Translation only: map SVG content to DrawingML/native objects and reorganize represented content into Master/Layout/Slide structure without inventing new visible page content. |
|
||||
| `svg_final/` | Mandatory derived, self-contained SVG visual preview. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. |
|
||||
| SVG-to-PPTX export | The only supported generated-PPTX route reads `svg_output/` and maps its content through the project converter to DrawingML/native objects. It may reorganize represented content into Master/Layout/Slide structure but MUST NOT invent new visible page content. |
|
||||
| Direct PPTX and presentation-behavior workflows | Remain separate. `template-fill-pptx`, `native-enhance-pptx`, animations, transitions, speaker notes, narration, and package relationships are not required to round-trip through SVG. |
|
||||
|
||||
**MUST — page-design closure**: For an SVG-authoring route, inspect the final page SVG to determine what the exported slide looks like. Do not reinterpret “SVG is the page-design language” as “SVG is the complete PPTX package description language.”
|
||||
@@ -78,7 +79,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `${SKILL_DIR}/scripts/source_to_md.py` | Unified source-to-Markdown dispatcher — default Step 1 entry for explicit file(s) or URL(s) |
|
||||
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data |
|
||||
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data / SmartArt structure |
|
||||
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
|
||||
| `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) |
|
||||
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
|
||||
@@ -197,7 +198,7 @@ For PPTX sources, `import-sources` automatically runs the standard intake enrich
|
||||
python3 ${SKILL_DIR}/scripts/pptx_intake.py <project_path>/sources/<source.pptx> -o <project_path>/analysis
|
||||
```
|
||||
|
||||
For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, observed usage) and `<stem>.slide_library.json` (text slots, geometry, native tables, native chart caches), and merges that deck's Strategist-facing digest into the single multi-deck index `analysis/source_profile.json` (`decks[]`, one self-contained entry per source deck, with prefixed artifact pointers). In the main generation path these are source facts and recommendation candidates, not replica constraints; beautify and template-fill workflows decide separately which fields become locked constraints.
|
||||
For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, observed usage) and `<stem>.slide_library.json` (text slots, geometry, native tables, native chart caches, SmartArt nodes/connections), and merges that deck's Strategist-facing digest into the single multi-deck index `analysis/source_profile.json` (`decks[]`, one self-contained entry per source deck, with prefixed artifact pointers). In the main generation path these are source facts and recommendation candidates, not replica constraints; beautify and template-fill workflows decide separately which fields become locked constraints.
|
||||
|
||||
Multi-deck: several PPTX files may be imported into one main-pipeline project — each gets its own `<stem>.*` artifacts and a deck entry in `source_profile.json`. `source_profile.json` stays the single must-read index (one entry for a one-deck project, several for a combined-source project). Stems must be distinct; re-importing the same stem replaces that deck's entry. The beautify / template-fill workflows remain single-deck (1:1 to one chosen source deck) and read that deck's `<stem>.*` artifacts.
|
||||
|
||||
@@ -217,11 +218,12 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project
|
||||
|
||||
Do **not** reinterpret this boundary as 1:1 redesign or free SVG generation. Use `template-fill` for raw PPTX template + generated PPTX requests; use `beautify` only when the source deck's page count, order, and wording are preserved.
|
||||
|
||||
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message. The trigger rule is mechanical, not interpretive:
|
||||
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message, plus one narrow workflow handoff: a project-scoped `create-template` run in the current conversation may pass its exact validated `<project>/templates/` output directly into this Step. The trigger rule is mechanical, not interpretive:
|
||||
|
||||
| User input contains | Step 3 action |
|
||||
|---|---|
|
||||
| One or more explicit template directory paths (each resolves to a directory containing `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck` in its YAML frontmatter) | Read each spec's `kind`, dispatch per the kind matrix below, fuse if multiple |
|
||||
| Current `create-template` workflow just completed project scope and validated its exact `<project>/templates/` output | Consume that single directory in place; it cannot join multi-path fusion |
|
||||
| Anything else — bare template names ("用 academic_defense"), style descriptions ("麦肯锡风格"), brand mentions ("招商银行风格"), vague intent ("想用个模板"), or silence | Skip Step 3, free design |
|
||||
|
||||
There is no slug matching, no name lookup, no fuzzy resolution. A name without a path does not trigger — the user must give a path the AI can `cd` into.
|
||||
@@ -261,16 +263,40 @@ The architecture has three independent reference bundles. Full schema in [`docs/
|
||||
| `kind: deck` | `design_spec.md` + template SVGs → `<project>/templates/`; logos / backgrounds / other **bitmaps** → `<project>/images/`. Strategist locks all segments; Strategist confirmation stage narrows to deck-content fields (audience / page count / outline / tone tweaks). |
|
||||
|
||||
```bash
|
||||
TEMPLATE_DIR=<user-supplied path>
|
||||
TEMPLATE_DIR="<user-supplied path>"
|
||||
PROJECT_TEMPLATES="<project_path>/templates"
|
||||
resolve_path() {
|
||||
python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$1"
|
||||
}
|
||||
# Bitmaps join the project's single runtime image pool (images/, referenced as
|
||||
# ../images/); the spec + template SVGs + other non-image assets stay in
|
||||
# templates/ as design reference the Strategist/Executor read but never render.
|
||||
cp -r ${TEMPLATE_DIR}/* <project_path>/templates/
|
||||
find <project_path>/templates -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.bmp' \) -exec mv {} <project_path>/images/ \;
|
||||
if [ "$(resolve_path "${TEMPLATE_DIR}")" = "$(resolve_path "${PROJECT_TEMPLATES}")" ]; then
|
||||
# Project-scoped create-template output is already staged in place.
|
||||
true
|
||||
else
|
||||
cp -r "${TEMPLATE_DIR}"/. "${PROJECT_TEMPLATES}/"
|
||||
find "${PROJECT_TEMPLATES}" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.bmp' \) -exec mv {} "<project_path>/images/" \;
|
||||
fi
|
||||
```
|
||||
|
||||
The same split applies to all three kinds — bitmaps always land in `images/`, the rest in `templates/`. The spec's `kind` field tells Strategist how to read the `templates/` side; downstream code doesn't distinguish. (Template SVGs in `templates/` are reference material only — the rendered pages live in `svg_output/` and reference images via `../images/`.)
|
||||
|
||||
When `create-template` used project output scope, its directory is already the
|
||||
target project's `templates/` root and its bitmap/icon runtime copies are already
|
||||
in their final project pools. Resolve both paths before copying: equality means
|
||||
**in-place consumption**, so skip both the copy and bitmap move. An in-place
|
||||
directory is one complete bundle and cannot participate in multi-path fusion;
|
||||
use external library bundles for fusion. Never place the local bundle under a
|
||||
nested `templates/local_master/` directory because the confirmation and quality
|
||||
gates read the project `templates/` root.
|
||||
|
||||
A project-scoped bundle belongs only to its target project: its SVGs may refer
|
||||
to sibling `../images/` and runtime `../icons/` pools that are outside the
|
||||
template root. Do not copy another project's `templates/` root as an external
|
||||
bundle. For cross-project reuse, recreate/promote the design with
|
||||
`create-template` library scope so the package is self-contained.
|
||||
|
||||
Legacy template packages may ship `native_structure.json` + `source_template.pptx`; keep the pair together in `<project>/templates/` for backward-compatible `preserve` export. Current `create-template` output does not package this pair: it rebuilds complete, explicitly layered SVG templates instead. During fusion, never mix one legacy contract with another template's source package.
|
||||
|
||||
The Strategist confirmation stage decides whether the selected template is used `strict` or `adaptive`. Both use `pptx_structure.mode: template` for current templates and map every page to one reference SVG plus one output PowerPoint layout. `strict` keeps the referenced layout contract unchanged; `adaptive` may create a new layout key while keeping the template Master contract. Legacy `preserve` remains strict-only compatibility behavior.
|
||||
@@ -330,26 +356,26 @@ Single-path Step 3 does **not** add provenance (the source is self-evident from
|
||||
|
||||
The fused frontmatter `kind` describes the resulting bundle: `deck` when both identity and structure are present, `layout` when only structure is present, and `brand` when only identity is present. Keep this field accurate; the Strategist confirmation server uses it to show template adherence only for bundles that actually own page structure.
|
||||
|
||||
**✅ Checkpoint — Default path proceeds to Step 4 without user interaction. If the user supplied one or more explicit template paths, those have been dispatched (or fused) into `<project_path>/templates/` before advancing.**
|
||||
**✅ Checkpoint — Default path proceeds to Step 4 without user interaction. If the user supplied one or more explicit template paths, those have been copied, staged in place, or fused into `<project_path>/templates/` before advancing.**
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Strategist Phase (MANDATORY — cannot be skipped)
|
||||
|
||||
🚧 **GATE**: Step 3 complete; default free-design path taken, or (if triggered) template files copied into the project.
|
||||
🚧 **GATE**: Step 3 complete; default free-design path taken, or (if triggered) template files copied or confirmed in place in the project.
|
||||
|
||||
First, read the role definition:
|
||||
```
|
||||
Read references/strategist.md
|
||||
```
|
||||
|
||||
> ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full I–XI section structure. See `strategist.md` Section 1.
|
||||
> ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full I–X section structure. See `strategist.md` Section 1.
|
||||
|
||||
**Artifact ownership**: fact-channel and source/derived artifact boundaries are defined in [`references/artifact-ownership.md`](references/artifact-ownership.md). This Step uses those ownership rules; it does not redefine them.
|
||||
|
||||
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Strategist confirmation stage, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
|
||||
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Strategist confirmation stage, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table / SmartArt entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
|
||||
|
||||
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the content-type files in `sources/`** — primarily `<stem>.md`, but also any user-supplied content the import archived there: `.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml` (a `metrics.json` or `data.csv` may carry core content — judge by what the file holds). Text, tables, and chart data values come from these (`ppt_to_md` now transcribes native chart data into Markdown tables). **Do NOT read pipeline sidecars in `sources/` as content**: `*.conversion_profile.json` (conversion audit) and `*_files/image_manifest.json` (asset index) are process metadata — open them only to audit a conversion or resolve assets, never as slide content. Converted-source originals archived in `sources/` (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) are read via their converted `<stem>.md`, not scanned directly in the main pipeline. The `analysis/` chart / table entries are a **structural digest** for outline decisions (which slides carried charts, type, series names) — not a second copy of the values; do NOT also pull chart values from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract; beautify uses it for native chart / table data while keeping slide text from the Markdown.
|
||||
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the content-type files in `sources/`** — primarily `<stem>.md`, but also any user-supplied content the import archived there: `.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml` (a `metrics.json` or `data.csv` may carry core content — judge by what the file holds). Text, tables, chart data values, and SmartArt node wording come from these (`ppt_to_md` transcribes native charts as Markdown tables and SmartArt nodes as hierarchical bullets). **Do NOT read pipeline sidecars in `sources/` as content**: `*.conversion_profile.json` (conversion audit) and `*_files/image_manifest.json` (asset index) are process metadata — open them only to audit a conversion or resolve assets, never as slide content. Converted-source originals archived in `sources/` (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) are read via their converted `<stem>.md`, not scanned directly in the main pipeline. The `analysis/` chart / table / diagram entries are a **structural digest** for outline decisions (which slides carried charts, tables, or SmartArt; chart types / series names; SmartArt layout and hierarchy) — not a second copy of the content values; do NOT also pull chart values or SmartArt wording from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract while preserving SmartArt unchanged; beautify uses it for native chart / table data and SmartArt relationships while keeping all wording from the Markdown.
|
||||
|
||||
**Strategist confirmation stage** (full template: `templates/design_spec_reference.md`):
|
||||
|
||||
@@ -641,18 +667,19 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
|
||||
> ⚠️ Run the three sub-steps **one at a time** — each must complete successfully before the next.
|
||||
> ❌ **NEVER** combine them into a single code block or shell invocation.
|
||||
|
||||
Canonical three-command pipeline (mirrors `references/shared-standards.md` §5):
|
||||
Canonical three-command pipeline (this step is the workflow authority;
|
||||
`references/shared-standards.md` §5 points here):
|
||||
|
||||
**Step 7.1** — Split speaker notes:
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>
|
||||
```
|
||||
|
||||
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / raster image optimization / text flattening / rounded rect to path):
|
||||
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / raster image optimization / text flattening):
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
|
||||
```
|
||||
Default raster handling for `svg_final/`: images are embedded at the rendered SVG size budget (`--image-scale 2`, `--max-dimension 2560`), opaque PNG photos may be written as JPEG, and transparent assets remain PNG. Use `--no-compress` or a higher `--max-dimension` only for diagnostic / high-fidelity SVG snapshots.
|
||||
This mandatory step writes self-contained visual-preview SVGs to `svg_final/`. Those files may be opened directly or manually inserted into PowerPoint as SVG pictures. Default raster handling embeds images at the rendered SVG size budget (`--image-scale 2`, `--max-dimension 2560`); opaque PNG photos may be written as JPEG, and transparent assets remain PNG. The existing EMF/WMF exception still applies: Office vector assets stay externally referenced for lossless native-PPTX passthrough, so the native PPTX remains the source of truth for pages that use them. Use `--no-compress` or a higher `--max-dimension` only for diagnostic / high-fidelity SVG previews.
|
||||
|
||||
**Step 7.3** — Export PPTX (embeds speaker notes by default):
|
||||
```bash
|
||||
@@ -663,9 +690,6 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
# backup/<timestamp>/templates/{native_structure.json,source_template.pptx}
|
||||
# ← preserve mode only
|
||||
# backup/<timestamp>/spec_lock.md ← preserve mode only
|
||||
#
|
||||
# Add --svg-snapshot to additionally emit the SVG-image preview pptx alongside the native pptx:
|
||||
# exports/<project_name>_<timestamp>_svg.pptx ← SVG preview pptx (reads svg_final/)
|
||||
# Add --native-objects to emit real editable chart/table objects instead of flattened shapes:
|
||||
# exports/<project_name>_<timestamp>_native_charts.pptx ← native chart/table objects (data-pptx-native markers)
|
||||
# Re-export with --recorded-narration audio (generate-audio workflow) embeds per-slide narration:
|
||||
@@ -686,11 +710,19 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
> The `svg_output/`
|
||||
> snapshot in `backup/<timestamp>/` is always written so the project can be
|
||||
> re-exported from frozen SVG sources without re-running the LLM. The SVG-rendered
|
||||
> preview pptx is opt-in via `--svg-snapshot`. Preserve mode also backs up the
|
||||
> paired native structure/source files and `spec_lock.md` under the same timestamp.
|
||||
> Live preview already provides the
|
||||
> SVG visual reference, so it's only needed when you want a self-contained file
|
||||
> to share. Pass `-s output` or `-s final` to force a single source if you need it.
|
||||
> preview remains the mandatory `svg_final/` artifact from Step 7.2; it is not
|
||||
> packaged as a second PPTX. Preserve mode also backs up the paired native
|
||||
> structure/source files and `spec_lock.md` under the same timestamp. Use the
|
||||
> default source selection for release exports. `-s final` is diagnostic-only
|
||||
> when comparing conversion behavior against the post-processed SVGs; it does
|
||||
> not change `svg_output/` ownership or establish a supported release route.
|
||||
|
||||
> **Supported PPTX boundary** — the only supported generated-PPTX path is
|
||||
> `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The
|
||||
> project does not emit an SVG-image PPTX and does not support PowerPoint's
|
||||
> manual **Convert to Shape** operation on `svg_final/`. Inserted `svg_final/`
|
||||
> pages remain ordinary SVG pictures unless the user independently accepts the
|
||||
> results of an unsupported Office conversion.
|
||||
|
||||
> **PPTX structure mode** — native export first reads
|
||||
> `spec_lock.md` `pptx_structure.mode`, then falls back to `baseline` when the
|
||||
@@ -776,6 +808,21 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
> template use stay on this deterministic route. Adaptive pages may define new
|
||||
> layout keys, but they repeat the same Master layer and one internally
|
||||
> consistent Layout/placeholder contract.
|
||||
> Template export also installs the locked `typography.title` size into every
|
||||
> Master `titleStyle` level and the locked `typography.body` size into every
|
||||
> `bodyStyle` and `otherStyle` level. Each text-bearing Layout placeholder also
|
||||
> writes its prototype run size to `a:lstStyle/a:lvl1pPr/a:defRPr@sz`, so a
|
||||
> newly inserted or reset placeholder keeps that Layout's local size before it
|
||||
> falls back to the Master. Generated slide runs and Layout prompt runs keep
|
||||
> their direct sizes. Missing or invalid locked title/body sizes fail template
|
||||
> export. Baseline, preserve, and flat modes do not apply either text-default
|
||||
> update.
|
||||
> Before publishing the temporary PPTX, template export reopens the finished
|
||||
> package and verifies `Presentation → Master ↔ Layout ← Slide` registration,
|
||||
> named custom Layout parts and content types, collision-free placeholder
|
||||
> identities, prototype bounds, and prompt/level-one sizes. This read-back gate
|
||||
> is template-only and does not compare later same-Layout slides' local geometry
|
||||
> or direct text formatting.
|
||||
> In template mode, `pptx_layouts` contains exactly one locked
|
||||
> layout key/name per page; reuse a key for a shared structure instead of
|
||||
> creating one key per content instance.
|
||||
@@ -801,11 +848,26 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
> claiming editability.
|
||||
|
||||
**Optional animation flags** (page transitions are on by default; per-element entrance is off by default — turn it on only when the user asks for it):
|
||||
- `-t <effect>` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`.
|
||||
- `-t <effect>` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`. `none` removes only the visual transition; an explicit automatic advance remains valid.
|
||||
- `-a <effect>` — per-element entrance animation. **Default `none`** — pages appear as a whole, no auto-firing element builds (the unsolicited cascade reads as the "AI deck" tell). Opt in with `auto` (map effect from group id: chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade; image-like ids `hero` / `figure-` / `image` / `img-` / `kpi` cycle a richer pool — zoom / dissolve / circle / box / diamond / wheel — so multiple images vary across the deck), a specific effect like `fade`, or `mixed` for the legacy 16-effect cycle. Requires top-level `<g id="...">` groups (already required by Executor).
|
||||
- `--animation-trigger {on-click,with-previous,after-previous}` — Start mode (matches PowerPoint's animation-pane Start dropdown). Default `after-previous` (click-free cascade; pace via `--animation-stagger`). Use `on-click` for presenter-paced reveals, or `with-previous` for all-at-once.
|
||||
- `--animation-config <path>` — optional object-level sidecar. Default: `<project_path>/animations.json` when present.
|
||||
- `--auto-advance <seconds>` — kiosk-style auto-play.
|
||||
- `--auto-advance <seconds>` — kiosk-style auto-play. Click remains enabled, so click or timer may advance the slide.
|
||||
|
||||
**Animation compatibility gate**: the default element animation remains `none`.
|
||||
When animation is enabled, unknown effects/modes/triggers, invalid numeric or
|
||||
order values, missing slide/group references, and explicit structural layer,
|
||||
static-role, or static-placeholder targets fail export; they never downgrade
|
||||
or disappear silently. An explicit
|
||||
sidecar group may override only the legacy chrome-name heuristic. `random`
|
||||
resolution is stable for the same effective input; with `--conversion-trace`,
|
||||
its resolved rows are written to the trace. Generated export performs per-slide semantic
|
||||
read-back plus package timing/`p:cTn`/`p:spTgt` validation. Narration merges
|
||||
audio timing into the existing DOM and preserves animation rows. Direct-PPTX
|
||||
routes preserve source object animation, compare its object-animation fingerprint
|
||||
before/after allowed edits, and validate structure; they do not author
|
||||
animation effects. The exact 22 tuples and OOXML rules live in
|
||||
[`scripts/docs/pptx-animations.md`](scripts/docs/pptx-animations.md).
|
||||
|
||||
**Optional custom animations** (only when the user asks to tune animation order/effects/timing for specific objects):
|
||||
|
||||
@@ -818,11 +880,11 @@ Run the standalone [`generate-audio`](workflows/generate-audio.md) workflow. The
|
||||
Do NOT call `notes_to_audio.py` directly without going through the workflow — `--voice` / `--voice-id` is required and the workflow produces the locale/provider-aware recommendation that makes the choice meaningful.
|
||||
|
||||
Full effect list, anchor logic, and limits: [`references/animations.md`](references/animations.md).
|
||||
The compatibility contract covers PowerPoint OOXML; do not promise identical
|
||||
animation playback in Keynote or other presentation applications.
|
||||
|
||||
> ❌ **NEVER** substitute `cp` for `finalize_svg.py` — finalize performs multiple critical processing steps
|
||||
> ❌ **NEVER** force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). The default auto-split already gives native the high-fidelity source it needs without touching legacy.
|
||||
> ❌ **NEVER** use `--only` in the standard pipeline. Keep it only for explicit
|
||||
> one-product diagnostics or compatibility checks.
|
||||
> ❌ **NEVER** use `-s final` for a release export. It is a diagnostic comparison only; the supported native route reads `svg_output/`.
|
||||
|
||||
> **Post-export annotation window**: the preview service from Step 6 typically remains running after export. If the user submitted annotations in the browser (during Executor or after export) and now asks to apply them — they may quote the browser prompt (`Changes saved to svg_output...` / `修改已保存到 svg_output...`), say "apply my annotations" / "应用注解" / equivalent — run [`live-preview`](workflows/live-preview.md) Step 2 to apply and re-export. Annotations submitted during generation are also handled here, not earlier.
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
# Page Transitions & Per-Element Animations
|
||||
|
||||
PPT Master's exported PPTX supports **page transitions** (slide-to-slide) and **per-element entrance animations** (within a slide). Both are controlled by `svg_to_pptx.py` CLI flags and ship as real OOXML — they animate inside PowerPoint and Keynote, no embedded video.
|
||||
PPT Master's exported PPTX supports **page transitions** and **per-element
|
||||
entrance animations** as real PowerPoint OOXML. Other applications may
|
||||
interpret timing differently; this contract makes no unconditional Keynote guarantee.
|
||||
|
||||
## Defaults
|
||||
## 1. Defaults
|
||||
|
||||
| Layer | Default | Why |
|
||||
|---|---|---|
|
||||
| Page transition | `fade`, 0.4s | Calm baseline that suits most decks |
|
||||
| Page transition | CLI: `fade`, 0.4s | Calm baseline that suits most decks; the public Python builder retains its legacy 0.5s default |
|
||||
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so element entrance is opt-in. Turn it on with `-a auto` (or another effect): effects map from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool |
|
||||
|
||||
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` (or `svg_final/`) — no need to rerun the LLM. To turn per-element animation on for the whole deck, pass `-a auto`.
|
||||
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` — no need to rerun the LLM. `-s final` is reserved for diagnostic comparison and is not a supported release source. To turn per-element animation on for the whole deck, pass `-a auto`.
|
||||
|
||||
## Custom Object-Level Animation
|
||||
---
|
||||
|
||||
## 2. Custom Object-Level Animation
|
||||
|
||||
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
|
||||
|
||||
@@ -28,18 +32,23 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project>
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
|
||||
```
|
||||
|
||||
Minimal sidecar:
|
||||
Single-slide sidecar excerpt (repeat the complete slide block for every SVG in `svg_output/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
|
||||
},
|
||||
"slides": {
|
||||
"03_market": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" },
|
||||
"groups": {
|
||||
"title": { "effect": "fade", "order": 1 },
|
||||
"chart": { "effect": "wipe", "order": 2, "duration": 0.6 },
|
||||
"insight": { "effect": "fly", "order": 3, "delay": 0.2 },
|
||||
"footer": { "effect": "none" }
|
||||
"insight": { "effect": "fly", "order": 3, "delay": 0.2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,31 +62,42 @@ Rules:
|
||||
- `effect: none` removes that group from the entrance sequence.
|
||||
- `order` changes animation order only; it does not change slide layering.
|
||||
- `delay` is seconds before that group starts in `after-previous` mode.
|
||||
- `duration` overrides the per-group entrance duration.
|
||||
- `duration` overrides the per-group schedule duration. `appear` remains a 1ms visibility flip; its configured duration spaces the next `after-previous` row.
|
||||
- `--animation none` overrides the sidecar and disables all per-element animation.
|
||||
- An explicit sidecar group may override the legacy chrome-name heuristic, but it cannot override `data-pptx-layer` or an explicit static role/placeholder marker.
|
||||
- Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted.
|
||||
|
||||
## Page Transitions
|
||||
---
|
||||
|
||||
## 3. Page Transitions
|
||||
|
||||
```bash
|
||||
# Pick a different effect
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t push --transition-duration 0.6
|
||||
|
||||
# Disable
|
||||
# Remove the visual transition
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none
|
||||
|
||||
# Auto-advance every 5 seconds (kiosk-style playback)
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --auto-advance 5
|
||||
|
||||
# Auto-advance with no visual transition
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none --auto-advance 5
|
||||
```
|
||||
|
||||
Available effects: `fade`, `push`, `wipe`, `split`, `strips`, `cover`, `random`.
|
||||
|
||||
Flags:
|
||||
|
||||
- `-t/--transition` — effect name, or `none` to disable. Default: `fade`.
|
||||
- `-t/--transition` — effect name, or `none` for no visual transition. Default: `fade`. `none` does not remove an explicitly configured automatic advance.
|
||||
- `--transition-duration` — seconds, default `0.4`.
|
||||
- `--auto-advance` — seconds; omit for presenter-controlled advance.
|
||||
- `--auto-advance` — seconds; click remains enabled, so the slide advances on click or when the timer expires. Omit for presenter-controlled advance.
|
||||
|
||||
## Per-Element Animations
|
||||
**Hard rule — no silent downgrade**: an unknown transition effect or invalid/non-finite duration fails export. It is never replaced by `fade`. Recorded narration keeps the resolved visual transition; `-t none --recorded-narration ...` writes narration-driven advance timing without restoring a visual effect.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-Element Animations
|
||||
|
||||
Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
|
||||
|
||||
@@ -103,14 +123,16 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
|
||||
--animation-stagger 0.7 --animation-duration 0.5
|
||||
|
||||
# All groups animate in unison on slide entry
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger with-previous
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger with-previous
|
||||
```
|
||||
|
||||
22 single effects: `appear`, `fade`, `fly`, `cut`, `zoom`, `wipe`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
|
||||
|
||||
These names preserve the established filter / `presetID` / `presetSubtype` tuples documented in [`pptx-animations.md`](../scripts/docs/pptx-animations.md#3-compatibility-contract). `cut` is a legacy public key; compatibility promises its existing tuple, not a semantic interpretation inferred from an external preset-id table.
|
||||
|
||||
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track` → `wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*` → `fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle` → `fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note` → `fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
|
||||
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
|
||||
- `random` — samples from the legacy 16-effect pool.
|
||||
- `random` — samples from the legacy 16-effect pool. Resolution is seeded from the effective deck input, so the same input produces the same choices; `--conversion-trace` records every resolved effect when diagnostics are enabled.
|
||||
|
||||
`appear` is excluded from every variation pool because it has no visible motion.
|
||||
|
||||
@@ -124,13 +146,15 @@ Flags:
|
||||
|
||||
> Note: `--recorded-narration` rejects `on-click`; use `after-previous` or `with-previous` for video-ready narrated decks.
|
||||
|
||||
## Anchor Logic — Top-Level `<g id="...">`
|
||||
---
|
||||
|
||||
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). One group = one click reveal.
|
||||
## 5. Anchor Logic — Top-Level `<g id="...">`
|
||||
|
||||
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). One group produces one animation-pane entrance row; whether that row needs a click depends on the selected Start mode.
|
||||
|
||||
Aim for **3–8 content groups per slide**. This is also the granularity PowerPoint uses for group-select / group-move, so it improves editing ergonomics regardless of animation.
|
||||
|
||||
**Chrome groups skip the cascade automatically.** Top-level groups that look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded from the click sequence and appear together with the slide. Detection is done on the `id`: after splitting on `-` and `_`, if any token matches `background` / `bg` / `decoration` / `decorations` / `decor` / `header` / `footer` / `chrome` / `watermark` / `pagenumber` / `pagenum` / `nav` / `logo` / `rule`, the group is treated as chrome. Examples that auto-skip: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Don't strip the `<g>` wrapper to avoid animation — keep it (PowerPoint group-select needs it) and just name it appropriately.
|
||||
**Chrome groups skip the cascade automatically.** Explicit SVG role and placeholder semantics are authoritative. A group with `data-pptx-layer` or an explicit static role/placeholder marker can never animate. For marker-free legacy SVGs only, top-level groups whose id tokens look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded and appear with the slide. An explicit `animations.json` group entry may override this id-name heuristic, but never an explicit structural marker. Examples that auto-skip by legacy id: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Do not strip the `<g>` wrapper to avoid animation — keep it for PowerPoint group selection and use `effect: none` when the content should remain static.
|
||||
|
||||
**Fallback for flat SVGs** (no top-level `<g>` wrappers, only raw `<rect>` / `<text>` / `<path>` at the root):
|
||||
|
||||
@@ -139,25 +163,44 @@ Aim for **3–8 content groups per slide**. This is also the granularity PowerPo
|
||||
|
||||
Executors should wrap logical sections in `<g id>` regardless of whether you plan to animate. The Executor reference (`skills/ppt-master/references/shared-standards.md`) requires it.
|
||||
|
||||
## Limitations
|
||||
---
|
||||
|
||||
- **Native shapes mode only.** Per-element animation needs editable shape anchors. `--only legacy` produces one image per slide and has no element granularity to animate; that mode is unaffected by `-a/--animation` and only honors `-t/--transition`.
|
||||
- **Office version drift on element animations.** Effects use the `<p:animEffect filter=...>` path (vs. `presetID` lookup tables) to stay stable across Office versions. Most filters render identically in PowerPoint 2016+; older Office may downgrade some filters to plain Appear.
|
||||
- **PNG fallback (compat mode) is for visual rendering only.** Transitions and animations live in the slide XML, not in the PNG, so disabling compat mode does not affect either layer.
|
||||
## 6. Validation and Read-Back
|
||||
|
||||
## Quick Reference
|
||||
Animation configuration is strict. Export fails on an unknown effect, mode, or trigger; a boolean or non-finite duration/delay/stagger; a non-positive duration; a negative delay/stagger; a non-positive or non-integer order; a missing slide/group reference; or any attempt to animate a structural layer. These errors never downgrade to another effect or silently omit a requested target.
|
||||
|
||||
Generated export reads each slide's timing tree back and checks row count/order, trigger, shape target, resolved effect tuple, duration, and timeline offset. Package validation then checks root timing placement, unique and valid `p:cTn` ids, and every `p:spTgt` reference. The writer does not emit `p:bldP` for groups or pictures. Direct-PPTX preserve mode tolerates unchanged legacy group/picture `p:bldP` rows from earlier PPT Master exports; new generated packages remain strict.
|
||||
|
||||
Narration injection merges audio timing into an existing direct `p:sld/p:timing` DOM and preserves entrance rows. A source timing tree nested in `mc:AlternateContent` or another non-root container fails safely instead of being rewritten or duplicated. Direct-PPTX routes fingerprint source object-animation timing before and after their allowed edits, then run structural package validation; they do not author or normalize animation effects.
|
||||
|
||||
---
|
||||
|
||||
## 7. Limitations
|
||||
|
||||
- **Native DrawingML output only.** Page transitions and per-element animations are authored on the PPTX produced by the project converter from `svg_output/`. `svg_final/` remains a static SVG visual preview, not an animated or alternate PPTX route.
|
||||
- **PowerPoint OOXML scope.** Effects preserve their established filter / `presetID` / `presetSubtype` tuples and are validated against the serialized PowerPoint package. Rendering in Keynote, LibreOffice, WPS, or other applications is outside the unconditional compatibility guarantee.
|
||||
- **Manual SVG shape conversion is unsupported.** Inserting an `svg_final/` page as an SVG picture does not establish element animation anchors; use the native PPTX when editable animated shapes are required.
|
||||
- **Source extension preservation.** Direct-PPTX routes preserve unknown transition `AlternateContent` when configured to keep the source. When advance timing changes, Choice and Fallback receive the same `advClick` / `advTm` values.
|
||||
|
||||
---
|
||||
|
||||
## 8. Quick Reference
|
||||
|
||||
| Goal | Command |
|
||||
|---|---|
|
||||
| Disable transitions | `-t none` |
|
||||
| Remove visual transition | `-t none` |
|
||||
| Change transition effect | `-t push` (or any from the list above) |
|
||||
| Slower transition | `--transition-duration 0.8` |
|
||||
| Auto-play | `--auto-advance 5` |
|
||||
| Disable element animation | `-a none` |
|
||||
| Switch to on-click trigger | `--animation-trigger on-click` |
|
||||
| Switch to on-click trigger | `-a auto --animation-trigger on-click` |
|
||||
| Use a single effect instead of auto | `--animation fade` |
|
||||
| All groups animate together | `--animation-trigger with-previous` |
|
||||
| Slower per-element reveal | `--animation-duration 0.5` |
|
||||
| Wider gap in after-previous | `--animation-stagger 0.7` |
|
||||
| All groups animate together | `-a auto --animation-trigger with-previous` |
|
||||
| Slower per-element reveal | `-a auto --animation-duration 0.5` |
|
||||
| Wider gap in after-previous | `-a auto --animation-stagger 0.7` |
|
||||
|
||||
See also: [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
|
||||
See also:
|
||||
|
||||
- [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
|
||||
- [`pptx-transitions.md`](../scripts/docs/pptx-transitions.md) for the shared OOXML writer, MCE preservation, and read-back contract.
|
||||
- [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact effect tuples, timing-tree rules, and animation package validator.
|
||||
|
||||
@@ -10,12 +10,12 @@ Global artifact ownership rules for PPT Master projects.
|
||||
|
||||
| Artifact | Owner | Role | Read/write contract |
|
||||
|---|---|---|---|
|
||||
| `sources/` content-type files | Content contract | Main pipeline source for text, tables, and chart data values | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judges by content; do not replace values with PPTX geometry JSON in the main pipeline |
|
||||
| `sources/` content-type files | Content contract | Main pipeline source for text, tables, chart data values, and SmartArt node wording | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judges by content; do not replace values with PPTX geometry JSON in the main pipeline |
|
||||
| `sources/` converted-source originals | Source archive | Imported source files that have a converted content contract (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) and source-adjacent extracted assets | Read via the converted `<stem>.md` in the main pipeline; direct-PPTX workflows read the `.pptx` by route |
|
||||
| `sources/*.conversion_profile.json`, `sources/*_files/image_manifest.json` | Pipeline sidecar | Conversion audit record / asset index | NOT read as slide content; open only to audit a conversion or resolve assets |
|
||||
| `analysis/source_profile.json` | Machine fact index | Compact Strategist-facing PPTX intake digest | Main pipeline reads as factual context and recommendation candidates |
|
||||
| `analysis/<stem>.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed |
|
||||
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches | Direct PPTX workflows use as native fill/structure contract |
|
||||
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches, SmartArt nodes/connections | Direct PPTX workflows use as native fill/structure contract |
|
||||
| `analysis/image_analysis.csv` | Regenerated image fact view | Measured facts about the current `images/` folder | Re-run `analyze_images.py` before reading image facts after changes |
|
||||
| `design_spec.md` | Human design narrative | Explains design intent, outline, rationale, and resource plan | Strategist writes; humans and later roles read for intent |
|
||||
| `spec_lock.md` | Execution contract | Literal colors, typography, icons, images, page rhythm, templates, and charts | Executor re-reads before every page; values must be used verbatim |
|
||||
@@ -27,8 +27,8 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design | Quality checker and native PPTX export read this as the canonical visual/page-layout source; templates and locks do not add missing visible objects at export |
|
||||
| `notes/total.md` | Speaker-note source | Complete notes before splitting | Step 6 writes; Step 7.1 splits |
|
||||
| `notes/slide_*.md` | Split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` |
|
||||
| `svg_final/` | Derived preview/export SVGs | Self-contained post-processed SVGs | Rebuild from `svg_output/` with `finalize_svg.py` |
|
||||
| `exports/` | Delivery artifacts | Native PPTX and optional SVG snapshot PPTX | Step 7.3 writes final outputs |
|
||||
| `svg_final/` | Derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Rebuild from `svg_output/` with `finalize_svg.py`; do not use as a supported PPTX source |
|
||||
| `exports/` | Delivery artifacts | Native DrawingML PPTX and its explicit native-object/narration variants | Step 7.3 writes final outputs from `svg_output/` |
|
||||
| `backup/<timestamp>/svg_output/` | Frozen author-source archive | Re-export source without re-running LLM | `svg_to_pptx.py` writes a snapshot during export |
|
||||
| `animations.json` | Optional animation config | Object-level animation sidecar | Created only by explicit animation workflow/request |
|
||||
|
||||
@@ -38,16 +38,17 @@ Global artifact ownership rules for PPT Master projects.
|
||||
|
||||
| Invariant | Rule |
|
||||
|---|---|
|
||||
| Content values | Main pipeline text, tables, and chart values come from content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), not from `slide_library.json`. |
|
||||
| Content values | Main pipeline text, tables, chart values, and SmartArt node wording come from content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), not from `slide_library.json`. |
|
||||
| Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `<stem>.slide_library.json`) are read per Step 4 / direct-PPTX workflow, not in the `sources/` content scan. |
|
||||
| PPTX structure | `slide_library.json` owns native geometry and slot facts for direct PPTX workflows. |
|
||||
| PPTX structure | `slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. |
|
||||
| Design contract | `design_spec.md` explains; `spec_lock.md` executes. Executor must not infer execution values from prose. |
|
||||
| Image facts | `images/` is live state; `analysis/image_analysis.csv` is a regenerated view, not a durable cache. |
|
||||
| SVG source | `svg_output/` is the only author source for generated pages. |
|
||||
| Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. |
|
||||
| Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts; do not force them into SVG metadata. |
|
||||
| Post-processed SVG | `svg_final/` is disposable and must be rebuildable from `svg_output/`. |
|
||||
| Export source | Native PPTX export reads `svg_output/` by default. SVG snapshot export reads `svg_final/` only when requested. |
|
||||
| Post-processed SVG | `svg_final/` is disposable, must be rebuilt in Step 7.2, and serves only as a self-contained visual preview / manually insertable SVG picture. |
|
||||
| Export source | The only supported generated-PPTX route reads `svg_output/` through the project SVG-to-DrawingML converter. A diagnostic `-s final` override does not change ownership or create a supported release route. |
|
||||
| Shape-conversion boundary | PowerPoint's manual Convert-to-Shape operation on `svg_final/` is outside the project compatibility contract. |
|
||||
| Confirmation | Final `confirm_ui/result.json` or chat confirmation overrides recommendations. |
|
||||
|
||||
**Forbidden - mixed ownership**: Do not copy chart values from Markdown into `analysis/` by hand, do not edit `svg_final/` as the source of a fix, and do not treat `design_spec.md` prose as a replacement for `spec_lock.md`.
|
||||
@@ -62,6 +63,5 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| `notes/slide_*.md` | `notes/total.md` | `python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>` |
|
||||
| `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>` |
|
||||
| Native PPTX | `svg_output/` plus notes/assets | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` |
|
||||
| SVG snapshot PPTX | `svg_final/` | `svg_to_pptx.py --svg-snapshot` |
|
||||
|
||||
**Default - regenerate derived views**: When a source artifact changes, regenerate the derived artifact at the owning step instead of patching the derived file directly.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
**Hard rule — minimal semantics without semantic loss**: `baseline` / free-design roots declare `data-pptx-page-role`; `template` / `preserve` roots already use `data-pptx-layout` and do not duplicate that identity. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. Ordinary page content keeps normal SVG structure without duplicate semantic classification. See [`semantic-svg.md`](./semantic-svg.md).
|
||||
|
||||
**Hard rule — supported PPTX route**: The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter. Step 7.2 still generates `svg_final/` as a mandatory self-contained visual preview that may be inserted as an SVG picture. Do not treat PowerPoint's manual Convert-to-Shape operation as an authoring target or compatibility requirement.
|
||||
|
||||
> Note: this rule covers page design only. Speaker notes, animations, transitions, narration, and direct native-PPTX workflows retain their separate artifacts and package-level processing.
|
||||
|
||||
---
|
||||
@@ -262,7 +264,7 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
|
||||
- **Technical specs**: see [shared-standards.md](shared-standards.md) for SVG/PPT constraints
|
||||
- **Card containers — use the documented patterns**: when a content page needs section cards (4 quadrants, parallel aspects, capability blocks, info cards), use the patterns codified in [`templates/charts/CHART_STYLE_GUIDE.md`](../templates/charts/CHART_STYLE_GUIDE.md) §11 — half-rounded section tab (§11.1), nested card border without stroke (§11.2), card-grid skeletons (§11.3), diagonal dashed connector for cross-quadrant relationships (§11.5), ground-anchor ellipse as a non-filter depth marker (§11.6), bidirectional interaction arrows for paired protocols (§11.7). Do not reinvent the "tinted full-rounded rect + white cover-rect to hide the bottom corners" hack; it survives in older templates but breaks SVG→PPTX color editing. Reference templates: [`labeled_card.svg`](../templates/charts/labeled_card.svg), [`quadrant_text_bullets.svg`](../templates/charts/quadrant_text_bullets.svg), [`kpi_cards.svg`](../templates/charts/kpi_cards.svg), [`matrix_2x2.svg`](../templates/charts/matrix_2x2.svg), [`team_roster.svg`](../templates/charts/team_roster.svg), [`client_server_flow.svg`](../templates/charts/client_server_flow.svg).
|
||||
- **Reference — prefer semantic shapes over preset stacks (not a constraint)**: when a slide needs to express "ascending / converging / breaking through / stacking" — i.e., a relationship that goes beyond a generic arrow — prefer a single custom `<polygon>` or `<path>` that encodes the semantics geometrically, rather than stacking multiple preset arrows. A converging-tip path or a podium polygon reads faster than three arrows pointing at a label. Examples of this technique appear in many imported corporate decks; see `projects/01_template_import/svg_output/slide_01.svg` shape-158 for a reference (gradient-filled inward-pointing arrow). Do not codify these as templates — they are page-specific; the rule is just "consider polygon before stacking presets."
|
||||
- **Reference — visual depth through restraint (not a constraint)**: layered depth comes from rhythm (flat vs lifted, dense vs spacious), not from shadows everywhere. Shadow typically suits 2-3 genuinely floating elements per page (cards on photos, primary CTA, overlays); keep peer-grid cards, dividers, body containers flat. Reach for typography weight, spacing, accent bars, subtle tints **before** shadow. Full rules in shared-standards.md §6.
|
||||
- **Reference — visual depth through restraint (not a constraint)**: layered depth comes from rhythm (flat vs lifted, dense vs spacious), not from shadows everywhere. Shadow typically suits 2-3 genuinely floating elements per page (cards on photos, primary CTA, overlays); keep peer-grid cards, dividers, body containers flat. Reach for typography weight, spacing, accent bars, subtle tints **before** shadow.
|
||||
|
||||
### 3.2 Native Object Metadata Marker (MANDATORY on eligible data-chart and text-grid table pages)
|
||||
|
||||
@@ -280,7 +282,7 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
|
||||
- Native chrome: write `title`, `subtitle`, axis titles, or `show_legend: true` only when the fallback visibly renders the same chrome inside the native chart's replacement scope. `title` is the PowerPoint chart title, not an object name; use `name` for page-semantic object naming (e.g. `p03-revenue-chart`). Write explicit `x`/`y`/`width`/`height` read from the drawn plot area; omission is the fallback — the exporter then infers the frame from the drawn fallback geometry.
|
||||
- Value-axis labels: when the fallback keeps category labels but intentionally omits numeric value-axis tick labels, set `show_value_axis_labels: false`.
|
||||
- Freeform chart text: transcribe center labels, source notes, and other in-chart annotations as companion `caption` / `note` / `notes` entries with explicit slide-coordinate bounds; do not rely on fallback `<text>` children to survive native export.
|
||||
- Native chart typography mirrors the SVG fallback. Copy the fallback's shared chart font into `style.font_family` and visible chart text sizes into the matching metadata fields (`title_font_size`, `subtitle_font_size`, `axis_font_size`, `note_font_size`, etc.) when role sizes differ; if omitted, the exporter infers shared font family and base chart text size from visible fallback text inside the native marker. When a visible chart title, subtitle, or axis title needs its own size/color/font, write that field as an object with `text`, `font_size`, `font_family`, and `color`. Use `axis_title_font_size`, `legend_font_size`, or companion per-entry `font_size` only when the fallback visibly uses a separate size. If a fallback role has no explicit size, use the compact three-tier chart defaults from [shared-standards.md](shared-standards.md).
|
||||
- Native chart typography mirrors the SVG fallback. Copy the fallback's shared chart font into `style.font_family` and visible chart text sizes into the matching metadata fields (`title_font_size`, `subtitle_font_size`, `axis_font_size`, `note_font_size`, etc.) only when role sizes differ; otherwise let the exporter infer them from visible fallback text. When a visible chart title, subtitle, or axis title needs its own size/color/font, write that field as an object with `text`, `font_size`, `font_family`, and `color`. Use `axis_title_font_size`, `legend_font_size`, or companion per-entry `font_size` only when the fallback visibly uses a separate size.
|
||||
- Native table typography mirrors the SVG fallback. Write `style.font_family` and `style.font_size` from the visible table text; use `header_font_size` or per-cell `font_size` only when the fallback visibly does so. If the fallback has no explicit table font, fall back to the deck body family and locked body size from `spec_lock.md typography`.
|
||||
- The marker group's transform stays translate/scale only (no rotate / matrix / skew).
|
||||
- Visual parity is not a goal: the SVG drawing remains the designed visual; the native object is a data-editable counterpart with PowerPoint-default styling that users restyle by hand after export. Never simplify the SVG design to match what a native object could show.
|
||||
@@ -500,21 +502,21 @@ Auto-split `notes/total.md` into per-page files in `notes/`.
|
||||
|
||||
> **Auto-continuation**: After Visual Construction Phase (all SVG pages) and Logic Construction Phase (all notes) are complete, the Executor proceeds directly to the post-processing pipeline.
|
||||
|
||||
**Post-processing & Export** (same canonical pipeline as [shared-standards.md §5](shared-standards.md)):
|
||||
**Post-processing & Export** (canonical workflow: [`SKILL.md` Step 7](../SKILL.md)):
|
||||
|
||||
```bash
|
||||
# 1. Split speaker notes
|
||||
python3 scripts/total_md_split.py <project_path>
|
||||
|
||||
# 2. SVG post-processing (auto-embed icons, images, etc.)
|
||||
# 2. SVG post-processing (auto-embed icons/images and flatten positioned text)
|
||||
python3 scripts/finalize_svg.py <project_path>
|
||||
# Output: svg_final/ self-contained SVG visual previews
|
||||
|
||||
# 3. Export PPTX
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
# Output (default-flow mode):
|
||||
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output)
|
||||
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
|
||||
#
|
||||
# Add --svg-snapshot to additionally emit:
|
||||
# exports/<project_name>_<timestamp>_svg.pptx ← SVG snapshot pptx (sibling of native pptx)
|
||||
```
|
||||
|
||||
`svg_final/` may be opened directly or manually inserted into PowerPoint as an SVG picture. It is not a second PPTX route. Use `-s final` only for converter diagnostics; release exports use the default `svg_output/` source. Manual Convert-to-Shape behavior is unsupported.
|
||||
|
||||
+8
-7
@@ -148,7 +148,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
24. **Custom path crop (blob, arrow, leaf, silhouette)** — `<clipPath><path d="…"/></clipPath>`; allows any curved or organic shape. PowerPoint export translates this to `custGeom` and survives roundtrip.
|
||||
|
||||
25. **Layered paper-cut stack** — multiple image or shape layers each with `clipPath` + a small `<feDropShadow>` offset to fake physical layering depth. Each layer casts a shadow onto the next, producing real-looking craft depth.
|
||||
25. **Layered paper-cut stack** — clip each image layer under the image-only contract in [`shared-standards.md`](shared-standards.md) §1.2; draw vector layers directly in their final geometry. A small conditional shadow on each layer can create physical separation.
|
||||
|
||||
26. **Triptych baked into a single wide image** — one wide `<image width=1160 height=334>` whose internal composition already contains 2–3 scenes. Generate the triptych as one image (not three separate calls) when scene-to-scene consistency matters — the model preserves character identity, lighting continuity, and color grading far more reliably when panels are produced together.
|
||||
|
||||
@@ -170,9 +170,9 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
33. **Spotlight mask — clear region surrounded by darkness** — cover the canvas with `<rect>` filled by a `<radialGradient>` whose inner stop is fully transparent and outer stop is opaque dark. Reads as a flashlight beam on the focal area. Use sparingly — it kills everything outside the spotlight.
|
||||
|
||||
34. **Gaussian-blur backdrop** — `<filter><feGaussianBlur stdDeviation="8–15"/></filter>` applied to the background image, with sharp content layered on top unblurred. Reads as depth-of-field. Be aware that filters have inconsistent PPT export support — if fidelity matters, bake the blur into the source image instead.
|
||||
34. **Gaussian-blur backdrop** — blur the background in the source image, then layer sharp SVG content above it. Native filter export maps the supported blur graph to a glow/shadow effect; it does not preserve a blurred-image backdrop.
|
||||
|
||||
35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Most reliable when baked into the source image at generation time. Runtime SVG duotone via `<feColorMatrix>` + `<feComponentTransfer>` is possible but the filter chain is fragile through PPT export — only attempt if you control the renderer.
|
||||
35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Bake it into the source image; the native PPT route does not support a runtime duotone filter chain.
|
||||
|
||||
36. **Drop shadow under image panel** — `<filter><feDropShadow dx dy stdDeviation flood-color flood-opacity/></filter>` applied to the image's container `<rect>` (or to the `<image>` itself). Standard depth lift.
|
||||
|
||||
@@ -204,7 +204,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
67. **Image with knock-out / cut-out shape** — overlay a shape filled with the background color or another image, creating the impression of a hole punched through the underlying image.
|
||||
|
||||
68. **Text-as-mask over image** — letterforms revealing image through them. SVG-level `<mask>` is forbidden in this project (PPT export breaks). The only reliable way: bake this effect into the image at generation time by prompting for "large lettering revealing the underlying scene through letterforms." Treat as a pre-rendered artistic choice, not a runtime effect.
|
||||
68. **Text-as-mask over image** — letterforms revealing image through them. Under the canonical SVG compatibility boundary in [`shared-standards.md`](shared-standards.md), realize this pattern as a pre-rendered image rather than a runtime effect. Prompt for "large lettering revealing the underlying scene through letterforms" and treat the result as a fixed artistic choice.
|
||||
|
||||
69. **Image rotated at a slight angle for editorial feel** — `transform="rotate(angle cx cy)"` on the `<image>` or its container `<g>`; 2–6 degrees typical. Adds dynamism without breaking layout.
|
||||
|
||||
@@ -252,9 +252,10 @@ Combine freely. The "AI-default" failure mode is the opposite: defaulting to bar
|
||||
## Hard Constraints
|
||||
|
||||
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
|
||||
- `<clipPath>` on `<image>` and transparency encoding (`fill-opacity` / `stop-opacity`, never `rgba()`) — authoritative form in [`shared-standards.md`](shared-standards.md) §1.2 and §2; do not restate or relax here.
|
||||
- No `<mask>`, no `<feComposite>` for alpha compositing. Alpha-effect routing (gradient overlays, clipPath crops, filter shadows, baked-in source image) is the table in [`shared-standards.md`](shared-standards.md) §1.0.
|
||||
- `<feDropShadow>` / `<feGaussianBlur>` are accepted but PPT export is inconsistent — bake into the source image when fidelity is critical.
|
||||
- All project-wide SVG compatibility exceptions and conditional mappings are
|
||||
owned by [`shared-standards.md`](shared-standards.md). This catalog neither
|
||||
restates nor relaxes that contract; each pattern records only its
|
||||
scenario-specific rendering choice.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+691
-709
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
|
||||
|
||||
## 1. Strategist Confirmation Stage
|
||||
|
||||
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 11-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes → XI Tech Constraints.
|
||||
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 10-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes.
|
||||
|
||||
⛔ **BLOCKING**: After the read, present professional recommendations for the confirmation fields below and wait for explicit user confirmation.
|
||||
|
||||
@@ -826,7 +826,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
|
||||
|
||||
> Note: §IX is the only content copy the Executor re-reads after context compression — what you write there is what survives.
|
||||
|
||||
### 6.2 Outline Output Specification (Must include 11 chapters)
|
||||
### 6.2 Outline Output Specification (Must include 10 sections)
|
||||
|
||||
| Chapter | Content Requirements |
|
||||
|---------|---------------------|
|
||||
@@ -840,7 +840,6 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
|
||||
| VIII. Image Resource List | Filename, dimensions, ratio, purpose, status, generation description |
|
||||
| IX. Content Outline | Grouped by chapter; each page includes layout, title, core message (the page's one idea), content blocks (in the selected phrasing mode), visualization type (if applicable) |
|
||||
| X. Speaker Notes Requirements | File naming rules, content structure description |
|
||||
| XI. Technical Constraints Reminder | SVG generation rules, PPT compatibility rules |
|
||||
|
||||
**Generation steps**:
|
||||
1. Read reference template: `templates/design_spec_reference.md`
|
||||
|
||||
+8
-6
@@ -53,10 +53,12 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
||||
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
|
||||
└── Placeholder / Needs-Manual without file → Dashed border + description text
|
||||
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
|
||||
5. Post-processing & Export → follow shared-standards.md §5
|
||||
5. Post-processing & Export → follow [`SKILL.md` Step 7](../SKILL.md)
|
||||
```
|
||||
|
||||
> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into `svg_final/`; export PPTX from `svg_final/`.
|
||||
> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into the mandatory `svg_final/` visual preview; native PPTX export independently reads `svg_output/`.
|
||||
|
||||
**Hard rule — export boundary**: `svg_final/` is a self-contained SVG preview for embeddable raster/SVG assets and may be manually inserted into PowerPoint as an SVG picture. EMF/WMF assets retain the documented external-reference exception for lossless native passthrough. The only supported generated-PPTX route is `svg_output/` through the project SVG-to-DrawingML converter. PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||
|
||||
---
|
||||
|
||||
@@ -65,7 +67,7 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
||||
| Method | Pros | Cons | Suitable For |
|
||||
|--------|------|------|-------------|
|
||||
| **External reference** | Small file size, fast iteration, easy to replace | Preview requires HTTP server from project root | `svg_output/` development phase |
|
||||
| **Base64 embedding** | Self-contained file, stable export | Large file size | `svg_final/` delivery phase |
|
||||
| **Base64 embedding** | Self-contained file, stable direct preview / SVG-picture insertion | Large file size | `svg_final/` preview phase |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,7 +108,7 @@ python3 -m http.server -d <project_path> 8000
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Base64 Embedding (Recommended for Delivery Phase)
|
||||
## Method 2: Base64 Embedding (Recommended for Preview Phase)
|
||||
|
||||
### Syntax
|
||||
|
||||
@@ -128,7 +130,7 @@ python3 -m http.server -d <project_path> 8000
|
||||
|
||||
## Conversion Process
|
||||
|
||||
Use the unified pipeline in [shared-standards.md §5](shared-standards.md). `finalize_svg.py` runs before export so image references in `svg_output/` become embedded assets in `svg_final/`.
|
||||
Use the unified pipeline in [`SKILL.md` Step 7](../SKILL.md). `finalize_svg.py` remains mandatory and embeds image references into the self-contained `svg_final/` preview. The following PPTX command still reads `svg_output/` by default and converts it directly to native DrawingML; it does not consume `svg_final/` in the supported release route.
|
||||
|
||||
```bash
|
||||
python3 scripts/finalize_svg.py <project_path>
|
||||
@@ -169,7 +171,7 @@ project/
|
||||
├── sources/ # Source files and their accompanying images
|
||||
│ └── article_files/
|
||||
├── svg_output/ # Raw version (external references)
|
||||
└── svg_final/ # Final version (images embedded)
|
||||
└── svg_final/ # Derived self-contained visual preview (images embedded)
|
||||
```
|
||||
|
||||
### Rounded Corner / Non-rectangular Image Cropping
|
||||
|
||||
+52
-19
@@ -4,15 +4,19 @@
|
||||
|
||||
## Core Mission
|
||||
|
||||
Generate reusable page templates for the **global template library** based on a finalized template brief, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
|
||||
Generate reusable page templates at the output scope confirmed by `create-template`, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
|
||||
|
||||
> This is a standalone role: only triggered via the `/create-template` workflow. It is **not** the project-level template selection/customization step in the main PPT generation pipeline.
|
||||
> This is a standalone role: only triggered via the `/create-template` workflow. It may write a global library package or a project-local thin bundle; it is not the template selection step in the main PPT generation pipeline.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Trigger**: `/create-template` workflow
|
||||
- **Output location**: `templates/<kind_dir>/<template_name>/` where `<kind_dir>` is `decks` (default — full-PPT replica with identity) or `layouts` (structure-only, no identity segment)
|
||||
- **Input**: finalized template brief (template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms)
|
||||
- **Output location**: `library` (default) → `skills/ppt-master/templates/<kind_dir>/<template_name>/`; `project` → the confirmed `<target_project>/templates/` root with no nested template-ID directory
|
||||
- **Input**: finalized template brief (output scope, target project when project-scoped, template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms)
|
||||
|
||||
**Hard rule — scope is execution metadata**: Use `output_scope` and `target_project` to route files, but do not write either field into portable `design_spec.md` frontmatter. Do not create a new PPTX structure mode; deck/layout output still declares `native_structure_mode: template`.
|
||||
|
||||
**Project-scope precondition**: The workflow has already confirmed an initialized target project, an empty `<target_project>/templates/`, and collision-free destination filenames in `images/` and `icons/`. Do not begin final writes before that all-at-once preflight passes.
|
||||
|
||||
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
|
||||
|
||||
@@ -94,7 +98,7 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
|
||||
- Cluster slides from `manifest.json` by `pageType` + visual structure (column count, hero-image vs. icon-grid vs. quote, etc.)
|
||||
- One SVG per cluster — do **not** emit a variant for a cluster represented by a single source slide unless that slide is structurally distinct from existing variants
|
||||
- One variant per visually distinct cluster — let the source's structural diversity drive the count. Collapse only **near-duplicates** (same column count, same hero element, same content density); do not collapse genuine structural differences just to keep the variant count down. If you find yourself wanting one variant per source slide, that is the signal the user should be in `mirror` mode, not `fidelity`
|
||||
- Record every emitted page in `design_spec.md §V Page Roster`; the corresponding index entry (`decks_index.json` or `layouts_index.json`) is generated automatically by `register_template.py` from the directory's actual SVG files
|
||||
- Record every emitted page in `design_spec.md §V Page Roster`; in library scope, `register_template.py` generates the corresponding index entry from the directory's actual SVG files. Project scope skips registration
|
||||
|
||||
> Variants reuse the parent type's placeholder set — see §4 (Placeholder Reference) below.
|
||||
|
||||
@@ -103,7 +107,7 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
|
||||
When the brief sets `Replication mode: mirror`, preserve literal page appearance while reconstructing layer ownership:
|
||||
|
||||
- Visual source: `<import_workspace>/svg-flat/slide_NN.svg` (the self-contained "what PowerPoint shows" view). Structural source: `svg/master_*.svg`, `svg/layout_*.svg`, and `svg/inheritance.json`.
|
||||
- Output: `templates/<kind_dir>/<template_id>/<NNN>_<page_type>.svg`, where `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
|
||||
- Output: `<template_target>/<NNN>_<page_type>.svg`, where `<template_target>` is the library package directory or the project `templates/` root. `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
|
||||
- Required metadata rewrite: declare the output Layout on the root, mark inherited Master/Layout visuals as direct preview layers, and map source content slots to semantic `data-pptx-placeholder` markers where the imported contract exposes them. Add `data-pptx-role` only to structural page-frame objects whose behavior is not already expressed by those specialized markers.
|
||||
- Other allowed modifications: rewrite `<image href="...">` paths to local assets and rename assets semantically. Keep geometry, decoration, sprite-sheet wrappers, original example text, chart previews, and fonts visually unchanged.
|
||||
- Forbidden: simplifying decorative complexity, merging similar slides, or dropping inherited preview chrome.
|
||||
@@ -121,7 +125,7 @@ When the brief sets `Replication mode: mirror`, preserve literal page appearance
|
||||
|
||||
**Scope rule — personality only.** A template `design_spec.md` describes **what makes this template recognizable**: brand colors, signature decorative motifs, page-by-page visual character, bundled assets. It does **not** restate generic constraints — those live in the canonical references and are already loaded by every downstream role:
|
||||
|
||||
- SVG technical constraints, PPT compatibility rules → [`shared-standards.md`](shared-standards.md)
|
||||
- General SVG required / forbidden / conditional interfaces → [`shared-standards.md`](shared-standards.md)
|
||||
- Generic layout pattern library, spacing bands, font-size ratio bands → [`templates/design_spec_reference.md`](../templates/design_spec_reference.md) (read by Strategist when authoring the **project** design_spec)
|
||||
- Canonical placeholder vocabulary → §4 below
|
||||
- Content methodology (pyramid / SCQA / MECE) → [`strategist.md`](strategist.md)
|
||||
@@ -130,6 +134,10 @@ Re-declaring any of these in a template `design_spec.md` is noise — Strategist
|
||||
|
||||
**Required skeleton:**
|
||||
|
||||
The frontmatter is portable across library and project scope. Do not add
|
||||
`output_scope` or `target_project`; those belong only to the workflow execution
|
||||
brief.
|
||||
|
||||
```markdown
|
||||
---
|
||||
template_id: <id>
|
||||
@@ -189,8 +197,7 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
|
||||
|
||||
| Don't write | Source |
|
||||
|---|---|
|
||||
| SVG technical constraints / Mandatory rules / Prohibited elements | `shared-standards.md` §1 |
|
||||
| PPT compatibility rules (`<g opacity>`, inline-styles-only, etc.) | `shared-standards.md` |
|
||||
| General SVG technical / compatibility rules | `shared-standards.md` |
|
||||
| Generic layout pattern library (centered card / 三栏 / timeline / …) | `design_spec_reference.md` §V |
|
||||
| Generic spacing bands (margin 40-60px, card gap 20-32px, etc.) | `design_spec_reference.md` §V |
|
||||
| Generic font-size hierarchy (cover 2.5-5x body, page title 1.5-2x, …) | `design_spec_reference.md` §IV |
|
||||
@@ -199,7 +206,10 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
|
||||
| "Usage Instructions" boilerplate (copy template / select page / …) | `create-template.md` |
|
||||
| Created Date / Page Count rows | not a library-level field |
|
||||
|
||||
When rewriting an existing template that contains the omitted sections, delete them — do not leave a "see XXX" pointer behind. The pointer is what this scope rule replaces.
|
||||
When rewriting an existing template that contains an omitted generic section,
|
||||
delete it rather than leaving a pointer. Keep a template-specific boundary only
|
||||
inside the personality section it qualifies (asset system, motif, image
|
||||
treatment, or page roster); do not preserve a generic technical-rules heading.
|
||||
|
||||
### 2. Inherit Design Specification
|
||||
|
||||
@@ -282,7 +292,7 @@ Use clear placeholder markers for replaceable content:
|
||||
|
||||
### 4. Placeholder Reference (canonical convention, overridable per template)
|
||||
|
||||
This is the **default vocabulary** used across the library. Newly created templates SHOULD prefer these names so projects that consume the library find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`).
|
||||
This is the **default vocabulary** used across template packages. Newly created templates SHOULD prefer these names so downstream projects find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`).
|
||||
|
||||
`svg_quality_checker.py --template-mode` emits **advisory warnings** when a page lacks the conventional placeholder for its type. To silence those warnings — and document the template's actual contract — declare a `placeholders:` map in `design_spec.md` frontmatter:
|
||||
|
||||
@@ -314,13 +324,13 @@ placeholders:
|
||||
| `{{CLOSING_MESSAGE}}` | Closing message | Ending page | Style-specific |
|
||||
| `{{COPYRIGHT}}` | Copyright | Ending page | Optional |
|
||||
|
||||
For TOC pages in **newly created library templates**, use indexed placeholders:
|
||||
For TOC pages in **newly created templates**, use indexed placeholders:
|
||||
|
||||
- `{{TOC_ITEM_1_TITLE}}`, `{{TOC_ITEM_1_DESC}}`
|
||||
- `{{TOC_ITEM_2_TITLE}}`, `{{TOC_ITEM_2_DESC}}`
|
||||
- ...
|
||||
|
||||
Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` for new templates. Existing templates may contain legacy placeholder variants, but new library assets should converge on the indexed TOC contract.
|
||||
Do **not** create new TOC placeholder families such as `{{CHAPTER_01_TITLE}}` for new templates. Existing templates may contain legacy placeholder variants, but new output should converge on the indexed TOC contract.
|
||||
|
||||
Variants reuse their parent type's placeholder set by default: every `03*_content*.svg` shares the content placeholder list above, unless the spec frontmatter declares an override for that specific stem.
|
||||
|
||||
@@ -332,7 +342,7 @@ When rebuilding from imported PPTX references, placeholder insertion takes prior
|
||||
|
||||
### File Save Location
|
||||
|
||||
Standard mode (default):
|
||||
Library scope keeps the existing self-contained package. Standard mode (default):
|
||||
|
||||
```
|
||||
templates/<kind_dir>/<template_name>/
|
||||
@@ -342,10 +352,11 @@ templates/<kind_dir>/<template_name>/
|
||||
├── 02_toc.svg # Optional
|
||||
├── 03_content.svg
|
||||
├── 04_ending.svg
|
||||
└── *.png / *.jpg # Image assets (if any)
|
||||
├── icons/ # Extracted vector assets (if any)
|
||||
└── *.png / *.jpg # Bitmap assets (if any)
|
||||
```
|
||||
|
||||
Fidelity mode adds variants and extension pages, e.g.:
|
||||
Fidelity mode adds variants and extension pages in the same package, e.g.:
|
||||
|
||||
```
|
||||
templates/<kind_dir>/<template_name>/
|
||||
@@ -381,6 +392,26 @@ templates/<kind_dir>/<template_name>/
|
||||
|
||||
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. Literal source text may remain, but every page still carries explicit native structure metadata.
|
||||
|
||||
Project scope writes directly into an initialized project's existing roots. The roster names still follow the selected replication mode, but the package is not nested under `<template_name>`:
|
||||
|
||||
```
|
||||
<target_project>/
|
||||
├── templates/
|
||||
│ ├── design_spec.md
|
||||
│ ├── 01_cover.svg
|
||||
│ ├── 02_chapter.svg
|
||||
│ ├── 03_content.svg
|
||||
│ ├── 04_ending.svg
|
||||
│ ├── ... # Fidelity variants or mirror pages
|
||||
│ └── icons/ # Package/validation copy
|
||||
├── images/
|
||||
│ └── *.png / *.jpg # Bitmap assets; template SVG href is ../images/<name>
|
||||
└── icons/
|
||||
└── *.svg # Identical runtime copy of extracted icons
|
||||
```
|
||||
|
||||
**Hard rule — project routing**: Keep `design_spec.md`, template SVGs, and non-bitmap package assets in `templates/`; place every bitmap in `images/`; duplicate each extracted icon into `templates/icons/` and runtime `icons/`. Do not write bitmaps into the project template root and do not create `<target_project>/templates/<template_name>/`. This output belongs only to the target project; use library scope for a self-contained cross-project package.
|
||||
|
||||
### Template Preview
|
||||
|
||||
After each template is generated, provide a brief summary table listing each template's status.
|
||||
@@ -401,7 +432,7 @@ If suitable template resources already exist, use them directly instead of gener
|
||||
2. **Adjust colors**: Modify colors per the project design spec
|
||||
3. **Customize**: Make project-specific adjustments
|
||||
|
||||
This section describes downstream reuse. The `Template_Designer` role itself is responsible for creating or normalizing the reusable library asset first.
|
||||
This section describes downstream reuse of an existing library package. The `Template_Designer` role may instead create the same contract directly in project scope when `create-template` selected that output.
|
||||
|
||||
**Example library structure** (query the appropriate kind's index — `templates/layouts/layouts_index.json` for structure-only templates, `templates/decks/decks_index.json` for full-PPT replicas, `templates/brands/brands_index.json` for identity-only presets):
|
||||
|
||||
@@ -426,11 +457,13 @@ templates/
|
||||
## Template_Designer Phase Complete
|
||||
|
||||
- [x] Read `references/template-designer.md`
|
||||
- [x] Output scope confirmed: `library` | `project`; project-scope preflight passed before final writes
|
||||
- [x] Replication mode confirmed: `standard` | `fidelity` | `mirror`
|
||||
- [x] Every page listed in `design_spec.md §V Page Roster` saved to `templates/<layouts|decks>/<template_name>/` (decks for full-PPT replicas, layouts for structure-only)
|
||||
- [x] Every page listed in `design_spec.md §V Page Roster` saved to the selected template target (library package directory or project `templates/` root)
|
||||
- [x] Naming convention applied (standard / fidelity: letter-suffix variants; mirror: `<NNN>_<page_type>.svg`)
|
||||
- [x] Templates follow design spec (colors, fonts, layout)
|
||||
- [x] Placeholder markers are clear and standardized; mirror may keep literal text but still maps imported semantic slots
|
||||
- [x] Every SVG is a complete preview with explicit Master/Layout/Slide/placeholder metadata and `native_structure_mode: template`
|
||||
- [ ] **Next step**: Validate assets and register the template via `register_template.py <id> --kind <deck|layout>`
|
||||
- [x] Project scope, when selected: bitmaps routed to `images/`; extracted icons copied to both `templates/icons/` and runtime `icons/`
|
||||
- [ ] **Next step**: Validate assets; register only library scope, otherwise hand the in-place project bundle to main Step 3
|
||||
```
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Flat line work, not material elevation. Depth reads from isometric projection and layered line weights, not shadows. Optional subtle corner vignette / accent glow on the dark paper — keep it faint. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
- Flat line work, not material elevation. Depth reads from isometric projection and layered line weights, not shadows. Optional subtle corner vignette / accent glow on the dark paper — keep it faint.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
|
||||
- Dark slate field; off-white chalk carries most marks; the deck's colors appear as soft, powdery pastel chalk accents, used sparingly.
|
||||
- Restrained and powdery — never saturated fills.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the chalk-on-slate, powdery-accent discipline — it names no colors. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
> HEX values come from confirmation `e`; this style only governs the chalk-on-slate, powdery-accent discipline — it names no colors.
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle. (Dark-theme legibility — prefer light stroke / outer glow over black shadow: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ Bloomberg / Economist news-infographic — publication-grade information density
|
||||
- A restrained field — light publication paper or dark graphite both fit (Economist vs Bloomberg-terminal); the deck's accent marks risk / key figures, an optional secondary distinguishes a second series; charts use tints of the same family, never a rainbow.
|
||||
- Numbers are colored to *mean* (up / down / risk / focus), not to decorate.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the restrained-field, meaning-coded-data discipline — it names no colors. (Dark-field legibility, if dark: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
> HEX values come from confirmation `e`; this style only governs the restrained-field, meaning-coded-data discipline — it names no colors.
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Depth via translucency, layering, bright edge highlights, and soft background glow — not hard drop shadows. Smooth multi-stop gradients are intrinsic here (the one style where generous gradient use is on-brand); keep them luminous, not muddy. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
- Depth via translucency, layering, bright edge highlights, and soft background glow — not hard drop shadows. Smooth multi-stop gradients are intrinsic here (the one style where generous gradient use is on-brand); keep them luminous, not muddy.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet. (Shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat. (Full shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
|
||||
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Batch-convert SVG files to PowerPoint presentations.
|
||||
# 用于将 SVG 文件批量转换为 PowerPoint 演示文稿
|
||||
# Uses Office-compatible mode (PNG + SVG dual format) by default, supports all Office versions.
|
||||
# 默认使用 Office 兼容模式(PNG + SVG 双格式),支持所有 Office 版本
|
||||
# Converts supported SVG elements to editable native DrawingML shapes.
|
||||
# 将受支持的 SVG 元素转换为可编辑的原生 DrawingML 形状
|
||||
python-pptx>=0.6.21
|
||||
XlsxWriter>=3.0.0
|
||||
|
||||
@@ -22,19 +22,6 @@ XlsxWriter>=3.0.0
|
||||
# notes_to_audio.py 用于在 macOS/Linux/Windows 上生成逐页旁白音频。
|
||||
edge-tts>=7.2.8
|
||||
|
||||
# Office compatibility dependencies (choose one, CairoSVG preferred)
|
||||
# Office 兼容模式依赖(二选一,优先使用 cairosvg)
|
||||
# Converts SVG to PNG fallback images for Office LTSC 2021 etc.
|
||||
# 用于将 SVG 转换为 PNG 后备图片,确保 Office LTSC 2021 等版本正常显示
|
||||
#
|
||||
# Option A / 方案 A: CairoSVG (recommended / 推荐, ~25MB, full gradient/filter support)
|
||||
# pip install cairosvg
|
||||
# macOS: brew install cairo
|
||||
#
|
||||
# Option B / 方案 B: svglib (lightweight / 轻量, ~5MB, some gradients may be lost)
|
||||
svglib>=1.5.0
|
||||
reportlab>=4.0.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# PDF to Markdown tool / PDF 转 Markdown 工具 (skills/ppt-master/scripts/source_to_md/pdf_to_md.py)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -76,8 +63,8 @@ openpyxl>=3.1.0
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Image processing tools / 图片处理工具
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# fix_image_aspect.py - Fix image aspect ratio (prevent stretching during PPT shape conversion)
|
||||
# fix_image_aspect.py - 修复图片宽高比(防止 PPT 转形状时拉伸)
|
||||
# fix_image_aspect.py - Normalize image boxes for PowerPoint SVG rendering diagnostics
|
||||
# fix_image_aspect.py - 为 PowerPoint SVG 渲染诊断归一化图片框
|
||||
# gemini_watermark_remover.py - Remove watermarks from Gemini-generated images
|
||||
# gemini_watermark_remover.py - 去除 Gemini 生成图片的水印
|
||||
Pillow>=9.0.0
|
||||
|
||||
@@ -46,6 +46,8 @@ python3 scripts/update_repo.py
|
||||
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
|
||||
| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
|
||||
| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) |
|
||||
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
|
||||
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||
| Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) |
|
||||
| Image tools | `image_gen.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
|
||||
| Repo maintenance | `update_repo.py` | README install/update section |
|
||||
@@ -116,6 +118,8 @@ python3 scripts/svg_to_pptx.py <project_path>
|
||||
|
||||
`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: only oversized full source images are reduced to max `2560px`, so later PowerPoint resizing keeps more image detail. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes.
|
||||
|
||||
`finalize_svg.py` remains mandatory because it creates the self-contained `svg_final/` visual preview. Those SVGs may be opened directly or inserted into PowerPoint as SVG pictures. The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter; `-s final` is diagnostic-only, and PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||
|
||||
For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, transitions, `template-fill-pptx`, and `native-enhance-pptx` remain separately owned capabilities.
|
||||
|
||||
When `spec_lock.md` has no `pptx_structure` section, native `svg_to_pptx.py` falls back to `baseline`: the generated deck keeps a standard Master/Layout relationship and promotes only exact, z-order-safe shared backgrounds/chrome. Root `data-pptx-page-role` selects `Cover`, `Agenda`, `Section`, `Closing`, or `Content`; filenames are a fallback for marker-free legacy SVGs. A family-wide exact background and an exact leading structurally marked chrome prefix may move into that Layout; ids are consulted only when the structural role is absent. Actual titles, body content, pictures, charts, tables, page numbers, and page-specific shapes remain on Slide. No visual-similarity or placeholder inference is performed. Baseline also prunes unused layouts and maps locked typography/colors into the PowerPoint theme. Use `--pptx-structure flat` for slide-local diagnostics.
|
||||
@@ -154,15 +158,16 @@ python3 scripts/update_repo.py --skip-pip
|
||||
- Keep one user-facing entry point per workflow at the top level of `scripts/`
|
||||
- Move provider-specific or helper internals into subdirectories
|
||||
- Prefer the unified entry points `project_manager.py`, `finalize_svg.py`, and `image_gen.py`
|
||||
- Use `svg_output/` for native export and `svg_final/` for SVG snapshot/preview export
|
||||
- Use `svg_output/` for the only supported native PPTX export and `svg_final/` for self-contained SVG visual preview / picture insertion
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Conversion Tools](./docs/conversion.md)
|
||||
- [Project Tools](./docs/project.md)
|
||||
- [SVG Pipeline Tools](./docs/svg-pipeline.md)
|
||||
- [PPTX Transition Core](./docs/pptx-transitions.md)
|
||||
- [Image Tools](./docs/image.md)
|
||||
- [Troubleshooting](./docs/troubleshooting.md)
|
||||
- [Skill Entry](../SKILL.md)
|
||||
|
||||
_Last updated: 2026-07-10_
|
||||
_Last updated: 2026-07-11_
|
||||
|
||||
@@ -33,6 +33,8 @@ from svg_to_pptx.animation_config import ( # noqa: E402
|
||||
build_group_listing,
|
||||
load_animation_config,
|
||||
validate_animation_config,
|
||||
validate_animation_config_errors,
|
||||
validate_transition_config,
|
||||
write_scaffold,
|
||||
)
|
||||
|
||||
@@ -111,10 +113,28 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if not config:
|
||||
print('No animations.json found; default animation policy will be used.')
|
||||
return 0
|
||||
warnings = validate_animation_config(project_path, config)
|
||||
if warnings:
|
||||
for warning in warnings:
|
||||
print(f'Warning: {warning}', file=sys.stderr)
|
||||
errors = list(dict.fromkeys(
|
||||
validate_transition_config(config)
|
||||
+ validate_animation_config_errors(config)
|
||||
))
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f'Error: {error}', file=sys.stderr)
|
||||
return 1
|
||||
reference_messages = validate_animation_config(project_path, config)
|
||||
reference_warnings = [
|
||||
message for message in reference_messages
|
||||
if ' has no id and cannot be customized in animations.json' in message
|
||||
]
|
||||
reference_errors = [
|
||||
message for message in reference_messages
|
||||
if message not in reference_warnings
|
||||
]
|
||||
for warning in reference_warnings:
|
||||
print(f'Warning: {warning}', file=sys.stderr)
|
||||
if reference_errors:
|
||||
for error in reference_errors:
|
||||
print(f'Error: {error}', file=sys.stderr)
|
||||
return 1
|
||||
print('Animation config validated successfully.')
|
||||
return 0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PPT Master - Beautify Inventory Builder
|
||||
|
||||
Mechanically merge a source deck's extracts into one per-slide ledger for the
|
||||
beautify-pptx workflow: text blocks + tables + charts (from a
|
||||
beautify-pptx workflow: text blocks + tables + charts + SmartArt structure (from a
|
||||
`template_fill_pptx.py analyze` slide_library.json) joined with the images
|
||||
bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic
|
||||
join only — `ignored` and `needs_confirmation` are emitted empty for the agent
|
||||
@@ -103,12 +103,27 @@ def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]])
|
||||
}
|
||||
for c in slide.get("charts", [])
|
||||
]
|
||||
diagrams = [
|
||||
{
|
||||
"diagram_id": diagram.get("diagram_id"),
|
||||
"shape_name": diagram.get("shape_name"),
|
||||
"geometry": diagram.get("geometry"),
|
||||
"layout": diagram.get("layout", {}),
|
||||
"root_ids": diagram.get("root_ids", []),
|
||||
"nodes": diagram.get("nodes", []),
|
||||
"connections": diagram.get("connections", []),
|
||||
"status": diagram.get("status"),
|
||||
"warnings": diagram.get("warnings", []),
|
||||
}
|
||||
for diagram in slide.get("diagrams", [])
|
||||
]
|
||||
slides_out.append({
|
||||
"slide_index": idx,
|
||||
"page_type": slide.get("page_type"),
|
||||
"text_blocks": text_blocks,
|
||||
"tables": tables,
|
||||
"charts": charts,
|
||||
"diagrams": diagrams,
|
||||
"images": images_by_slide.get(idx, []),
|
||||
"ignored": [], # agent fills: hidden shapes, master-only text, image crop/rotation
|
||||
"needs_confirmation": [], # agent fills: combo/dual-axis charts, merged-cell tables, overcrowded pages
|
||||
|
||||
@@ -486,64 +486,15 @@ LAYOUT_MARGINS = {
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SVG Technical Specifications
|
||||
# SVG Policy Reference
|
||||
# ============================================================
|
||||
|
||||
# Do not mirror element/attribute rules here. The shared standard is the
|
||||
# authoring authority and the quality checker enforces it.
|
||||
# Keep the exported key for compatibility with existing config consumers.
|
||||
SVG_CONSTRAINTS = {
|
||||
# Forbidden elements - PPT incompatible
|
||||
'forbidden_elements': [
|
||||
# Clipping / Masking
|
||||
# Note: `clipPath` on <image> elements is conditionally allowed — the
|
||||
# converter maps qualifying clip shapes to DrawingML picture geometry.
|
||||
# See references/shared-standards.md §1.2. It is NOT listed here
|
||||
# because this flat list has no per-parent-element semantics; the
|
||||
# actual validation is in svg_quality_checker._check_forbidden_elements.
|
||||
'mask',
|
||||
# Style system
|
||||
'style',
|
||||
# Structure / Nesting
|
||||
'foreignObject',
|
||||
# Text / Fonts
|
||||
'textPath',
|
||||
# Animation / Interaction
|
||||
'animate',
|
||||
'animateMotion',
|
||||
'animateTransform',
|
||||
'animateColor',
|
||||
'set',
|
||||
'script',
|
||||
# Others
|
||||
'iframe',
|
||||
],
|
||||
# Forbidden attributes
|
||||
# Note: marker-start / marker-end are NOT banned — they are conditionally
|
||||
# allowed (see references/shared-standards.md §1.1). The svg_to_pptx
|
||||
# converter maps qualifying <marker> defs to native DrawingML
|
||||
# <a:headEnd>/<a:tailEnd>.
|
||||
'forbidden_attributes': [
|
||||
'class',
|
||||
'id',
|
||||
'onclick', 'onload', 'onmouseover', 'onmouseout',
|
||||
'onfocus', 'onblur', 'onchange',
|
||||
],
|
||||
# Forbidden patterns (regex matching)
|
||||
'forbidden_patterns': [
|
||||
r'@font-face', # Web fonts
|
||||
r'rgba\s*\(', # rgba colors (PPT incompatible)
|
||||
r'<\?xml-stylesheet\b', # External CSS
|
||||
r'<link[^>]*rel\s*=\s*["\']stylesheet["\']',
|
||||
r'@import\s+', # External CSS
|
||||
r'<g[^>]*\sopacity\s*=', # Group opacity
|
||||
r'<image[^>]*\sopacity\s*=', # Image opacity
|
||||
r'\bon\w+\s*=', # Event attributes
|
||||
r'(?s)(?=.*<symbol)(?=.*<use\b)', # <symbol> + <use> complex usage (order-independent)
|
||||
],
|
||||
'recommended_fonts': [
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI'
|
||||
]
|
||||
'authority': 'skills/ppt-master/references/shared-standards.md',
|
||||
'validator': 'skills/ppt-master/scripts/svg_quality_checker.py',
|
||||
}
|
||||
|
||||
|
||||
@@ -642,19 +593,6 @@ class Config:
|
||||
"""
|
||||
return FONT_SIZES.get(size_name, FONT_SIZES['body'])
|
||||
|
||||
@staticmethod
|
||||
def validate_svg_element(element_name: str) -> bool:
|
||||
"""
|
||||
Validate whether an SVG element is allowed.
|
||||
|
||||
Args:
|
||||
element_name: Element name
|
||||
|
||||
Returns:
|
||||
Whether the element is allowed
|
||||
"""
|
||||
return element_name.lower() not in [e.lower() for e in SVG_CONSTRAINTS['forbidden_elements']]
|
||||
|
||||
@staticmethod
|
||||
def get_project_path(subdir: str = '') -> Path:
|
||||
"""
|
||||
|
||||
@@ -194,6 +194,7 @@ Behavior:
|
||||
- extracts slide text in reading order
|
||||
- converts PowerPoint tables to Markdown tables
|
||||
- transcribes native chart data (type + categories × series values) into a Markdown table, so chart numbers are not lost in conversion
|
||||
- transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning
|
||||
- exports embedded pictures to a sibling `_files/` directory
|
||||
- appends speaker notes when present
|
||||
- writes `<input>.conversion_profile.json` after successful conversion
|
||||
@@ -218,15 +219,15 @@ python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
|
||||
|
||||
Outputs (per source deck, prefixed by file stem):
|
||||
- `<stem>.identity.json` — canvas size/aspect, theme palette/fonts, observed colors/fonts
|
||||
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches
|
||||
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, and page types) under `decks[]`, with prefixed artifact pointers
|
||||
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
|
||||
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
|
||||
|
||||
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify / template-fill workflows stay single-deck and read one chosen deck's `<stem>.*` artifacts.
|
||||
|
||||
Usage boundary:
|
||||
- Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default.
|
||||
- Beautify promotes selected identity/content fields into locked constraints after confirmation.
|
||||
- Template-fill uses the slide library as the native PPTX fill contract.
|
||||
- Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes.
|
||||
- Template-fill uses the slide library as the native PPTX fill contract; SmartArt is inventory-only and remains unchanged.
|
||||
|
||||
## `pptx_to_svg.py`
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# PPTX Animation Core
|
||||
|
||||
The shared animation core owns the entrance-effect vocabulary, trigger
|
||||
semantics, OOXML timing writer, semantic read-back, and package validation for
|
||||
PowerPoint OOXML. Per-element animation remains opt-in: generated PPTX export
|
||||
defaults to `none`, exactly as before this validation upgrade.
|
||||
|
||||
## 1. Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Effect registry, timing writer, and read-back | `scripts/pptx_animations.py` |
|
||||
| Sidecar parsing and SVG target discovery | `svg_to_pptx/animation_config.py` |
|
||||
| SVG group-to-shape mapping | `svg_to_pptx/drawingml/converter.py` |
|
||||
| Generated PPTX resolution and validation | `svg_to_pptx/pptx_package/builder.py` |
|
||||
| Narration timing merge | `svg_to_pptx/pptx_package/narration.py` |
|
||||
| Public authoring contract | `references/animations.md` |
|
||||
| Customization workflow | `workflows/customize-animations.md` |
|
||||
|
||||
**Hard rule**: only the generated SVG-to-PPTX route writes object entrance
|
||||
animations. Direct-PPTX routes preserve source animations and run structural
|
||||
package validation; they do not resolve or author animation effects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain Model
|
||||
|
||||
One resolved animation-pane row contains these fields:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| Target | Positive PowerPoint shape id written to `p:spTgt@spid` |
|
||||
| Effect | One exact registry tuple: filter, `presetID`, and `presetSubtype` |
|
||||
| Trigger | `on-click`, `with-previous`, or `after-previous` |
|
||||
| Duration | Finite positive schedule duration; filter effects serialize it as behavior duration |
|
||||
| Delay | Finite non-negative offset used by `after-previous` |
|
||||
| Order | Positive integer sidecar order; ties retain stable SVG order |
|
||||
|
||||
Modes resolve before XML writing:
|
||||
|
||||
| Mode | Resolution |
|
||||
|---|---|
|
||||
| `auto` | Deterministic semantic mapping from the SVG group id |
|
||||
| `mixed` | Deterministic legacy cycle |
|
||||
| `random` | Stable seeded choice from the legacy pool |
|
||||
| `none` | No entrance sequence |
|
||||
|
||||
The same effective input produces the same `random` choices. When enabled,
|
||||
`--conversion-trace` records each resolved row and effect, so a generated deck
|
||||
can be audited without replaying the resolver.
|
||||
|
||||
---
|
||||
|
||||
## 3. Compatibility Contract
|
||||
|
||||
The registry preserves these established 22 tuples exactly:
|
||||
|
||||
| Key | `p:animEffect@filter` | `presetID` | `presetSubtype` |
|
||||
|---|---|---:|---:|
|
||||
| `appear` | none | 1 | 0 |
|
||||
| `fade` | `fade` | 10 | 0 |
|
||||
| `fly` | `slide(fromBottom)` | 2 | 4 |
|
||||
| `cut` | `slide(fromLeft)` | 42 | 8 |
|
||||
| `zoom` | `image` | 23 | 0 |
|
||||
| `wipe` | `wipe(left)` | 22 | 1 |
|
||||
| `split` | `barn(inVertical)` | 16 | 21 |
|
||||
| `blinds` | `blinds(horizontal)` | 3 | 10 |
|
||||
| `checkerboard` | `checkerboard(across)` | 5 | 6 |
|
||||
| `dissolve` | `dissolve` | 9 | 0 |
|
||||
| `random_bars` | `randombar(horizontal)` | 14 | 10 |
|
||||
| `peek` | `wipe(down)` | 12 | 4 |
|
||||
| `wheel` | `wheel(4)` | 21 | 0 |
|
||||
| `box` | `box(in)` | 4 | 0 |
|
||||
| `circle` | `circle(in)` | 6 | 0 |
|
||||
| `diamond` | `diamond(in)` | 8 | 0 |
|
||||
| `plus` | `plus(in)` | 13 | 0 |
|
||||
| `strips` | `strips(downRight)` | 18 | 12 |
|
||||
| `wedge` | `wedge` | 20 | 0 |
|
||||
| `stretch` | `stretch(across)` | 17 | 0 |
|
||||
| `expand` | `stretch(across)` | 50 | 0 |
|
||||
| `swivel` | `wheel(1)` | 19 | 0 |
|
||||
|
||||
`cut` is a legacy public key. Compatibility promises the tuple above; it does
|
||||
not infer a different semantic name from external preset-id tables.
|
||||
|
||||
**Hard rule — no downgrade**:
|
||||
|
||||
- Keep the 22 established tuples byte-for-byte equivalent in meaning.
|
||||
- Reject an unknown effect, mode, or trigger; never substitute another value.
|
||||
- Reject booleans and non-finite, out-of-range, or invalidly ordered values.
|
||||
- Reject a missing slide, missing group, or structural-layer target.
|
||||
- Keep the generated-route default at `none`; validation does not opt a deck in.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target Resolution
|
||||
|
||||
Generated object animation targets top-level SVG content groups. Explicit SVG
|
||||
semantics are authoritative; the group-id chrome heuristic is only a fallback
|
||||
for marker-free legacy SVGs.
|
||||
|
||||
| Target state | Behavior |
|
||||
|---|---|
|
||||
| Ordinary content group | Animatable |
|
||||
| Legacy chrome-like id | Skipped unless explicitly named in `animations.json` |
|
||||
| Explicit sidecar group override | May override only the legacy chrome-name heuristic |
|
||||
| `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable |
|
||||
|
||||
An explicit sidecar entry cannot turn a Master/Layout/Slide structural layer or
|
||||
an explicitly marked static page-frame role/placeholder into an animation
|
||||
target. This boundary preserves PPTX structure even when a legacy id resembles
|
||||
content.
|
||||
|
||||
---
|
||||
|
||||
## 5. OOXML Rules
|
||||
|
||||
The writer emits one root-level `p:timing` after `p:transition` and before
|
||||
`p:extLst`. Its animation tree contains a `tmRoot`, one `mainSeq`, unique
|
||||
`p:cTn@id` values, and `p:spTgt` references to shapes on the same slide.
|
||||
|
||||
Trigger mapping:
|
||||
|
||||
| Public trigger | Entrance `p:cTn@nodeType` |
|
||||
|---|---|
|
||||
| `on-click` | `clickEffect` |
|
||||
| `with-previous` | `withEffect` |
|
||||
| `after-previous` | `afterEffect` |
|
||||
|
||||
The writer does not emit `p:bldP` for grouped content or pictures. Microsoft
|
||||
defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or
|
||||
`p:pic` creates an invalid build reference. Package validation still accepts a
|
||||
valid source `p:bldP` that targets a text-bearing shape.
|
||||
|
||||
Direct-PPTX preserve mode also tolerates an unchanged legacy `p:bldP` that
|
||||
targets an existing group/picture. Earlier PPT Master exports wrote this form;
|
||||
the direct routes fingerprint and preserve it instead of blocking those decks.
|
||||
New generated output never writes it, and generated-package validation remains
|
||||
strict.
|
||||
|
||||
`appear` is the visibility-flip exception: its `p:set` behavior is always 1ms.
|
||||
The configured positive duration remains the row's scheduling span used when
|
||||
computing the next `after-previous` offset; read-back verifies the 1ms behavior
|
||||
and the resulting timeline offset separately.
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation and Read-Back
|
||||
|
||||
Generated export reads every slide back before packaging and compares each
|
||||
requested row with the serialized result:
|
||||
|
||||
- row count and row order;
|
||||
- trigger and shape target;
|
||||
- resolved effect key, filter, `presetID`, and `presetSubtype`;
|
||||
- serialized behavior duration and computed timeline offset (`appear` uses the
|
||||
1ms exception above).
|
||||
|
||||
After packaging, validation scans every slide part for root timing placement,
|
||||
duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
|
||||
targets, and unsupported generated effect tuples. A mismatch fails export
|
||||
before the requested output file replaces an existing deck.
|
||||
|
||||
Narration injection parses and merges the slide DOM. It adds audio timing under
|
||||
the existing `tmRoot` child list, allocates fresh time-node ids, and preserves
|
||||
the object entrance sequence. It does not replace an existing `p:timing` tree.
|
||||
The merge accepts only a direct `p:sld/p:timing` source tree; a timing tree
|
||||
wrapped in `mc:AlternateContent` or another non-root container fails safely
|
||||
instead of being rewritten or duplicated.
|
||||
|
||||
Direct-PPTX routes run the structural package validator with generated-effect
|
||||
enforcement disabled. This permits preservation of source/extension effects and
|
||||
legacy group build rows while still rejecting corrupt timing IDs or missing
|
||||
targets. Template fill and native enhancement fingerprint the source
|
||||
object-animation tree before and after their allowed edits; any semantic change
|
||||
fails. These routes have no object-animation write ownership.
|
||||
|
||||
---
|
||||
|
||||
## 7. Compatibility Scope
|
||||
|
||||
The compatibility contract covers PowerPoint OOXML and PowerPoint read-back.
|
||||
Other presentation applications may interpret timing trees or filter values
|
||||
differently; the exporter does not make an unconditional Keynote guarantee.
|
||||
|
||||
Official references:
|
||||
|
||||
- [Microsoft animation-filter implementation notes](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/a96dab70-2e72-4319-928d-0eb4b275ce58)
|
||||
- [Microsoft `p:bldP` implementation restrictions](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/40d17b6d-30c0-4c10-b042-b2597824a820)
|
||||
- [Open XML SDK time-node values](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.timenodevalues?view=openxml-3.0.1)
|
||||
- [Open XML SDK shape target](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.shapetarget?view=openxml-3.0.1)
|
||||
|
||||
See [`pptx-transitions.md`](./pptx-transitions.md) for the symmetric page-motion
|
||||
core, MCE handling, and slide-advance contract.
|
||||
@@ -0,0 +1,148 @@
|
||||
# PPTX Transition Core
|
||||
|
||||
The shared transition core owns page-transition vocabulary, slide-advance
|
||||
semantics, OOXML mutation, MCE preservation, package timing enablement, and
|
||||
read-back validation for every PPTX route.
|
||||
|
||||
## 1. Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Page transition registry | scripts/pptx_transitions.py |
|
||||
| In-slide object animation | scripts/pptx_animations.py |
|
||||
| Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py |
|
||||
| Template Fill adapter | template_fill_pptx/transitions.py |
|
||||
| Native Enhance adapter | native_narration_pptx.py |
|
||||
| Public workflow | references/animations.md |
|
||||
|
||||
**Hard rule**: adapters resolve route policy, then call the shared core. They
|
||||
must not build, replace, or patch a transition with route-local XML or regex.
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain Model
|
||||
|
||||
| Layer | Meaning | OOXML |
|
||||
|---|---|---|
|
||||
| Enter | How the current slide appears from the preceding slide | Transition effect child plus duration |
|
||||
| Advance | How the current slide leaves for the next slide | advClick and advTm |
|
||||
|
||||
Enter policy:
|
||||
|
||||
| Policy | Behavior |
|
||||
|---|---|
|
||||
| preserve | Keep the source visual transition, including unknown extensions |
|
||||
| replace | Write the requested supported effect |
|
||||
| none | Write no visual effect |
|
||||
|
||||
Advance mode:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| preserve | Keep source advClick and advTm |
|
||||
| click | Click advance only |
|
||||
| after | Timed advance only |
|
||||
| both | Click or timed advance, whichever occurs first |
|
||||
| narration | Timed advance from audio duration plus padding; click disabled |
|
||||
|
||||
**Hard rule**: enter=none may coexist with a timed advance. The valid result is
|
||||
a timing-only p:transition with no visual-effect child.
|
||||
|
||||
---
|
||||
|
||||
## 3. Compatibility Contract
|
||||
|
||||
The Phase 1 registry preserves the established seven effects:
|
||||
|
||||
| Effect | Required child and attributes |
|
||||
|---|---|
|
||||
| fade | p:fade |
|
||||
| push | p:push dir=r |
|
||||
| wipe | p:wipe dir=r |
|
||||
| split | p:split orient=horz dir=out |
|
||||
| strips | p:strips dir=rd |
|
||||
| cover | p:cover dir=r |
|
||||
| random | p:random |
|
||||
|
||||
**Hard rule — no downgrade**:
|
||||
|
||||
- Never rename or remove an established effect.
|
||||
- Never omit its established direction or split attributes.
|
||||
- Reject an unknown requested effect; never substitute fade.
|
||||
- Preserve an unknown source effect when the route selects preserve.
|
||||
- A future PowerPoint extension counts as successful only when the primary
|
||||
Choice contains the requested effect. A fallback alone is not success.
|
||||
|
||||
---
|
||||
|
||||
## 4. Route Mapping
|
||||
|
||||
| Route | Default enter | Default advance | Compatibility note |
|
||||
|---|---|---|---|
|
||||
| Generated PPTX CLI | fade, 0.4s | click | auto-advance maps to both |
|
||||
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
|
||||
| Template Fill v1 | fade, 0.5s | click | keep preserves source; legacy advance_after maps to both |
|
||||
| Native Enhance v1 | Confirmed plan effect | Confirmed timing module | Disabled transitions preserve unless the v1 plan explicitly selected none |
|
||||
|
||||
Template Fill and Native Enhance keep their v1 route defaults in Phase 1.
|
||||
The public `create_pptx_with_native_svg` Python API also retains its legacy
|
||||
0.5s default; the CLI explicitly passes 0.4s. Changing a default policy is a
|
||||
separate migration decision.
|
||||
|
||||
---
|
||||
|
||||
## 5. OOXML Rules
|
||||
|
||||
**Slide child order**:
|
||||
|
||||
~~~text
|
||||
p:cSld
|
||||
p:clrMapOvr
|
||||
p:transition or transition mc:AlternateContent
|
||||
p:timing
|
||||
p:extLst
|
||||
~~~
|
||||
|
||||
One slide may contain at most one logical transition carrier:
|
||||
|
||||
- one direct p:transition; or
|
||||
- one root-level mc:AlternateContent whose Choice/Fallback branches contain
|
||||
p:transition.
|
||||
|
||||
Mutation rules:
|
||||
|
||||
| Operation | Direct transition | AlternateContent |
|
||||
|---|---|---|
|
||||
| preserve | Leave unchanged | Leave wrapper and branches unchanged |
|
||||
| advance-only | Patch direct attributes | Patch Choice and Fallback identically |
|
||||
| replace | Replace the direct carrier | Remove the whole wrapper, then write one carrier |
|
||||
| none | Remove visual carrier; retain timing-only carrier when needed | Remove the whole wrapper; retain timing-only carrier when needed |
|
||||
|
||||
**MCE prefix rule**: Requires and Ignorable values contain textual prefix
|
||||
names. Serialization must retain bindings for those exact names. Renaming an
|
||||
effect prefix without updating these attributes corrupts compatibility.
|
||||
|
||||
**Package timing rule**: when a route writes advTm, set
|
||||
ppt/presProps.xml p:presentationPr/p:showPr useTimings=1. Do not write showPr
|
||||
into ppt/presentation.xml.
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation and Read-Back
|
||||
|
||||
Reject:
|
||||
|
||||
- unknown effect names;
|
||||
- non-finite values, including NaN and Infinity;
|
||||
- duration less than or equal to zero;
|
||||
- negative advance or narration padding;
|
||||
- booleans passed as numeric API values;
|
||||
- multiple logical transition carriers;
|
||||
- unresolved MCE Requires or Ignorable prefixes.
|
||||
|
||||
Read-back must report the primary Choice effect separately from the fallback.
|
||||
It must also report carrier type, duration, click mode, and automatic advance
|
||||
time. Package validation must run after writing, not only before mutation.
|
||||
|
||||
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
|
||||
add a tests directory or test_*.py files.
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export.
|
||||
|
||||
The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||
|
||||
## Recommended Pipeline
|
||||
|
||||
Run these steps in order:
|
||||
@@ -20,9 +22,11 @@ Unified post-processing entry point. This is the preferred way to run SVG cleanu
|
||||
|
||||
It aggregates:
|
||||
- `embed_icons.py`
|
||||
- static same-document `<use>` expansion from `svg_to_pptx/use_expander.py`
|
||||
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
|
||||
- `flatten_tspan.py`
|
||||
- `svg_rect_to_path.py`
|
||||
|
||||
`svg_final/` remains a required Step 7.2 artifact even though the native exporter reads `svg_output/`. It is the self-contained visual reference and may be manually inserted as an SVG picture.
|
||||
|
||||
## `svg_to_pptx.py`
|
||||
|
||||
@@ -35,7 +39,9 @@ python3 scripts/svg_to_pptx.py <project_path> --pptx-structure template # expli
|
||||
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure preserve # imported source package contract
|
||||
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # structure diagnostic
|
||||
# Template-import visual round-trip diagnostic only:
|
||||
python3 scripts/svg_to_pptx.py <template_import_output> --only native -s svg-flat
|
||||
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
|
||||
# Post-processed-source comparison diagnostic only (never a release export):
|
||||
python3 scripts/svg_to_pptx.py <project_path> -s final
|
||||
python3 scripts/svg_to_pptx.py <project_path> --no-notes
|
||||
python3 scripts/svg_to_pptx.py <project_path> -t none
|
||||
python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
|
||||
@@ -49,17 +55,15 @@ Behavior:
|
||||
- Default output (default-flow mode, no `-o`):
|
||||
- `exports/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
|
||||
- `backup/<timestamp>/svg_output/` — copy of Executor SVG source, always written so the pptx can be rebuilt via `finalize_svg → svg_to_pptx` without re-running the LLM
|
||||
- `--svg-snapshot` (opt-in) additionally emits:
|
||||
- `exports/<project_name>_<timestamp>_svg.pptx` — SVG snapshot pptx for visual reference, sibling of the native pptx
|
||||
- Live preview already serves as the SVG visual reference for day-to-day use; the snapshot pptx is for distribution or frozen-state archival
|
||||
- Explicit `-o/--output` skips `backup/`; pair with `--svg-snapshot` to also emit the side-by-side `_svg.pptx` next to the chosen path
|
||||
- `finalize_svg.py` always creates `svg_final/` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX.
|
||||
- Explicit `-o/--output` changes the native PPTX destination and skips `backup/`.
|
||||
- Paragraph merging is enabled by default and trades some SVG line-layout fidelity for PowerPoint editability:
|
||||
- Default: mergeable paragraph blocks (same x, dy clustered around one base line-height, optional larger gap for paragraph breaks) collapse into one editable text frame with multiple `<a:p>` and precise `<a:lnSpc>` / `<a:spcBef>`. Resizing the box reflows text inside it.
|
||||
- With `--no-merge`: every dy-stacked `<tspan>` becomes its own text frame — exact SVG line layout is preserved but a 12-line paragraph is 12 separate textboxes
|
||||
- Side effect: PowerPoint may wrap merged paragraphs to a different line count than the SVG source. Long body text (abstracts, multi-paragraph sections, reference lists) usually benefits from the default; pages with tight typographic alignment (covers, charts, tables) usually want `--no-merge`
|
||||
- Mergeable detection is conservative: only fires when the children form a clean paragraph block; mixed-layout `<text>` falls through to the default per-line path
|
||||
- Native export reads `svg_output/`; `--svg-snapshot` and legacy export read
|
||||
`svg_final/`. Use `-s` only as an explicit diagnostic override.
|
||||
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
|
||||
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
|
||||
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
|
||||
page-design source. Templates and locks may guide authoring, but finalize/export
|
||||
never use them to overlay visible content missing from the SVG. Notes, animation,
|
||||
@@ -70,11 +74,13 @@ Behavior:
|
||||
- Omitting `--pptx-structure` reads `spec_lock.md` and falls back to `baseline`. Baseline assigns Layout families from root `data-pptx-page-role`, keeps content Slide-local, and promotes only exact family-wide backgrounds plus exact leading structurally marked chrome; filenames and ids are compatibility fallbacks for marker-free legacy SVGs. It never infers placeholders or visual similarity. Template mode builds reusable PowerPoint structure only from explicit SVG metadata and validates every `pptx_layouts` row plus cross-slide equality. Both strict and adaptive template adherence use this mode; adaptive may introduce a new Layout key under the same Master.
|
||||
- Template/preserve placeholder semantics distinguish title, subtitle, body, picture, chart, table, generic object, media, date, footer, and slide number. Reconstructed titles are normally type-matched without an index; explicit imported title indices and all other source indices are retained. Imported `subTitle`, `obj`, `media`, and `dt` identities remain distinct through `manifest.json`, `native_structure.json`, Layout XML, and Slide XML.
|
||||
- Baseline/template native export reads `spec_lock.md` typography into the PowerPoint theme: `title_family` becomes the major font and `body_family` / `font_family` becomes the minor font. Matching SVG text emits `+mj-*` / `+mn-*` tokens, while unrelated emphasis/code/brand families stay concrete. Preserve mode keeps the imported source theme; flat mode keeps fixed-font diagnostic output.
|
||||
- Template mode also requires numeric `typography.title` and `typography.body` rows. It writes the title size to every Master `p:titleStyle` level and the body size to every `p:bodyStyle` / `p:otherStyle` level. Each generated Layout text placeholder also stores the prototype's first direct run size in `a:lstStyle/a:lvl1pPr/a:defRPr@sz` while retaining its prompt run size. Slide direct-run sizes remain unchanged. Missing title/body rows fail template export; baseline, preserve, and flat modes do not apply these updates.
|
||||
- Baseline/template native export also maps canonical `spec_lock.md` color roles into the PowerPoint color scheme and emits context-safe `schemeClr` tokens for exact matches in SVG fills/text/strokes, gradients/patterns/bullets, native tables, and native-chart accent series. Local colors, inverse white/black, and effects stay concrete. Preserve mode keeps the imported source color scheme; flat mode keeps fixed-color diagnostic output.
|
||||
- Preserve mode is legacy strict-only compatibility for existing projects that already carry `native_structure.json` + `source_template.pptx`. Current template creation does not emit the pair.
|
||||
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
|
||||
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
|
||||
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
|
||||
- Before publishing template-mode native output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication; other structure modes skip this gate.
|
||||
- SVG clip paths are still restricted for authored SVGs, but nested crop wrappers generated by PPTX import are mapped back to native picture crop / geometry when possible.
|
||||
- Speaker notes are embedded automatically unless `--no-notes` is used
|
||||
- Recorded narration is opt-in:
|
||||
@@ -82,6 +88,7 @@ Behavior:
|
||||
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
|
||||
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
|
||||
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from audio duration
|
||||
- Narration timing is merged into the existing slide timing DOM; object entrance rows and the resolved page transition are preserved rather than regenerated
|
||||
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
|
||||
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
|
||||
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
|
||||
@@ -89,6 +96,7 @@ Behavior:
|
||||
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
|
||||
- Page transitions are controlled by `-t/--transition`; per-element entrance animations are controlled by `-a/--animation`
|
||||
- Per-element animation applies to top-level SVG `<g id="...">` groups in z-order; aim for 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
|
||||
- An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker
|
||||
- Start mode is set by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), `with-previous` (all together on slide entry)
|
||||
- `on-click` is for live presentations only; recorded narration rejects it because the tool does not generate object-level click timings
|
||||
- Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide
|
||||
@@ -98,15 +106,13 @@ Behavior:
|
||||
ids (hero/figure-/image/img-/kpi) cycle through a richer pool
|
||||
(zoom/dissolve/circle/box/diamond/wheel), while unmatched ids cycle through
|
||||
fade/wipe/fly/zoom.
|
||||
- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` samples from that same legacy pool
|
||||
- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` uses a stable seed from the effective deck input, and `--conversion-trace` records each resolved effect when enabled
|
||||
- `--animation-duration` controls per-element entrance length (default `0.4`); `--animation-stagger` adds gap between elements in `after-previous` mode (default `0.5`)
|
||||
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`
|
||||
|
||||
Performance (legacy `_svg.pptx` PNG fallback, only when `--svg-snapshot` or `--only legacy`):
|
||||
- SVG→PNG is pre-rendered in a process pool before the main loop. Default workers = `min(cpu, pages, 8)`; override with `--workers N` (set `1` for sequential, `0` is treated as sequential).
|
||||
- Results are cached at `<project>/.cache/svg_png/` keyed by SVG content hash + size + active renderer (`cairosvg` vs `svglib`). Switching renderers naturally invalidates the cache; nothing to clean by hand.
|
||||
- `--cache-dir <path>` relocates the cache; `--no-cache` forces re-render without writing/reading the cache (handy when debugging rendering).
|
||||
- Native mode (`--only native`) is unaffected — that path embeds DrawingML shapes and never touches PNG.
|
||||
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
|
||||
- Generated export reads every slide back and verifies animation row order, trigger, shape target, resolved effect tuple, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
|
||||
- The animation writer does not emit `p:bldP` for groups or pictures. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects
|
||||
- The full registry, OOXML rules, and compatibility boundary are documented in [`pptx-animations.md`](./pptx-animations.md)
|
||||
|
||||
Dependency:
|
||||
|
||||
@@ -187,16 +193,6 @@ python3 scripts/svg_finalize/flatten_tspan.py examples/<project>/svg_output
|
||||
python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg
|
||||
```
|
||||
|
||||
### `svg_rect_to_path.py`
|
||||
|
||||
```bash
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path>
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py path/to/file.svg
|
||||
```
|
||||
|
||||
Use when rounded corners must survive PowerPoint shape conversion.
|
||||
|
||||
### `align_embed_images.py`
|
||||
|
||||
```bash
|
||||
@@ -220,17 +216,13 @@ python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
|
||||
|
||||
Replaces `<use data-icon="chunk-filled/name" .../>`, `<use data-icon="tabler-filled/name" .../>` and `<use data-icon="tabler-outline/name" .../>` placeholders with actual SVG path elements. Use for manual icon embedding checks outside `finalize_svg.py`.
|
||||
|
||||
## PPT Compatibility Rules
|
||||
## SVG Compatibility Contract
|
||||
|
||||
Use PowerPoint-safe transparency syntax:
|
||||
The canonical SVG authoring and native-mapping contract lives exclusively in
|
||||
[`shared-standards.md`](../../references/shared-standards.md). This tool guide
|
||||
does not repeat accepted syntax, rejected constructs, or conditional limits.
|
||||
|
||||
| Avoid | Use instead |
|
||||
|------|-------------|
|
||||
| `fill=\"rgba(...)\"` | `fill=\"#hex\"` + `fill-opacity` |
|
||||
| `<g opacity=\"...\">` | Set opacity on each child |
|
||||
| `<image opacity=\"...\">` | Overlay with a mask layer |
|
||||
|
||||
PowerPoint also has trouble with unsupported filters and direct SVG features
|
||||
not mapped to DrawingML. Connector arrows may use qualified
|
||||
`marker-start` / `marker-end`; chunky or exotic arrows should be standalone
|
||||
`<path>` / `<polygon>` shapes.
|
||||
`svg_quality_checker.py` validates source SVG before finalization.
|
||||
`finalize_svg.py` and native export apply the preprocessing required by that
|
||||
contract, while native conversion fails on unsupported visual elements rather
|
||||
than silently dropping them.
|
||||
|
||||
@@ -15,12 +15,15 @@ python3 scripts/project_manager.py validate <project_path>
|
||||
|
||||
1. Check the file path and filename.
|
||||
2. Confirm naming conventions are consistent.
|
||||
3. Preview via a local server if browser file loading is inconsistent:
|
||||
3. Run the mandatory post-processing step when you need a self-contained preview:
|
||||
|
||||
```bash
|
||||
python3 -m http.server --directory <svg_output_path> 8000
|
||||
python3 scripts/finalize_svg.py <project_path>
|
||||
python3 -m http.server --directory <project_path>/svg_final 8000
|
||||
```
|
||||
|
||||
`svg_final/` is the visual-preview artifact and may be inserted into PowerPoint as an SVG picture. Fix authored content in `svg_output/`, then regenerate `svg_final/`; do not edit the derived preview as the source.
|
||||
|
||||
## Speaker Notes Do Not Split
|
||||
|
||||
Check `total.md`:
|
||||
@@ -44,7 +47,11 @@ python3 scripts/finalize_svg.py <project_path>
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
Do not export directly from `svg_output/` when `svg_final/` exists.
|
||||
Keep all three steps even though they have different consumers: Step 7.2 creates the mandatory `svg_final/` visual preview, while the supported native PPTX exporter reads `svg_output/` directly. Do not pass `-s final` for a release export; that override is diagnostic-only.
|
||||
|
||||
## Inserted SVG Does Not Convert Cleanly to Shapes
|
||||
|
||||
PowerPoint's manual Convert-to-Shape behavior is unsupported. `svg_final/` is supported only as a visual preview / SVG picture. Use the native PPTX generated from `svg_output/` when editable DrawingML shapes are required.
|
||||
|
||||
## Recorded Narration Missing
|
||||
|
||||
|
||||
@@ -162,11 +162,11 @@ class ErrorHelper:
|
||||
'severity': 'error'
|
||||
},
|
||||
'id_attribute_detected': {
|
||||
'message': 'Forbidden id attribute detected',
|
||||
'message': 'Forbidden CSS selector usage with id detected',
|
||||
'solutions': [
|
||||
'Remove all id attributes',
|
||||
'Use inline styles instead',
|
||||
'Avoid relying on selectors for positioning or style reuse'
|
||||
'Keep IDs for local references or documented semantic/animation groups',
|
||||
'Remove <style> rules and CSS selectors',
|
||||
'Use inline presentation attributes instead'
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
@@ -180,15 +180,6 @@ class ErrorHelper:
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
'symbol_use_detected': {
|
||||
'message': 'Forbidden <symbol> + <use> complex usage detected',
|
||||
'solutions': [
|
||||
'Expand <symbol> into actual SVG code',
|
||||
'Avoid <symbol> + <use> reuse structures',
|
||||
'Embed SVG paths directly when icons are needed'
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
# Note: <marker> and marker-end are NO LONGER forbidden — they are
|
||||
# conditionally allowed (see references/shared-standards.md §1.1).
|
||||
# The converter maps qualifying markers to native DrawingML arrow heads.
|
||||
@@ -201,33 +192,6 @@ class ErrorHelper:
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
'rgba_detected': {
|
||||
'message': 'Forbidden rgba() color detected',
|
||||
'solutions': [
|
||||
'Replace rgba() with hex + opacity notation',
|
||||
'Example: fill="#FFFFFF" fill-opacity="0.1"',
|
||||
'Use stroke-opacity for strokes'
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
'group_opacity_detected': {
|
||||
'message': 'Forbidden <g opacity> detected',
|
||||
'solutions': [
|
||||
'Remove group-level opacity',
|
||||
'Set opacity individually on each child element',
|
||||
'Use fill-opacity / stroke-opacity for control'
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
'image_opacity_detected': {
|
||||
'message': 'Forbidden <image opacity> detected',
|
||||
'solutions': [
|
||||
'Remove image opacity attribute',
|
||||
'Add a <rect> overlay to control transparency',
|
||||
'Ensure overlay color matches the background'
|
||||
],
|
||||
'severity': 'error'
|
||||
},
|
||||
'event_attribute_detected': {
|
||||
'message': 'Forbidden event attribute detected',
|
||||
'solutions': [
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"""
|
||||
PPT Master - SVG Post-processing Tool (Unified Entry Point)
|
||||
|
||||
Processes SVG files from svg_output/ and outputs them to svg_final/.
|
||||
By default, all processing steps are executed. You can also specify
|
||||
individual steps via arguments.
|
||||
Processes SVG files from svg_output/ and produces the visual preview in
|
||||
svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
|
||||
to read svg_output/ by default; svg_final/ may be opened directly or inserted
|
||||
as an SVG image. EMF/WMF assets retain their external-reference exception.
|
||||
By default, all processing steps are executed. You can also specify individual
|
||||
steps via arguments.
|
||||
|
||||
Architecture note: this module's outputs feed svg_final/ on disk AND its
|
||||
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
|
||||
@@ -17,27 +20,26 @@ Usage:
|
||||
python3 scripts/finalize_svg.py <project_directory>
|
||||
|
||||
# Execute only specific steps
|
||||
python3 scripts/finalize_svg.py <project_directory> --only embed-icons fix-rounded
|
||||
python3 scripts/finalize_svg.py <project_directory> --only embed-icons align-images
|
||||
|
||||
Examples:
|
||||
python3 scripts/finalize_svg.py projects/my_project
|
||||
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
|
||||
|
||||
Processing options:
|
||||
embed-icons - Replace <use data-icon="..."/> with actual icon SVG
|
||||
embed-icons - Expand project icons and static same-document <use>
|
||||
align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||
Replaces the former crop-images + fix-aspect + embed-images
|
||||
trio. The old names remain accepted as aliases for the
|
||||
merged step, so existing --only invocations keep working.
|
||||
flatten-text - Convert <tspan> to independent <text> (for special renderers)
|
||||
fix-rounded - Convert <rect rx="..."/> to <path> (for PPT shape conversion)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
|
||||
@@ -51,6 +53,14 @@ from svg_finalize.align_embed_images import (
|
||||
count_office_vector_refs_in_svg,
|
||||
)
|
||||
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
|
||||
from svg_to_pptx.geometry_properties import (
|
||||
GeometryStyleError,
|
||||
materialize_inline_geometry_in_file,
|
||||
)
|
||||
from svg_to_pptx.use_expander import (
|
||||
UseExpansionError,
|
||||
expand_local_use_references_in_file,
|
||||
)
|
||||
|
||||
|
||||
def safe_print(text: str) -> None:
|
||||
@@ -92,28 +102,6 @@ def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def process_rounded_rect(svg_file: Path, verbose: bool = False) -> int:
|
||||
"""Convert rounded rectangles in a single SVG file (in-place modification)"""
|
||||
try:
|
||||
from svg_finalize.svg_rect_to_path import process_svg
|
||||
|
||||
with open(svg_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
processed, count = process_svg(content, verbose=False)
|
||||
|
||||
if count > 0:
|
||||
with open(svg_file, 'w', encoding='utf-8') as f:
|
||||
f.write(processed)
|
||||
if verbose:
|
||||
safe_print(f" [OK] {svg_file.name}: {count} rounded rectangle(s)")
|
||||
return count
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
safe_print(f" [ERROR] {svg_file.name}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def finalize_project(
|
||||
project_dir: Path,
|
||||
options: dict[str, bool],
|
||||
@@ -167,19 +155,62 @@ def finalize_project(
|
||||
if not quiet:
|
||||
print()
|
||||
|
||||
# Step 2: Embed icons
|
||||
# Core normalization: downstream image/rect processors read XML geometry.
|
||||
geometry_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
# Step 2: Expand project icons, then standard same-document use references.
|
||||
if options.get('embed_icons'):
|
||||
if not quiet:
|
||||
safe_print("[1/4] Embedding icons...")
|
||||
safe_print("[1/3] Expanding icons + local use references...")
|
||||
icons_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir)
|
||||
count = embed_icons_in_file(
|
||||
svg_file,
|
||||
icons_dir,
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
fallback_dir=icons_fallback_dir,
|
||||
)
|
||||
icons_count += count
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: expanded icon geometry "
|
||||
f"materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
local_use_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
local_use_count += expand_local_use_references_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, UseExpansionError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
|
||||
)
|
||||
return False
|
||||
if not quiet:
|
||||
if icons_count > 0:
|
||||
safe_print(f" {icons_count} icon(s) embedded")
|
||||
else:
|
||||
safe_print(" No icons")
|
||||
if local_use_count > 0:
|
||||
safe_print(f" {local_use_count} local use reference(s) expanded")
|
||||
else:
|
||||
safe_print(" No local use references")
|
||||
|
||||
if not quiet and geometry_count:
|
||||
safe_print(
|
||||
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
|
||||
)
|
||||
|
||||
# Step 3: Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||
# Replaces the former crop-images / fix-aspect / embed-images trio: the
|
||||
@@ -189,7 +220,7 @@ def finalize_project(
|
||||
# from disk once.
|
||||
if options.get('align_images'):
|
||||
if not quiet:
|
||||
safe_print("[2/4] Aligning + embedding images...")
|
||||
safe_print("[2/3] Aligning + embedding images...")
|
||||
img_count = 0
|
||||
img_errors = 0
|
||||
office_vector_count = 0
|
||||
@@ -227,7 +258,7 @@ def finalize_project(
|
||||
# Step 4: Flatten text
|
||||
if options.get('flatten_text'):
|
||||
if not quiet:
|
||||
safe_print("[3/4] Flattening text...")
|
||||
safe_print("[3/3] Flattening text...")
|
||||
flatten_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
if process_flatten_text(svg_file, verbose=False):
|
||||
@@ -238,20 +269,6 @@ def finalize_project(
|
||||
else:
|
||||
safe_print(" No processing needed")
|
||||
|
||||
# Step 5: Convert rounded rects to Path
|
||||
if options.get('fix_rounded'):
|
||||
if not quiet:
|
||||
safe_print("[4/4] Converting rounded rects to Path...")
|
||||
rounded_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
count = process_rounded_rect(svg_file, verbose=False)
|
||||
rounded_count += count
|
||||
if not quiet:
|
||||
if rounded_count > 0:
|
||||
safe_print(f" {rounded_count} rounded rectangle(s) converted")
|
||||
else:
|
||||
safe_print(" No rounded rectangles")
|
||||
|
||||
# Done
|
||||
if not quiet:
|
||||
print()
|
||||
@@ -271,14 +288,13 @@ def main() -> None:
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s projects/my_project # Execute all processing (default)
|
||||
%(prog)s projects/my_project --only embed-icons fix-rounded
|
||||
%(prog)s projects/my_project --only embed-icons align-images
|
||||
%(prog)s projects/my_project -q # Quiet mode
|
||||
|
||||
Processing options (for --only):
|
||||
embed-icons Embed icons
|
||||
embed-icons Expand project icons and static same-document <use>
|
||||
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
|
||||
flatten-text Flatten text
|
||||
fix-rounded Convert rounded rects to Path
|
||||
|
||||
Aliases (still accepted):
|
||||
crop-images, fix-aspect, embed-images → all map to align-images
|
||||
@@ -293,7 +309,7 @@ Aliases (still accepted):
|
||||
'align-images',
|
||||
# Backwards-compatible aliases — all three map to align-images now.
|
||||
'crop-images', 'fix-aspect', 'embed-images',
|
||||
'flatten-text', 'fix-rounded',
|
||||
'flatten-text',
|
||||
],
|
||||
help=('Execute only specified processing steps (default: all). '
|
||||
'crop-images / fix-aspect / embed-images are accepted as '
|
||||
@@ -329,7 +345,6 @@ Aliases (still accepted):
|
||||
'embed_icons': 'embed-icons' in only,
|
||||
'align_images': bool(only & _ALIGN_ALIASES),
|
||||
'flatten_text': 'flatten-text' in only,
|
||||
'fix_rounded': 'fix-rounded' in only,
|
||||
}
|
||||
else:
|
||||
# Execute all by default
|
||||
@@ -337,7 +352,6 @@ Aliases (still accepted):
|
||||
'embed_icons': True,
|
||||
'align_images': True,
|
||||
'flatten_text': True,
|
||||
'fix_rounded': True,
|
||||
}
|
||||
|
||||
if args.max_dimension < 1:
|
||||
|
||||
+194
-91
@@ -42,7 +42,19 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from pptx_animations import TRANSITIONS, create_transition_xml # noqa: E402
|
||||
from pptx_animations import ( # noqa: E402
|
||||
object_animation_fingerprint,
|
||||
validate_pptx_animation_package,
|
||||
)
|
||||
from pptx_transitions import ( # noqa: E402
|
||||
AdvanceUpdate,
|
||||
EnterUpdate,
|
||||
TRANSITIONS,
|
||||
apply_slide_motion_xml,
|
||||
set_directory_use_timings,
|
||||
validate_pptx_transition_package,
|
||||
validate_seconds,
|
||||
)
|
||||
from svg_to_pptx.pptx_package.builder import ( # noqa: E402
|
||||
_add_default_content_type,
|
||||
_append_relationship,
|
||||
@@ -55,7 +67,6 @@ from svg_to_pptx.pptx_package.narration import ( # noqa: E402
|
||||
IMAGE_REL_TYPE,
|
||||
MEDIA_REL_TYPE,
|
||||
NARRATION_EXTENSIONS,
|
||||
apply_recorded_timing,
|
||||
inject_narration,
|
||||
next_shape_id,
|
||||
probe_audio_duration,
|
||||
@@ -96,6 +107,20 @@ def _sanitize_slug(value: str) -> str:
|
||||
return slug or "native_enhance"
|
||||
|
||||
|
||||
def _positive_seconds_arg(value: str) -> float:
|
||||
try:
|
||||
return validate_seconds(value, "transition duration", allow_zero=False)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _non_negative_seconds_arg(value: str) -> float:
|
||||
try:
|
||||
return validate_seconds(value, "narration padding", allow_zero=True)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -276,39 +301,6 @@ def _add_notes_content_types(content_types: str, note_indices: set[int]) -> str:
|
||||
return content_types
|
||||
|
||||
|
||||
def _set_transition_only(slide_xml: str, effect: str, duration: float) -> str:
|
||||
transition_xml = create_transition_xml(effect=effect, duration=duration)
|
||||
if re.search(r"<p:transition\b[^>]*/>", slide_xml):
|
||||
return re.sub(r"\s*<p:transition\b[^>]*/>", "\n" + transition_xml, slide_xml, count=1)
|
||||
if re.search(r"<p:transition\b[^>]*>.*?</p:transition>", slide_xml, re.S):
|
||||
return re.sub(
|
||||
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
|
||||
"\n" + transition_xml,
|
||||
slide_xml,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
if "<p:timing>" in slide_xml:
|
||||
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
|
||||
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
|
||||
|
||||
|
||||
def _apply_recorded_timing_without_transition(slide_xml: str, advance_after: float) -> str:
|
||||
adv_ms = max(1, int(advance_after * 1000))
|
||||
transition_xml = f' <p:transition advTm="{adv_ms}"/>'
|
||||
slide_xml = re.sub(r"\s*<p:transition\b[^>]*/>", "", slide_xml, count=1)
|
||||
slide_xml = re.sub(
|
||||
r"\s*<p:transition\b[^>]*>.*?</p:transition>",
|
||||
"",
|
||||
slide_xml,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
if "<p:timing>" in slide_xml:
|
||||
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
|
||||
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
|
||||
|
||||
|
||||
def _apply_notes(extract_dir: Path, slide: SlidePart, note_md: Path) -> None:
|
||||
notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8"))
|
||||
if not notes_text:
|
||||
@@ -346,10 +338,10 @@ def _apply_audio(
|
||||
slide: SlidePart,
|
||||
audio_path: Path,
|
||||
*,
|
||||
transition: str,
|
||||
transition_duration: float,
|
||||
enter: EnterUpdate,
|
||||
timings_enabled: bool,
|
||||
narration_padding: float,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
media_dir = extract_dir / "ppt" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -370,6 +362,7 @@ def _apply_audio(
|
||||
|
||||
slide_xml_path = extract_dir / slide.part_name
|
||||
slide_xml = slide_xml_path.read_text(encoding="utf-8")
|
||||
source_animation_fingerprint = object_animation_fingerprint(slide_xml)
|
||||
shape_id = next_shape_id(slide_xml)
|
||||
slide_xml = inject_narration(
|
||||
slide_xml,
|
||||
@@ -380,20 +373,29 @@ def _apply_audio(
|
||||
poster_rid=poster_rid,
|
||||
)
|
||||
|
||||
duration = probe_audio_duration(audio_path)
|
||||
if duration is None:
|
||||
raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}")
|
||||
advance_after = duration + narration_padding
|
||||
if transition == "none":
|
||||
slide_xml = _apply_recorded_timing_without_transition(slide_xml, advance_after)
|
||||
else:
|
||||
slide_xml = apply_recorded_timing(
|
||||
advance = AdvanceUpdate(mode="preserve")
|
||||
if timings_enabled:
|
||||
duration = probe_audio_duration(audio_path)
|
||||
if duration is None:
|
||||
raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}")
|
||||
advance = AdvanceUpdate(
|
||||
mode="narration",
|
||||
after=duration + narration_padding,
|
||||
)
|
||||
|
||||
wrote_advance = False
|
||||
if enter.policy != "preserve" or timings_enabled:
|
||||
slide_xml, wrote_advance = apply_slide_motion_xml(
|
||||
slide_xml,
|
||||
advance_after=advance_after,
|
||||
transition_duration=transition_duration,
|
||||
transition_effect=transition,
|
||||
enter=enter,
|
||||
advance=advance,
|
||||
)
|
||||
if object_animation_fingerprint(slide_xml) != source_animation_fingerprint:
|
||||
raise RuntimeError(
|
||||
f"Slide {slide.index} object animations changed while adding narration"
|
||||
)
|
||||
slide_xml_path.write_text(slide_xml, encoding="utf-8")
|
||||
return timings_enabled and wrote_advance
|
||||
|
||||
|
||||
def _update_content_types(extract_dir: Path, note_indices: set[int], audio_exts: set[str]) -> None:
|
||||
@@ -441,6 +443,32 @@ def _enabled_modules(plan: dict) -> set[str]:
|
||||
return enabled
|
||||
|
||||
|
||||
def _resolve_enter_update(
|
||||
*,
|
||||
cli_effect: str | None,
|
||||
configured_effect: object,
|
||||
transitions_enabled: bool,
|
||||
duration: float,
|
||||
) -> EnterUpdate:
|
||||
if cli_effect is None and not transitions_enabled:
|
||||
if configured_effect == "none":
|
||||
return EnterUpdate(policy="none", effect=None, duration=duration)
|
||||
return EnterUpdate(policy="preserve", duration=duration)
|
||||
|
||||
effect = cli_effect if cli_effect is not None else configured_effect
|
||||
if not isinstance(effect, str):
|
||||
raise ValueError(f"transition effect must be a string: {effect!r}")
|
||||
if effect == "none":
|
||||
return EnterUpdate(policy="none", effect=None, duration=duration)
|
||||
if effect not in TRANSITIONS:
|
||||
valid = ", ".join(sorted(TRANSITIONS))
|
||||
raise ValueError(
|
||||
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
|
||||
)
|
||||
|
||||
return EnterUpdate(policy="replace", effect=effect, duration=duration)
|
||||
|
||||
|
||||
def _plan_confirmed(plan: dict) -> bool:
|
||||
return plan.get("status") == "confirmed"
|
||||
|
||||
@@ -661,32 +689,64 @@ def apply_project(args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
|
||||
modules = _enabled_modules(plan)
|
||||
transitions_cfg = (
|
||||
plan.get("modules", {}).get("transitions", {})
|
||||
if isinstance(plan.get("modules"), dict)
|
||||
else {}
|
||||
)
|
||||
timings_cfg = (
|
||||
plan.get("modules", {}).get("timings", {})
|
||||
if isinstance(plan.get("modules"), dict)
|
||||
else {}
|
||||
)
|
||||
transition = (
|
||||
args.transition
|
||||
or transitions_cfg.get("effect")
|
||||
or transition_cfg.get("effect")
|
||||
or "fade"
|
||||
)
|
||||
transition_duration = (
|
||||
args.transition_duration
|
||||
or transitions_cfg.get("duration")
|
||||
or float(transition_cfg.get("duration") or 0.5)
|
||||
)
|
||||
narration_padding = (
|
||||
args.narration_padding
|
||||
if args.narration_padding is not None
|
||||
else float(timings_cfg.get("narration_padding") or 0.4)
|
||||
)
|
||||
modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {}
|
||||
transitions_cfg = modules_cfg.get("transitions", {})
|
||||
if not isinstance(transitions_cfg, dict):
|
||||
transitions_cfg = {}
|
||||
timings_cfg = modules_cfg.get("timings", {})
|
||||
if not isinstance(timings_cfg, dict):
|
||||
timings_cfg = {}
|
||||
|
||||
if "effect" in transitions_cfg:
|
||||
configured_effect = transitions_cfg["effect"]
|
||||
elif "effect" in transition_cfg:
|
||||
configured_effect = transition_cfg["effect"]
|
||||
else:
|
||||
configured_effect = "fade"
|
||||
|
||||
if args.transition_duration is not None:
|
||||
raw_transition_duration = args.transition_duration
|
||||
elif "duration" in transitions_cfg:
|
||||
raw_transition_duration = transitions_cfg["duration"]
|
||||
elif "duration" in transition_cfg:
|
||||
raw_transition_duration = transition_cfg["duration"]
|
||||
else:
|
||||
raw_transition_duration = 0.5
|
||||
|
||||
if args.narration_padding is not None:
|
||||
raw_narration_padding = args.narration_padding
|
||||
elif "narration_padding" in timings_cfg:
|
||||
raw_narration_padding = timings_cfg["narration_padding"]
|
||||
else:
|
||||
raw_narration_padding = 0.4
|
||||
|
||||
try:
|
||||
if args.transition is not None or "transitions" in modules:
|
||||
transition_duration = validate_seconds(
|
||||
raw_transition_duration,
|
||||
"transition duration",
|
||||
allow_zero=False,
|
||||
)
|
||||
else:
|
||||
transition_duration = 0.5
|
||||
if "timings" in modules:
|
||||
narration_padding = validate_seconds(
|
||||
raw_narration_padding,
|
||||
"narration padding",
|
||||
allow_zero=True,
|
||||
)
|
||||
else:
|
||||
narration_padding = 0.4
|
||||
enter_update = _resolve_enter_update(
|
||||
cli_effect=args.transition,
|
||||
configured_effect=configured_effect,
|
||||
transitions_enabled="transitions" in modules,
|
||||
duration=transition_duration,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
apply_transition_without_audio = (
|
||||
args.apply_transition_without_audio
|
||||
or bool(transitions_cfg.get("apply_without_audio"))
|
||||
@@ -710,6 +770,7 @@ def apply_project(args: argparse.Namespace) -> int:
|
||||
audio_exts: set[str] = set()
|
||||
audio_count = 0
|
||||
transition_only_count = 0
|
||||
wrote_auto_advance = False
|
||||
for slide in slides:
|
||||
note = _note_path(notes_dir, slide.index)
|
||||
if "notes" in modules and note:
|
||||
@@ -718,33 +779,75 @@ def apply_project(args: argparse.Namespace) -> int:
|
||||
|
||||
audio = _audio_path(audio_dir, slide.index)
|
||||
if "audio" in modules and audio:
|
||||
_apply_audio(
|
||||
wrote_auto_advance = _apply_audio(
|
||||
extract_dir,
|
||||
slide,
|
||||
audio,
|
||||
transition=transition if "transitions" in modules else "none",
|
||||
transition_duration=transition_duration,
|
||||
narration_padding=narration_padding if "timings" in modules else 0,
|
||||
)
|
||||
enter=enter_update,
|
||||
timings_enabled="timings" in modules,
|
||||
narration_padding=narration_padding,
|
||||
) or wrote_auto_advance
|
||||
audio_exts.add(audio.suffix.lower())
|
||||
audio_count += 1
|
||||
continue
|
||||
|
||||
if (
|
||||
"transitions" in modules
|
||||
and apply_transition_without_audio
|
||||
and transition != "none"
|
||||
apply_transition_without_audio
|
||||
and enter_update.policy != "preserve"
|
||||
):
|
||||
slide_xml_path = extract_dir / slide.part_name
|
||||
slide_xml = slide_xml_path.read_text(encoding="utf-8")
|
||||
source_animation_fingerprint = object_animation_fingerprint(
|
||||
slide_xml
|
||||
)
|
||||
slide_xml, _uses_timings = apply_slide_motion_xml(
|
||||
slide_xml,
|
||||
enter=enter_update,
|
||||
advance=AdvanceUpdate(mode="preserve"),
|
||||
)
|
||||
if (
|
||||
object_animation_fingerprint(slide_xml)
|
||||
!= source_animation_fingerprint
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Slide {slide.index} object animations changed while "
|
||||
"updating the transition"
|
||||
)
|
||||
slide_xml_path.write_text(
|
||||
_set_transition_only(slide_xml, transition, transition_duration),
|
||||
slide_xml,
|
||||
encoding="utf-8",
|
||||
)
|
||||
transition_only_count += 1
|
||||
|
||||
_update_content_types(extract_dir, note_indices, audio_exts)
|
||||
_zip_dir(extract_dir, output_path)
|
||||
if wrote_auto_advance:
|
||||
set_directory_use_timings(extract_dir)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="native-enhance-output-",
|
||||
dir=output_path.parent,
|
||||
) as output_tmp:
|
||||
candidate_path = Path(output_tmp) / output_path.name
|
||||
_zip_dir(extract_dir, candidate_path)
|
||||
try:
|
||||
validate_pptx_transition_package(
|
||||
candidate_path,
|
||||
require_use_timings=wrote_auto_advance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX transition package validation failed: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_animation_package(
|
||||
candidate_path,
|
||||
require_supported_effects=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX animation/timing package validation failed: {exc}"
|
||||
) from exc
|
||||
candidate_path.replace(output_path)
|
||||
|
||||
print(f"Output: {output_path}", file=sys.stderr)
|
||||
print(f"Notes applied: {len(note_indices)}", file=sys.stderr)
|
||||
@@ -813,8 +916,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
init.add_argument("--project-dir", default=None, help="explicit project directory")
|
||||
init.add_argument("--projects-root", default="projects", help="projects root (default: projects)")
|
||||
init.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()))
|
||||
init.add_argument("--transition-duration", type=float, default=0.5)
|
||||
init.add_argument("--narration-padding", type=float, default=0.4)
|
||||
init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
|
||||
init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
|
||||
init.add_argument(
|
||||
"--apply-transition-without-audio",
|
||||
action="store_true",
|
||||
@@ -825,8 +928,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
plan = subparsers.add_parser("plan", help="draft an enhancement module plan")
|
||||
plan.add_argument("project_path", help="native enhancement project directory")
|
||||
plan.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()) + ["none"])
|
||||
plan.add_argument("--transition-duration", type=float, default=0.5)
|
||||
plan.add_argument("--narration-padding", type=float, default=0.4)
|
||||
plan.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
|
||||
plan.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
|
||||
plan.add_argument(
|
||||
"--apply-transition-without-audio",
|
||||
action="store_true",
|
||||
@@ -839,8 +942,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
apply.add_argument("-o", "--output", default=None, help="output .pptx path")
|
||||
apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists")
|
||||
apply.add_argument("--transition", default=None, choices=sorted(TRANSITIONS.keys()) + ["none"])
|
||||
apply.add_argument("--transition-duration", type=float, default=None)
|
||||
apply.add_argument("--narration-padding", type=float, default=None)
|
||||
apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None)
|
||||
apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None)
|
||||
apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan")
|
||||
apply.add_argument(
|
||||
"--apply-transition-without-audio",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@ PPT Master - PPTX Intake Enrichment
|
||||
|
||||
Extract reusable PPTX intake facts into a standard analysis bundle. This is a
|
||||
read-only companion to `ppt_to_md.py`: Markdown remains the content source,
|
||||
while this bundle provides canvas, visual identity, slide geometry, tables, and
|
||||
native chart data for downstream workflows.
|
||||
while this bundle provides canvas, visual identity, slide geometry, tables,
|
||||
native chart data, and SmartArt structure for downstream workflows.
|
||||
|
||||
Usage:
|
||||
python3 scripts/pptx_intake.py <source.pptx> -o <output_dir>
|
||||
@@ -90,6 +90,49 @@ def _table_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"table_count": len(tables), "tables": tables}
|
||||
|
||||
|
||||
def _diagram_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
|
||||
diagrams: list[dict[str, Any]] = []
|
||||
text_item_count = 0
|
||||
unreadable_count = 0
|
||||
warning_count = 0
|
||||
slides_with_diagrams: set[int] = set()
|
||||
for slide in slide_library.get("slides", []):
|
||||
slide_index = slide.get("slide_index")
|
||||
for diagram in slide.get("diagrams", []):
|
||||
node_count = int(diagram.get("node_count") or 0)
|
||||
text_count = int(diagram.get("text_count") or 0)
|
||||
text_item_count += text_count
|
||||
if not diagram.get("text_extracted"):
|
||||
unreadable_count += 1
|
||||
if diagram.get("status") != "ok" or diagram.get("warnings"):
|
||||
warning_count += 1
|
||||
if isinstance(slide_index, int):
|
||||
slides_with_diagrams.add(slide_index)
|
||||
diagrams.append(
|
||||
{
|
||||
"slide_index": slide_index,
|
||||
"diagram_id": diagram.get("diagram_id"),
|
||||
"layout": diagram.get("layout", {}),
|
||||
"node_count": node_count,
|
||||
"text_count": text_count,
|
||||
"connection_count": int(diagram.get("connection_count") or 0),
|
||||
"max_depth": int(diagram.get("max_depth") or 0),
|
||||
"text_extracted": bool(diagram.get("text_extracted")),
|
||||
"has_persisted_drawing": bool(diagram.get("has_persisted_drawing")),
|
||||
"status": diagram.get("status"),
|
||||
"warnings": diagram.get("warnings", []),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"diagram_count": len(diagrams),
|
||||
"text_item_count": text_item_count,
|
||||
"unreadable_count": unreadable_count,
|
||||
"warning_count": warning_count,
|
||||
"slides_with_diagrams": sorted(slides_with_diagrams),
|
||||
"diagrams": diagrams,
|
||||
}
|
||||
|
||||
|
||||
def build_source_profile(
|
||||
pptx_path: Path,
|
||||
identity: dict[str, Any],
|
||||
@@ -118,7 +161,8 @@ def build_source_profile(
|
||||
"into locked constraints after user confirmation."
|
||||
),
|
||||
"template_fill": (
|
||||
"Use slide slots, tables, charts, and geometry as the native PPTX fill contract."
|
||||
"Use slide slots, tables, charts, diagrams, and geometry as the native PPTX "
|
||||
"fill contract; diagrams are inventory-only and remain unchanged."
|
||||
),
|
||||
},
|
||||
"artifacts": {
|
||||
@@ -142,12 +186,14 @@ def build_source_profile(
|
||||
"slide_index": slide.get("slide_index"),
|
||||
"page_type": slide.get("page_type"),
|
||||
"slot_count": len(slide.get("slots", [])),
|
||||
"diagram_count": len(slide.get("diagrams", [])),
|
||||
}
|
||||
for slide in slide_library.get("slides", [])
|
||||
],
|
||||
},
|
||||
"tables": _table_summary(slide_library),
|
||||
"charts": _chart_summary(slide_library),
|
||||
"diagrams": _diagram_summary(slide_library),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+17
-2
@@ -104,6 +104,7 @@ def convert_blip_fill(
|
||||
filename, img_bytes = _normalize_office_media(filename, img_bytes)
|
||||
filename, img_bytes = _apply_blip_image_effects(filename, img_bytes, blip)
|
||||
href = _build_href(filename, img_bytes, media_subdir, embed_inline)
|
||||
opacity_attr = _blip_opacity_attr(blip)
|
||||
|
||||
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
|
||||
src_rect = blip_fill_elem.find("a:srcRect", NS)
|
||||
@@ -120,7 +121,7 @@ def convert_blip_fill(
|
||||
svg = (
|
||||
f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
|
||||
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
|
||||
f'preserveAspectRatio="none"/>'
|
||||
f'preserveAspectRatio="none"{opacity_attr}/>'
|
||||
)
|
||||
else:
|
||||
# Crop expressed as a unit-rectangle viewBox on a nested <svg>.
|
||||
@@ -132,7 +133,7 @@ def convert_blip_fill(
|
||||
f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
|
||||
f'preserveAspectRatio="none">'
|
||||
f'<image href="{href}" x="0" y="0" width="1" height="1" '
|
||||
f'preserveAspectRatio="none"/>'
|
||||
f'preserveAspectRatio="none"{opacity_attr}/>'
|
||||
f"</svg>"
|
||||
)
|
||||
|
||||
@@ -169,6 +170,20 @@ def convert_picture(
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _blip_opacity_attr(blip: ET.Element) -> str:
|
||||
"""Translate DrawingML fixed image alpha to an SVG opacity attribute."""
|
||||
alpha = blip.find("a:alphaModFix", NS)
|
||||
if alpha is None:
|
||||
return ""
|
||||
try:
|
||||
opacity = max(0.0, min(1.0, float(alpha.attrib.get("amt", "100000")) / 100000.0))
|
||||
except ValueError:
|
||||
return ""
|
||||
if opacity >= 1.0:
|
||||
return ""
|
||||
return f' opacity="{fmt_num(opacity, 5)}"'
|
||||
|
||||
_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -167,7 +167,7 @@ class ProjectManager:
|
||||
f"- Created: {date_str}\n\n"
|
||||
"## Directories\n\n"
|
||||
"- `svg_output/`: raw SVG output\n"
|
||||
"- `svg_final/`: finalized SVG output\n"
|
||||
"- `svg_final/`: self-contained SVG visual preview; may be inserted manually as an SVG image, but PowerPoint Convert to Shape is unsupported\n"
|
||||
"- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n"
|
||||
"- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
|
||||
"- `notes/`: speaker notes\n"
|
||||
@@ -175,7 +175,7 @@ class ProjectManager:
|
||||
"- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
|
||||
"- `sources/`: source materials and normalized markdown\n"
|
||||
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
|
||||
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added with `--svg-snapshot`, `_native_charts.pptx` name with `--native-objects`, `_narrated.pptx` name when narration audio is embedded\n"
|
||||
"- `exports/`: native DrawingML pptx (timestamped); `_native_charts.pptx` name with `--native-objects`, `_narrated.pptx` name when narration audio is embedded\n"
|
||||
"- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
|
||||
),
|
||||
encoding="utf-8",
|
||||
|
||||
+65
-2
@@ -2,8 +2,8 @@
|
||||
"""
|
||||
PowerPoint to Markdown Converter
|
||||
|
||||
Extracts slide text, tables, speaker notes, and embedded pictures from
|
||||
Open XML PowerPoint files into Markdown.
|
||||
Extracts slide text, tables, SmartArt node structure, speaker notes, and
|
||||
embedded pictures from Open XML PowerPoint files into Markdown.
|
||||
|
||||
Primary use case: PPTX source decks -> Markdown for PPT generation input.
|
||||
|
||||
@@ -29,10 +29,12 @@ import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
@@ -41,6 +43,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from _batch import run_path_batch # noqa: E402
|
||||
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
|
||||
from template_fill_pptx.diagram_read import ( # noqa: E402
|
||||
read_smartart_diagrams,
|
||||
smartart_to_markdown,
|
||||
)
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.enum.action import PP_ACTION
|
||||
@@ -721,6 +727,39 @@ def convert_presentation_to_markdown(
|
||||
_reset_generated_asset_dir(asset_dir)
|
||||
|
||||
presentation = Presentation(str(input_file))
|
||||
conversion_warnings: list[str] = []
|
||||
diagrams_by_slide: dict[int, list[dict[str, object]]] = {}
|
||||
diagram_scan_failures: dict[int, str] = {}
|
||||
try:
|
||||
with zipfile.ZipFile(input_file) as package:
|
||||
for slide_index, slide in enumerate(presentation.slides, 1):
|
||||
slide_part = str(slide.part.partname).lstrip("/")
|
||||
try:
|
||||
diagrams = read_smartart_diagrams(package, slide_part, slide_index)
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
|
||||
diagrams_by_slide[slide_index] = []
|
||||
diagram_scan_failures[slide_index] = str(exc)
|
||||
conversion_warnings.append(
|
||||
f"Slide {slide_index}: SmartArt scan failed: {exc}"
|
||||
)
|
||||
continue
|
||||
diagrams_by_slide[slide_index] = diagrams
|
||||
for diagram in diagrams:
|
||||
issues = [str(item) for item in diagram.get("warnings", []) if item]
|
||||
if diagram.get("status") != "ok":
|
||||
issues.insert(0, f"status={diagram.get('status')}")
|
||||
if not issues:
|
||||
continue
|
||||
conversion_warnings.append(
|
||||
f"Slide {slide_index}, {diagram.get('shape_name') or diagram.get('diagram_id')}: "
|
||||
f"SmartArt content {'; '.join(issues)}"
|
||||
)
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
|
||||
conversion_warnings.append(f"SmartArt package scan failed: {exc}")
|
||||
for slide_index in range(1, len(presentation.slides) + 1):
|
||||
diagrams_by_slide.setdefault(slide_index, [])
|
||||
diagram_scan_failures.setdefault(slide_index, str(exc))
|
||||
|
||||
lines = [
|
||||
f"# {input_file.stem}",
|
||||
"",
|
||||
@@ -741,6 +780,13 @@ def convert_presentation_to_markdown(
|
||||
lines.append("")
|
||||
|
||||
blocks = []
|
||||
slide_diagrams = diagrams_by_slide.get(slide_index, [])
|
||||
diagrams_by_shape_id = {
|
||||
str(diagram.get("shape_id")): diagram
|
||||
for diagram in slide_diagrams
|
||||
if diagram.get("shape_id") is not None
|
||||
}
|
||||
emitted_diagram_ids: set[str] = set()
|
||||
for item in iter_leaf_shapes(slide.shapes):
|
||||
shape = item.shape
|
||||
|
||||
@@ -750,6 +796,13 @@ def convert_presentation_to_markdown(
|
||||
blocks.append(table_md)
|
||||
continue
|
||||
|
||||
shape_id = str(getattr(shape, "shape_id", ""))
|
||||
diagram = diagrams_by_shape_id.get(shape_id)
|
||||
if diagram is not None:
|
||||
blocks.append(smartart_to_markdown(diagram))
|
||||
emitted_diagram_ids.add(str(diagram.get("diagram_id")))
|
||||
continue
|
||||
|
||||
is_picture_shape = shape.shape_type in {
|
||||
MSO_SHAPE_TYPE.PICTURE,
|
||||
MSO_SHAPE_TYPE.LINKED_PICTURE,
|
||||
@@ -795,6 +848,15 @@ def convert_presentation_to_markdown(
|
||||
except (ValueError, AttributeError, KeyError):
|
||||
blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}")
|
||||
|
||||
for diagram in slide_diagrams:
|
||||
if str(diagram.get("diagram_id")) in emitted_diagram_ids:
|
||||
continue
|
||||
blocks.append(smartart_to_markdown(diagram))
|
||||
if slide_index in diagram_scan_failures:
|
||||
blocks.append(
|
||||
f"> [SmartArt scan unavailable: {diagram_scan_failures[slide_index]}]"
|
||||
)
|
||||
|
||||
if blocks:
|
||||
lines.append("\n\n".join(blocks))
|
||||
lines.append("")
|
||||
@@ -822,6 +884,7 @@ def convert_presentation_to_markdown(
|
||||
converter="ppt_to_md.py",
|
||||
conversion_type=suffix.lstrip("."),
|
||||
asset_dir=asset_dir,
|
||||
warnings=conversion_warnings,
|
||||
)
|
||||
|
||||
print(f"[OK] Saved Markdown to: {out_file}")
|
||||
|
||||
@@ -34,7 +34,7 @@ import urllib.request
|
||||
import webbrowser
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
@@ -89,6 +89,11 @@ from embed_icons import ( # noqa: E402
|
||||
extract_paths_from_icon,
|
||||
generate_icon_group,
|
||||
)
|
||||
from svg_to_pptx.geometry_properties import ( # noqa: E402
|
||||
GeometryStyleError,
|
||||
INLINE_GEOMETRY_PROPERTIES,
|
||||
materialize_inline_geometry_properties,
|
||||
)
|
||||
|
||||
_ICONS_DIR = _SCRIPTS_DIR.parent.parent / 'templates' / 'icons'
|
||||
_USE_ICON_PATTERN = re.compile(r'<use\s+[^>]*data-icon="[^"]*"[^>]*/>')
|
||||
@@ -184,6 +189,47 @@ def _inline_icons(content: str) -> tuple[str, list[dict]]:
|
||||
return new_content, warnings
|
||||
|
||||
|
||||
def _strip_edited_inline_geometry(
|
||||
elem: ET.Element,
|
||||
attr_names: Iterable[str],
|
||||
) -> None:
|
||||
"""Remove style declarations superseded by edited geometry attributes."""
|
||||
style = elem.get('style')
|
||||
if not style:
|
||||
return
|
||||
tag = elem.tag.rsplit('}', 1)[-1] if '}' in elem.tag else elem.tag
|
||||
supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
|
||||
edited = {
|
||||
str(name).lower()
|
||||
for name in attr_names
|
||||
if str(name).lower() in supported
|
||||
}
|
||||
if not edited:
|
||||
return
|
||||
|
||||
retained = []
|
||||
changed = False
|
||||
for raw_declaration in style.split(';'):
|
||||
declaration = raw_declaration.strip()
|
||||
if not declaration:
|
||||
continue
|
||||
if ':' not in declaration:
|
||||
retained.append(declaration)
|
||||
continue
|
||||
raw_name, _raw_value = declaration.split(':', 1)
|
||||
if raw_name.strip().lower() in edited:
|
||||
changed = True
|
||||
continue
|
||||
retained.append(declaration)
|
||||
|
||||
if not changed:
|
||||
return
|
||||
if retained:
|
||||
elem.set('style', '; '.join(retained))
|
||||
else:
|
||||
elem.attrib.pop('style', None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staged-edit value validation (POST /api/slide/<name>/edit).
|
||||
# The browser may expose raw element attributes, so validation protects only
|
||||
@@ -290,6 +336,10 @@ def _apply_edit_record(root: ET.Element, record: dict) -> tuple[bool, Optional[s
|
||||
return ok, reason
|
||||
attrs = record.get('attrs')
|
||||
if attrs:
|
||||
target = _find_by_id(root, element_id)
|
||||
if target is None:
|
||||
return False, 'not-found'
|
||||
_strip_edited_inline_geometry(target, attrs.keys())
|
||||
ok, reason = set_attributes(root, element_id, attrs)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
@@ -578,6 +628,12 @@ def create_app(
|
||||
logger.warning('slide parse failed: %s: %s', name, exc)
|
||||
return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
|
||||
|
||||
try:
|
||||
materialize_inline_geometry_properties(root)
|
||||
except GeometryStyleError as exc:
|
||||
logger.warning('slide geometry materialization failed: %s: %s', name, exc)
|
||||
return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
|
||||
|
||||
assign_temp_ids(root)
|
||||
if pending_edits:
|
||||
ok, reason = _apply_edit_records(root, pending_edits)
|
||||
@@ -712,6 +768,11 @@ def create_app(
|
||||
except ET.ParseError as exc:
|
||||
return jsonify({'error': f'Failed to parse SVG: {exc}'}), 500
|
||||
|
||||
try:
|
||||
materialize_inline_geometry_properties(root)
|
||||
except GeometryStyleError as exc:
|
||||
return jsonify({'error': f'Invalid inline geometry: {exc}'}), 400
|
||||
|
||||
assign_temp_ids(root)
|
||||
pending = app.config['PENDING_EDITS'].get(name) or []
|
||||
ok, reason = _apply_edit_records(root, pending)
|
||||
@@ -741,6 +802,7 @@ def create_app(
|
||||
staged['text'] = new_text
|
||||
if attrs:
|
||||
old_attrs = {k: target.get(k) for k in attrs}
|
||||
_strip_edited_inline_geometry(target, attrs.keys())
|
||||
ok, reason = set_attributes(root, element_id, attrs)
|
||||
if not ok:
|
||||
return jsonify({'error': f'Attribute edit failed: {reason}'}), (
|
||||
|
||||
+43
-2
@@ -901,7 +901,48 @@
|
||||
applyElementAttrs(el, sa);
|
||||
}
|
||||
|
||||
var INLINE_GEOMETRY_STYLE_PROPERTIES = {
|
||||
rect: new Set(["x", "y", "width", "height", "rx", "ry"]),
|
||||
circle: new Set(["cx", "cy", "r"]),
|
||||
ellipse: new Set(["cx", "cy", "rx", "ry"]),
|
||||
image: new Set(["x", "y", "width", "height"]),
|
||||
svg: new Set(["x", "y", "width", "height"]),
|
||||
use: new Set(["x", "y", "width", "height"])
|
||||
};
|
||||
|
||||
function stripEditedInlineGeometry(el, attrs) {
|
||||
var style = el.getAttribute("style");
|
||||
var supported = INLINE_GEOMETRY_STYLE_PROPERTIES[localName(el)];
|
||||
if (!style || !supported) return;
|
||||
var edited = new Set(Object.keys(attrs || {}).map(function (key) {
|
||||
return String(key).toLowerCase();
|
||||
}).filter(function (key) { return supported.has(key); }));
|
||||
if (edited.size === 0) return;
|
||||
|
||||
var retained = [];
|
||||
var changed = false;
|
||||
style.split(";").forEach(function (rawDeclaration) {
|
||||
var declaration = rawDeclaration.trim();
|
||||
if (!declaration) return;
|
||||
var colon = declaration.indexOf(":");
|
||||
if (colon < 0) {
|
||||
retained.push(declaration);
|
||||
return;
|
||||
}
|
||||
var name = declaration.slice(0, colon).trim().toLowerCase();
|
||||
if (edited.has(name)) {
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
retained.push(declaration);
|
||||
});
|
||||
if (!changed) return;
|
||||
if (retained.length > 0) el.setAttribute("style", retained.join("; "));
|
||||
else el.removeAttribute("style");
|
||||
}
|
||||
|
||||
function applyElementAttrs(el, attrs) {
|
||||
stripEditedInlineGeometry(el, attrs);
|
||||
if (localName(el) === "g" && el.hasAttribute("data-icon") &&
|
||||
attrs.x !== undefined && attrs.y !== undefined) {
|
||||
// The preview expands <use data-icon> into a <g>, but disk still owns
|
||||
@@ -2620,7 +2661,7 @@
|
||||
var attrs = {};
|
||||
attrs[key] = value;
|
||||
stageEditRequest(eid, { attrs: attrs })
|
||||
.then(function () { el.setAttribute(key, value); })
|
||||
.then(function () { applyElementAttrs(el, attrs); })
|
||||
.catch(function (err) { showError(t("err_edit") + err.message); });
|
||||
}
|
||||
|
||||
@@ -2856,7 +2897,7 @@
|
||||
var attrs = {};
|
||||
attrs[key] = value;
|
||||
return stageEditRequest(el.id, { attrs: attrs }).then(function () {
|
||||
el.setAttribute(key, value);
|
||||
applyElementAttrs(el, attrs);
|
||||
});
|
||||
});
|
||||
Promise.all(jobs)
|
||||
|
||||
+6
-6
@@ -9,12 +9,12 @@ Replaces the previous three independent finalize_svg steps:
|
||||
reference points to a pre-cropped asset.
|
||||
fix-aspect → for each <image>, read the source bitmap dimensions and
|
||||
adjust x/y/width/height so the rendered box matches the
|
||||
image aspect ratio (PowerPoint's "Convert to Shape"
|
||||
ignores preserveAspectRatio and stretches otherwise).
|
||||
embed-images → Base64-inline every external image reference so the
|
||||
legacy/preview pptx (which packages the SVG verbatim)
|
||||
can resolve them — pptx-internal SVG cannot follow
|
||||
``../images/…`` relative URIs.
|
||||
image aspect ratio in PowerPoint SVG rendering paths that
|
||||
do not honor preserveAspectRatio consistently.
|
||||
embed-images → Base64-inline every embeddable external image reference
|
||||
so ``svg_final/`` remains portable when opened or manually
|
||||
inserted as an SVG image. EMF/WMF keep the documented
|
||||
external-reference exception.
|
||||
|
||||
Why merge: each step independently parsed + serialized the SVG, each step
|
||||
re-read the same bitmap from disk, and the two spatial transforms (crop and
|
||||
|
||||
+5
-4
@@ -3,11 +3,12 @@
|
||||
SVG Image Aspect Ratio Fix Tool
|
||||
|
||||
Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
|
||||
This prevents images from being stretched when PowerPoint converts SVG to editable shapes.
|
||||
This prevents images from being stretched by PowerPoint SVG rendering paths that do not
|
||||
honor preserveAspectRatio consistently.
|
||||
|
||||
Principle:
|
||||
When PowerPoint converts SVG to editable shapes, it ignores the preserveAspectRatio attribute
|
||||
and directly stretches the image to fill the area specified by width/height.
|
||||
Some PowerPoint SVG rendering paths ignore the preserveAspectRatio attribute and directly
|
||||
stretch the image to fill the area specified by width/height.
|
||||
|
||||
This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
|
||||
<image> elements so that images are centered and maintain their original aspect ratio.
|
||||
@@ -331,7 +332,7 @@ def fix_image_aspect_in_svg(svg_path: str, dry_run: bool = False, verbose: bool
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Fix image aspect ratios in SVG to prevent stretching when PowerPoint converts to shapes',
|
||||
description='Normalize SVG image boxes for PowerPoint SVG rendering diagnostics',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
|
||||
+7
-6
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - SVG Rounded Rectangle to Path Tool
|
||||
PPT Master - Legacy SVG Rounded Rectangle Diagnostic
|
||||
|
||||
Solves the issue of rounded corners being lost when using "Convert to Shape" in PowerPoint:
|
||||
Converts <rect> elements with rx/ry to equivalent <path> elements.
|
||||
Historical diagnostic that converts <rect> elements with rx/ry to equivalent
|
||||
<path> elements. It is not part of finalize_svg.py or the supported export
|
||||
workflow; PowerPoint's manual "Convert to Shape" behavior is not supported.
|
||||
|
||||
Usage:
|
||||
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
|
||||
@@ -235,7 +236,7 @@ def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Pat
|
||||
def main() -> None:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master - SVG Rounded Rectangle to Path Tool',
|
||||
description='PPT Master - Legacy SVG Rounded Rectangle Diagnostic',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
@@ -245,7 +246,7 @@ Examples:
|
||||
|
||||
What it does:
|
||||
Converts <rect> elements with rx/ry to equivalent <path> elements.
|
||||
Processed SVGs preserve rounded corners when using "Convert to Shape" in PowerPoint.
|
||||
Legacy diagnostic only; the standard pipeline preserves SVG rounded rectangles.
|
||||
'''
|
||||
)
|
||||
|
||||
@@ -269,7 +270,7 @@ What it does:
|
||||
quiet = args.quiet
|
||||
|
||||
if not quiet:
|
||||
print("PPT Master - SVG Rounded Rectangle to Path Tool")
|
||||
print("PPT Master - Legacy SVG Rounded Rectangle Diagnostic")
|
||||
print("=" * 50)
|
||||
|
||||
total_converted = 0
|
||||
|
||||
+458
-50
@@ -10,6 +10,7 @@ Usage:
|
||||
python3 scripts/svg_quality_checker.py --all examples
|
||||
"""
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
@@ -25,11 +26,9 @@ configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
from project_utils import CANVAS_FORMATS
|
||||
from error_helper import ErrorHelper
|
||||
except ImportError:
|
||||
print("Warning: Unable to import dependency modules")
|
||||
print("Warning: Unable to import project_utils")
|
||||
CANVAS_FORMATS = {}
|
||||
ErrorHelper = None
|
||||
|
||||
try:
|
||||
from update_spec import parse_lock as _parse_spec_lock
|
||||
@@ -40,17 +39,35 @@ try:
|
||||
from svg_to_pptx.animation_config import (
|
||||
load_animation_config as _load_animation_config,
|
||||
validate_animation_config as _validate_animation_config,
|
||||
validate_animation_config_errors as _validate_animation_config_errors,
|
||||
validate_transition_config as _validate_transition_config,
|
||||
)
|
||||
except ImportError:
|
||||
except ImportError as exc:
|
||||
_load_animation_config = None
|
||||
_validate_animation_config = None
|
||||
_validate_animation_config_errors = None
|
||||
_validate_transition_config = None
|
||||
_animation_config_import_error = str(exc)
|
||||
else:
|
||||
_animation_config_import_error = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.drawingml.utils import (
|
||||
parse_font_family as _parse_export_font_family,
|
||||
parse_inline_style as _parse_inline_style,
|
||||
parse_svg_color as _parse_export_color,
|
||||
)
|
||||
except ImportError:
|
||||
_parse_export_font_family = None
|
||||
_parse_inline_style = None
|
||||
_parse_export_color = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.drawingml.converter import (
|
||||
collect_unsupported_visuals as _collect_unsupported_visuals,
|
||||
)
|
||||
except ImportError:
|
||||
_collect_unsupported_visuals = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.native_objects import (
|
||||
@@ -85,6 +102,26 @@ except ImportError:
|
||||
})
|
||||
_validate_semantic_markers = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.geometry_properties import (
|
||||
materialize_inline_geometry_properties as _materialize_inline_geometry_properties,
|
||||
validate_inline_geometry_properties as _validate_inline_geometry_properties,
|
||||
)
|
||||
except ImportError:
|
||||
_materialize_inline_geometry_properties = None
|
||||
_validate_inline_geometry_properties = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.use_expander import (
|
||||
UseExpansionError as _UseExpansionError,
|
||||
expand_local_use_references as _expand_local_use_references,
|
||||
validate_local_use_references as _validate_local_use_references,
|
||||
)
|
||||
except ImportError:
|
||||
_UseExpansionError = None
|
||||
_expand_local_use_references = None
|
||||
_validate_local_use_references = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.pptx_package.template_structure import (
|
||||
TemplateStructureError as _TemplateStructureError,
|
||||
@@ -133,9 +170,37 @@ except ImportError:
|
||||
_unresolved_external_image_reference_path = None
|
||||
|
||||
|
||||
HEX_VALUE_RE = re.compile(r"#[0-9A-Fa-f]{3,8}")
|
||||
HEX_VALUE_RE = re.compile(
|
||||
r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
|
||||
)
|
||||
_BARE_HEX_VALUE_RE = re.compile(
|
||||
r"(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})"
|
||||
)
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||
_SUPPORTED_FILTER_PRIMITIVES = frozenset({
|
||||
'feDropShadow',
|
||||
'feGaussianBlur',
|
||||
'feOffset',
|
||||
'feFlood',
|
||||
'feComposite',
|
||||
'feMerge',
|
||||
'feMergeNode',
|
||||
'feComponentTransfer',
|
||||
'feFuncA',
|
||||
})
|
||||
_FILTER_EFFECT_PRIMITIVES = frozenset({'feDropShadow', 'feGaussianBlur'})
|
||||
|
||||
|
||||
def _normalize_hex_rgb(value: str) -> str | None:
|
||||
"""Normalize 3/4/6/8-digit HEX to alpha-free ``RRGGBB``."""
|
||||
if not HEX_VALUE_RE.fullmatch(value):
|
||||
return None
|
||||
color = value[1:]
|
||||
if len(color) in {3, 4}:
|
||||
color = ''.join(channel * 2 for channel in color)
|
||||
return color[:6].upper()
|
||||
|
||||
|
||||
# Fonts that survive direct PPTX typeface assignment on a typical Windows /
|
||||
# macOS viewer without requiring a custom install. Keep this aligned with
|
||||
@@ -397,6 +462,12 @@ class SVGQualityChecker:
|
||||
# 2. Check forbidden elements
|
||||
self._check_forbidden_elements(content, root, result)
|
||||
|
||||
# 2b. Validate the supported shadow/glow filter interface.
|
||||
self._check_filter_effects(root, result)
|
||||
|
||||
# 2c. Reject gradient inheritance and transform semantics.
|
||||
self._check_gradient_interfaces(root, result)
|
||||
|
||||
# 3. Check font-size values
|
||||
self._check_font_size_values(content, result)
|
||||
|
||||
@@ -412,6 +483,9 @@ class SVGQualityChecker:
|
||||
# 7. Check icon placeholders resolve before post-processing.
|
||||
self._check_icon_placeholders(root, svg_path, result)
|
||||
|
||||
# 7b. Reject visual elements the native converter cannot dispatch.
|
||||
self._check_unsupported_visual_elements(root, result)
|
||||
|
||||
# 8. Check object-level animation anchor quality.
|
||||
self._check_animation_group_ids(root, result)
|
||||
|
||||
@@ -579,15 +653,35 @@ class SVGQualityChecker:
|
||||
result['errors'].append("Detected forbidden <link rel=\"stylesheet\"> (external CSS references forbidden)")
|
||||
if re.search(r'@import\s+', content_lower):
|
||||
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
|
||||
if _validate_inline_geometry_properties is None:
|
||||
result['warnings'].append(
|
||||
"Unable to import inline geometry validator; "
|
||||
"native export will still validate geometry styles."
|
||||
)
|
||||
else:
|
||||
geometry_errors = _validate_inline_geometry_properties(root)
|
||||
for error in geometry_errors:
|
||||
result['errors'].append(f"Invalid inline geometry property: {error}")
|
||||
if not geometry_errors:
|
||||
_materialize_inline_geometry_properties(root)
|
||||
|
||||
# Structure / nesting
|
||||
if 'foreignobject' in local_names:
|
||||
result['errors'].append(
|
||||
"Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
|
||||
has_symbol = 'symbol' in local_names
|
||||
has_use = 'use' in local_names
|
||||
if has_symbol and has_use:
|
||||
result['errors'].append("Detected forbidden <symbol> + <use> complex usage (use basic shapes or simple <use> instead)")
|
||||
has_generic_use = any(
|
||||
_local_name(elem).lower() == 'use' and elem.get('data-icon') is None
|
||||
for elem in elems
|
||||
)
|
||||
if has_generic_use:
|
||||
if _validate_local_use_references is None:
|
||||
result['warnings'].append(
|
||||
"Detected local <use> references, but the shared validator "
|
||||
"could not be imported; native export will still validate them."
|
||||
)
|
||||
else:
|
||||
for error in _validate_local_use_references(root):
|
||||
result['errors'].append(f"Invalid local <use> reference: {error}")
|
||||
# marker-start / marker-end are conditionally allowed (see shared-standards.md §1.1).
|
||||
# The converter maps qualifying <marker> defs to native DrawingML <a:headEnd>/<a:tailEnd>.
|
||||
# We only warn when a marker is used without an obvious <defs> definition in the same file.
|
||||
@@ -616,31 +710,260 @@ class SVGQualityChecker:
|
||||
# Other discouraged elements
|
||||
if 'iframe' in local_names:
|
||||
result['errors'].append("Detected <iframe> element (should not appear in SVG)")
|
||||
# Paint grammar: rgba()/hsl()/alpha-hex all render in browser preview
|
||||
# but come back as None from parse_hex_color, so the exporter writes
|
||||
# <a:noFill/> — the fill silently disappears in PPTX. Named colors and
|
||||
# rgb() export correctly and are deliberately not flagged.
|
||||
|
||||
# Paint-server references must match the exact definitions consumed by
|
||||
# drawingml.converter.collect_defs: direct children of <defs> only.
|
||||
defs_by_id = {}
|
||||
for defs_elem in elems:
|
||||
if _local_name(defs_elem).lower() != 'defs':
|
||||
continue
|
||||
for child in defs_elem:
|
||||
child_id = child.get('id')
|
||||
if child_id:
|
||||
defs_by_id[child_id] = child
|
||||
pattern_descendant_ids = {
|
||||
id(descendant)
|
||||
for pattern in elems
|
||||
if _local_name(pattern).lower() == 'pattern'
|
||||
for descendant in pattern.iter()
|
||||
if descendant is not pattern
|
||||
}
|
||||
fill_shape_tags = {'rect', 'circle', 'ellipse', 'path', 'polygon', 'polyline'}
|
||||
stroke_shape_tags = fill_shape_tags | {'line'}
|
||||
paint_reference_errors = set()
|
||||
for elem in elems:
|
||||
style_values = (
|
||||
_parse_inline_style(elem.get('style'))
|
||||
if _parse_inline_style is not None else {}
|
||||
)
|
||||
for attr in ('fill', 'stroke'):
|
||||
value = style_values.get(attr) or elem.get(attr)
|
||||
match = re.fullmatch(r'url\(#([^)]+)\)', (value or '').strip())
|
||||
if match is None:
|
||||
continue
|
||||
ref_id = match.group(1)
|
||||
target = defs_by_id.get(ref_id)
|
||||
elem_tag = _local_name(elem)
|
||||
elem_tag_lower = elem_tag.lower()
|
||||
if target is None:
|
||||
paint_reference_errors.add(
|
||||
f"<{elem_tag}> {attr}=url(#{ref_id}) has no matching "
|
||||
"direct <defs> definition"
|
||||
)
|
||||
continue
|
||||
has_text_descendant = any(
|
||||
_local_name(descendant).lower() in {'text', 'tspan'}
|
||||
for descendant in elem.iter()
|
||||
if descendant is not elem
|
||||
)
|
||||
if id(elem) in pattern_descendant_ids:
|
||||
allowed_tags = ()
|
||||
elif attr == 'fill' and elem_tag_lower in fill_shape_tags:
|
||||
allowed_tags = ('lineargradient', 'radialgradient', 'pattern')
|
||||
elif attr == 'stroke' and elem_tag_lower in stroke_shape_tags:
|
||||
allowed_tags = ('lineargradient', 'radialgradient')
|
||||
elif attr == 'fill' and elem_tag_lower in {'text', 'tspan'}:
|
||||
allowed_tags = ('lineargradient', 'radialgradient')
|
||||
elif attr == 'fill' and elem_tag_lower == 'g':
|
||||
allowed_tags = (
|
||||
('lineargradient', 'radialgradient')
|
||||
if has_text_descendant
|
||||
else ('lineargradient', 'radialgradient', 'pattern')
|
||||
)
|
||||
elif attr == 'stroke' and elem_tag_lower == 'g' and not has_text_descendant:
|
||||
allowed_tags = ('lineargradient', 'radialgradient')
|
||||
else:
|
||||
allowed_tags = ()
|
||||
target_tag = _local_name(target).lower()
|
||||
if not allowed_tags:
|
||||
paint_reference_errors.add(
|
||||
f"<{elem_tag}> {attr}=url(#{ref_id}) is not supported "
|
||||
"by native PPTX conversion in this context"
|
||||
)
|
||||
continue
|
||||
if target_tag not in allowed_tags:
|
||||
tag_labels = {
|
||||
'lineargradient': 'linearGradient',
|
||||
'radialgradient': 'radialGradient',
|
||||
'pattern': 'pattern',
|
||||
}
|
||||
expected = '/'.join(
|
||||
tag_labels[tag] for tag in allowed_tags
|
||||
)
|
||||
paint_reference_errors.add(
|
||||
f"<{elem_tag}> {attr}=url(#{ref_id}) resolves to "
|
||||
f"<{_local_name(target)}>; expected {expected}"
|
||||
)
|
||||
result['errors'].extend(sorted(paint_reference_errors))
|
||||
|
||||
# Paint grammar: use the exporter's parser so authoring validation and
|
||||
# native conversion accept the same CSS color subset.
|
||||
paint_values = [
|
||||
value
|
||||
for attr in ('fill', 'stroke', 'stop-color')
|
||||
(attr, value)
|
||||
for attr in (
|
||||
'fill', 'stroke', 'stop-color', 'flood-color',
|
||||
'data-pptx-fg', 'data-pptx-bg',
|
||||
)
|
||||
for value in self._svg_property_values(content, attr)
|
||||
]
|
||||
if any('rgba' in value.lower() for value in paint_values):
|
||||
result['errors'].append("Detected forbidden rgba() color (use fill-opacity/stroke-opacity instead)")
|
||||
if any('hsl' in value.lower() for value in paint_values):
|
||||
result['errors'].append(
|
||||
"Detected hsl()/hsla() color (not exported to PPTX — fills become "
|
||||
"invisible; use 6-digit HEX instead)")
|
||||
alpha_hex_re = re.compile(r'^#[0-9A-Fa-f]{4}$|^#[0-9A-Fa-f]{8}$')
|
||||
if any(alpha_hex_re.match(value.strip()) for value in paint_values):
|
||||
result['errors'].append(
|
||||
"Detected alpha-channel HEX color (#RGBA/#RRGGBBAA is not exported "
|
||||
"to PPTX — fills become invisible; use 6-digit HEX plus "
|
||||
"fill-opacity/stroke-opacity)")
|
||||
if any(_local_name(elem).lower() == 'g' and elem.get('opacity') for elem in elems):
|
||||
result['errors'].append("Detected forbidden <g opacity> (set opacity on each child element individually)")
|
||||
if any(_local_name(elem).lower() == 'image' and elem.get('opacity') for elem in elems):
|
||||
result['errors'].append("Detected forbidden <image opacity> (use overlay mask approach)")
|
||||
if _parse_export_color is None:
|
||||
result['warnings'].append(
|
||||
"Unable to import svg_to_pptx color parser; skipped paint syntax check"
|
||||
)
|
||||
else:
|
||||
invalid_paints = set()
|
||||
for attr, value in paint_values:
|
||||
normalized = value.strip()
|
||||
if attr in {'fill', 'stroke'} and (
|
||||
normalized.lower() == 'none'
|
||||
or re.fullmatch(r'url\(#[^)]+\)', normalized)
|
||||
):
|
||||
continue
|
||||
if _BARE_HEX_VALUE_RE.fullmatch(normalized):
|
||||
invalid_paints.add(value)
|
||||
continue
|
||||
color, _alpha = _parse_export_color(normalized)
|
||||
if color is None:
|
||||
invalid_paints.add(value)
|
||||
if invalid_paints:
|
||||
shown = ', '.join(sorted(invalid_paints)[:5])
|
||||
more = len(invalid_paints) - 5
|
||||
suffix = f" (+{more} more)" if more > 0 else ""
|
||||
result['errors'].append(
|
||||
"Unsupported SVG paint value(s) for PPTX export: "
|
||||
f"{shown}{suffix}. Use a supported named color, rgb()/rgba(), "
|
||||
"hsl()/hsla(), or #RGB/#RGBA/#RRGGBB/#RRGGBBAA."
|
||||
)
|
||||
for elem in elems:
|
||||
tag = _local_name(elem).lower()
|
||||
if tag not in {'g', 'image'}:
|
||||
continue
|
||||
raw_opacity = elem.get('opacity')
|
||||
if raw_opacity is None:
|
||||
style_match = re.search(
|
||||
r'(?:^|;)\s*opacity\s*:\s*([^;]+)',
|
||||
elem.get('style', ''),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
raw_opacity = style_match.group(1).strip() if style_match else None
|
||||
if raw_opacity is None:
|
||||
continue
|
||||
try:
|
||||
opacity = float(raw_opacity)
|
||||
except ValueError:
|
||||
opacity = -1.0
|
||||
if not 0.0 <= opacity <= 1.0:
|
||||
result['errors'].append(
|
||||
f"<{tag} opacity> must be a numeric value from 0 to 1, got {raw_opacity!r}"
|
||||
)
|
||||
if tag == 'g' and opacity < 1.0 and any(
|
||||
descendant.get('data-pptx-native')
|
||||
for descendant in elem.iter()
|
||||
):
|
||||
result['warnings'].append(
|
||||
"<g opacity> around data-pptx-native content uses the SVG "
|
||||
"fallback; --native-objects export rejects that combination"
|
||||
)
|
||||
|
||||
def _check_filter_effects(self, root: ET.Element, result: Dict) -> None:
|
||||
"""Validate filters against the native shadow/glow approximation."""
|
||||
elems = list(root.iter())
|
||||
direct_filters = []
|
||||
filters_by_id = {}
|
||||
for defs_elem in elems:
|
||||
if _local_name(defs_elem) != 'defs':
|
||||
continue
|
||||
for child in defs_elem:
|
||||
if _local_name(child) != 'filter':
|
||||
continue
|
||||
direct_filters.append(child)
|
||||
filter_id = child.get('id')
|
||||
if filter_id:
|
||||
filters_by_id[filter_id] = child
|
||||
|
||||
issues = set()
|
||||
for elem in elems:
|
||||
tag = _local_name(elem)
|
||||
style_values = (
|
||||
_parse_inline_style(elem.get('style'))
|
||||
if _parse_inline_style is not None else {}
|
||||
)
|
||||
if style_values.get('filter'):
|
||||
issues.add(
|
||||
f"<{tag}> filter must use a direct filter=\"url(#id)\" "
|
||||
"attribute; inline style filters are not supported"
|
||||
)
|
||||
|
||||
raw_filter = elem.get('filter')
|
||||
if raw_filter is None:
|
||||
continue
|
||||
match = re.fullmatch(r'url\(#([^)]+)\)', raw_filter.strip())
|
||||
if match is None:
|
||||
issues.add(
|
||||
f"<{tag}> filter must be an exact local url(#id) reference; "
|
||||
f"got {raw_filter!r}"
|
||||
)
|
||||
continue
|
||||
filter_id = match.group(1)
|
||||
if filter_id not in filters_by_id:
|
||||
issues.add(
|
||||
f"<{tag}> filter=url(#{filter_id}) has no matching direct "
|
||||
f"<defs><filter id=\"{filter_id}\"> definition"
|
||||
)
|
||||
|
||||
for filter_elem in direct_filters:
|
||||
filter_id = filter_elem.get('id')
|
||||
label = f"filter #{filter_id}" if filter_id else '<filter> without id'
|
||||
primitives = [
|
||||
_local_name(descendant)
|
||||
for descendant in filter_elem.iter()
|
||||
if descendant is not filter_elem
|
||||
]
|
||||
unsupported = sorted(
|
||||
set(primitives) - _SUPPORTED_FILTER_PRIMITIVES
|
||||
)
|
||||
if unsupported:
|
||||
issues.add(
|
||||
f"{label} uses unsupported filter primitive(s): "
|
||||
f"{', '.join(unsupported)}"
|
||||
)
|
||||
if not _FILTER_EFFECT_PRIMITIVES.intersection(primitives):
|
||||
issues.add(
|
||||
f"{label} must contain feDropShadow or feGaussianBlur"
|
||||
)
|
||||
if any(
|
||||
_local_name(descendant) == 'feFuncA'
|
||||
and descendant.get('type') != 'linear'
|
||||
for descendant in filter_elem.iter()
|
||||
):
|
||||
issues.add(f"{label} requires feFuncA type=\"linear\"")
|
||||
|
||||
result['errors'].extend(sorted(issues))
|
||||
|
||||
def _check_gradient_interfaces(self, root: ET.Element, result: Dict) -> None:
|
||||
"""Reject gradient inheritance, transforms, and spread modes."""
|
||||
issues = set()
|
||||
for gradient in root.iter():
|
||||
tag = _local_name(gradient)
|
||||
if tag not in {'linearGradient', 'radialGradient'}:
|
||||
continue
|
||||
gradient_id = gradient.get('id')
|
||||
label = f"<{tag} id=\"{gradient_id}\">" if gradient_id else f'<{tag}>'
|
||||
attribute_names = {
|
||||
name.rsplit('}', 1)[-1]
|
||||
for name in gradient.attrib
|
||||
}
|
||||
if 'href' in attribute_names:
|
||||
issues.add(
|
||||
f"{label} cannot inherit from href/xlink:href; "
|
||||
"define gradient stops directly"
|
||||
)
|
||||
if 'gradientTransform' in attribute_names:
|
||||
issues.add(f"{label} cannot use gradientTransform")
|
||||
if 'spreadMethod' in attribute_names:
|
||||
issues.add(f"{label} cannot use spreadMethod")
|
||||
|
||||
result['errors'].extend(sorted(issues))
|
||||
|
||||
def _check_font_size_values(self, content: str, result: Dict):
|
||||
"""Require font-size values to be unitless numeric SVG px values."""
|
||||
@@ -900,6 +1223,47 @@ class SVGQualityChecker:
|
||||
f"{fallback_msg})"
|
||||
)
|
||||
|
||||
def _check_unsupported_visual_elements(
|
||||
self,
|
||||
root: ET.Element,
|
||||
result: Dict,
|
||||
) -> None:
|
||||
"""Reject authored visual elements with no native converter dispatch."""
|
||||
if _collect_unsupported_visuals is None:
|
||||
result['errors'].append(
|
||||
"Unable to import native visual-element preflight; "
|
||||
"cannot verify SVG element support"
|
||||
)
|
||||
return
|
||||
if _expand_local_use_references is None or _UseExpansionError is None:
|
||||
result['errors'].append(
|
||||
"Unable to import local <use> expansion; "
|
||||
"cannot verify SVG element support"
|
||||
)
|
||||
return
|
||||
|
||||
expanded_root = copy.deepcopy(root)
|
||||
try:
|
||||
_expand_local_use_references(expanded_root)
|
||||
except _UseExpansionError:
|
||||
# _check_forbidden_elements already reports the actionable
|
||||
# local-reference validation error.
|
||||
return
|
||||
|
||||
unsupported = _collect_unsupported_visuals(
|
||||
expanded_root,
|
||||
allow_data_icon_use=True,
|
||||
)
|
||||
if not unsupported:
|
||||
return
|
||||
|
||||
preview = '; '.join(unsupported[:8])
|
||||
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
|
||||
result['errors'].append(
|
||||
f"Unsupported visual SVG element(s) for native PPTX export: "
|
||||
f"{preview}{suffix}"
|
||||
)
|
||||
|
||||
def _check_animation_group_ids(self, root: ET.Element, result: Dict):
|
||||
"""Warn when visible top-level groups cannot be customized."""
|
||||
non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
|
||||
@@ -937,9 +1301,8 @@ class SVGQualityChecker:
|
||||
`dkUpDiag`). Two failure modes worth catching pre-export:
|
||||
|
||||
1. Missing annotation → converter silently falls back to `ltUpDiag`
|
||||
(diagonal stripes) and picks `bg = #FFFFFF` when the pattern has
|
||||
no child <rect>, turning a hand-authored grid into white-on-stripes
|
||||
in PPTX.
|
||||
(diagonal stripes). Color metadata or child paints still resolve,
|
||||
with white as the final background fallback.
|
||||
2. Invalid preset name → PPTX schema rejects the file; PowerPoint
|
||||
opens it with "needs to be repaired". OOXML
|
||||
`ST_PresetPatternVal` is a closed enum — only the names in
|
||||
@@ -954,9 +1317,9 @@ class SVGQualityChecker:
|
||||
result['warnings'].append(
|
||||
f"<pattern id=\"{pat_id}\"> has no data-pptx-pattern attribute — "
|
||||
"PPTX export will fall back to `ltUpDiag` (diagonal stripes), "
|
||||
"not your custom geometry. Add data-pptx-pattern=\"lgGrid\" / "
|
||||
"\"smGrid\" / etc. plus a <rect fill=\"<bg>\"/> child so the "
|
||||
"preset and bg color match your design."
|
||||
"not your custom geometry. Add a valid data-pptx-pattern; "
|
||||
"set data-pptx-fg/data-pptx-bg or matching child paints "
|
||||
"when explicit pattern colors are required."
|
||||
)
|
||||
continue
|
||||
if prst not in self._OOXML_PATTERN_PRESETS:
|
||||
@@ -966,8 +1329,8 @@ class SVGQualityChecker:
|
||||
"will fail schema validation ('needs to be repaired'). "
|
||||
"Use one of: smGrid / lgGrid / dotGrid (grids), "
|
||||
"ltUpDiag / dkUpDiag / cross / diagCross / weave / plaid / "
|
||||
"horzBrick (others); full enum in svg_quality_checker.py "
|
||||
"_OOXML_PATTERN_PRESETS."
|
||||
"horzBrick (others); see references/shared-standards.md §7 "
|
||||
"for the full authoring enum."
|
||||
)
|
||||
|
||||
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
|
||||
@@ -1132,7 +1495,8 @@ class SVGQualityChecker:
|
||||
def _check_spec_lock_drift(self, content: str, svg_path: Path, result: Dict):
|
||||
"""Detect values used in the SVG that fall outside spec_lock.md.
|
||||
|
||||
Covers colors (fill / stroke / stop-color), font-family, and font-size.
|
||||
Covers colors (fill / stroke / stop-color / flood-color / pattern
|
||||
metadata), font-family, and font-size.
|
||||
Emits per-file warnings summarising the drift counts; exact drifting
|
||||
values are accumulated in self._drift_summary for the end-of-run
|
||||
aggregation. When spec_lock.md is missing, silently skip (consistent
|
||||
@@ -1145,8 +1509,14 @@ class SVGQualityChecker:
|
||||
# Build allow-sets from the lock
|
||||
allowed_colors = set()
|
||||
for v in lock.get('colors', {}).values():
|
||||
if HEX_VALUE_RE.fullmatch(v):
|
||||
allowed_colors.add(v.upper())
|
||||
if _parse_export_color is not None:
|
||||
color, _alpha = _parse_export_color(v)
|
||||
if color:
|
||||
allowed_colors.add(color)
|
||||
else:
|
||||
color = _normalize_hex_rgb(v)
|
||||
if color:
|
||||
allowed_colors.add(color)
|
||||
|
||||
typo = lock.get('typography', {})
|
||||
numeric_size_re = re.compile(r'^(?:\d+(?:\.\d+)?|\.\d+)$')
|
||||
@@ -1198,13 +1568,28 @@ class SVGQualityChecker:
|
||||
|
||||
# Scan SVG for used values
|
||||
color_drifts = set()
|
||||
for attr in ('fill', 'stroke', 'stop-color'):
|
||||
for attr in (
|
||||
'fill', 'stroke', 'stop-color', 'flood-color',
|
||||
'data-pptx-fg', 'data-pptx-bg',
|
||||
):
|
||||
for raw_value in self._svg_property_values(content, attr):
|
||||
if not HEX_VALUE_RE.fullmatch(raw_value):
|
||||
normalized = raw_value.strip()
|
||||
if normalized.lower() in {'none', 'transparent'} or re.fullmatch(
|
||||
r'url\(#[^)]+\)', normalized
|
||||
):
|
||||
continue
|
||||
val = raw_value.upper()
|
||||
if _BARE_HEX_VALUE_RE.fullmatch(normalized):
|
||||
continue
|
||||
if _parse_export_color is not None:
|
||||
val, _alpha = _parse_export_color(normalized)
|
||||
if val is None:
|
||||
continue
|
||||
else:
|
||||
val = _normalize_hex_rgb(normalized)
|
||||
if val is None:
|
||||
continue
|
||||
if val not in allowed_colors:
|
||||
color_drifts.add(val)
|
||||
color_drifts.add(f'#{val}')
|
||||
|
||||
font_drifts = set()
|
||||
for val in self._font_family_values(content):
|
||||
@@ -1985,9 +2370,21 @@ class SVGQualityChecker:
|
||||
|
||||
def _check_animation_config_contract(self, dir_path: Path) -> None:
|
||||
"""Project-level animations.json reference checks."""
|
||||
if _load_animation_config is None or _validate_animation_config is None:
|
||||
return
|
||||
project_path = self._resolve_project_path(dir_path)
|
||||
config_path = project_path / 'animations.json'
|
||||
if (
|
||||
_load_animation_config is None
|
||||
or _validate_animation_config is None
|
||||
or _validate_animation_config_errors is None
|
||||
or _validate_transition_config is None
|
||||
):
|
||||
if config_path.is_file():
|
||||
detail = _animation_config_import_error or 'unknown import error'
|
||||
self._animation_issues.append((
|
||||
'error',
|
||||
f'animations.json validation is unavailable: {detail}',
|
||||
))
|
||||
return
|
||||
try:
|
||||
config = _load_animation_config(project_path)
|
||||
except Exception as exc:
|
||||
@@ -1995,8 +2392,19 @@ class SVGQualityChecker:
|
||||
return
|
||||
if not config:
|
||||
return
|
||||
for warning in _validate_animation_config(project_path, config):
|
||||
self._animation_issues.append(('warning', warning))
|
||||
fatal_errors = list(dict.fromkeys(
|
||||
_validate_transition_config(config)
|
||||
+ _validate_animation_config_errors(config)
|
||||
))
|
||||
for error in fatal_errors:
|
||||
self._animation_issues.append(('error', error))
|
||||
for message in _validate_animation_config(project_path, config):
|
||||
severity = (
|
||||
'warning'
|
||||
if ' has no id and cannot be customized in animations.json' in message
|
||||
else 'error'
|
||||
)
|
||||
self._animation_issues.append((severity, message))
|
||||
|
||||
def _check_template_contract(self, dir_path: Path,
|
||||
svg_files: List[Path]) -> None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master - SVG to PPTX Tool (thin wrapper).
|
||||
|
||||
Delegates to the svg_to_pptx package. Kept for CLI backward compatibility:
|
||||
Delegates to the svg_to_pptx package. ``-s final`` remains a native-export
|
||||
diagnostic override; the standard pipeline reads ``svg_output/``:
|
||||
python3 scripts/svg_to_pptx.py <project_path> -s final
|
||||
"""
|
||||
|
||||
|
||||
+313
-53
@@ -9,15 +9,19 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_animations import (
|
||||
ANIMATIONS,
|
||||
ANIMATION_MODES,
|
||||
ANIMATION_TRIGGERS,
|
||||
animation_seconds_to_milliseconds,
|
||||
normalize_animation_effect,
|
||||
normalize_animation_trigger,
|
||||
)
|
||||
from pptx_transitions import TRANSITIONS, validate_seconds
|
||||
|
||||
from .drawingml.utils import SVG_NS
|
||||
from .semantic_markers import is_static_page_frame
|
||||
|
||||
try:
|
||||
from pptx_animations import ANIMATIONS, TRANSITIONS
|
||||
except ImportError:
|
||||
ANIMATIONS = {}
|
||||
TRANSITIONS = {}
|
||||
|
||||
|
||||
_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
|
||||
_CHROME_ID_TOKENS = frozenset({
|
||||
@@ -38,6 +42,7 @@ class GroupTarget:
|
||||
group_id: str
|
||||
order: int
|
||||
chrome: bool = False
|
||||
structurally_static: bool = False
|
||||
|
||||
|
||||
def _tag_name(elem: ET.Element) -> str:
|
||||
@@ -77,10 +82,16 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
|
||||
role = child.get('data-pptx-role')
|
||||
placeholder = child.get('data-pptx-placeholder')
|
||||
has_explicit_semantics = role is not None or placeholder is not None
|
||||
if child.get('data-pptx-layer') is not None:
|
||||
has_structural_layer = child.get('data-pptx-layer') is not None
|
||||
semantic_static = (
|
||||
has_explicit_semantics
|
||||
and is_static_page_frame(role, placeholder)
|
||||
)
|
||||
structurally_static = has_structural_layer or semantic_static
|
||||
if has_structural_layer:
|
||||
chrome = True
|
||||
elif has_explicit_semantics:
|
||||
chrome = is_static_page_frame(role, placeholder)
|
||||
chrome = semantic_static
|
||||
else:
|
||||
chrome = is_chrome_id(group_id)
|
||||
targets.append(
|
||||
@@ -89,6 +100,7 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
|
||||
group_id=group_id,
|
||||
order=visual_index,
|
||||
chrome=chrome,
|
||||
structurally_static=structurally_static,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -135,20 +147,277 @@ def load_animation_config(project_path: Path, config_path: str | None = None) ->
|
||||
return data
|
||||
|
||||
|
||||
def _valid_animation_effect(effect: str) -> bool:
|
||||
return effect == 'none' or effect in ANIMATIONS or effect in ('auto', 'mixed', 'random')
|
||||
|
||||
|
||||
def _valid_transition_effect(effect: str) -> bool:
|
||||
return effect == 'none' or effect in TRANSITIONS
|
||||
|
||||
|
||||
def _animation_effect_error(effect: object, label: str) -> str | None:
|
||||
if not isinstance(effect, str):
|
||||
return f'animations.json {label} animation effect must be a string'
|
||||
try:
|
||||
normalize_animation_effect(effect)
|
||||
except ValueError:
|
||||
valid = ', '.join((*ANIMATIONS, *ANIMATION_MODES, 'none'))
|
||||
return (
|
||||
f'animations.json {label} has unknown animation effect: {effect}; '
|
||||
f'valid effects: {valid}'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _animation_trigger_error(trigger: object, label: str) -> str | None:
|
||||
if not isinstance(trigger, str):
|
||||
return f'animations.json {label} animation trigger must be a string'
|
||||
try:
|
||||
normalize_animation_trigger(trigger)
|
||||
except ValueError:
|
||||
valid = ', '.join(ANIMATION_TRIGGERS)
|
||||
return (
|
||||
f'animations.json {label} has unknown animation trigger: {trigger}; '
|
||||
f'valid triggers: {valid}'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _unknown_field_errors(
|
||||
value: dict[str, Any],
|
||||
allowed: frozenset[str],
|
||||
label: str,
|
||||
) -> list[str]:
|
||||
return [
|
||||
f'animations.json {label} has unknown field: {field}'
|
||||
for field in sorted(set(value) - allowed)
|
||||
]
|
||||
|
||||
|
||||
def validate_transition_config(config: dict[str, Any]) -> list[str]:
|
||||
"""Return fatal transition-sidecar errors that must block export."""
|
||||
errors: list[str] = []
|
||||
defaults = config.get('defaults', {})
|
||||
default_effect = 'fade'
|
||||
if not isinstance(defaults, dict):
|
||||
errors.append('animations.json field "defaults" must be an object')
|
||||
else:
|
||||
errors.extend(
|
||||
_transition_scope_errors(
|
||||
defaults,
|
||||
'defaults',
|
||||
inherited_effect='fade',
|
||||
)
|
||||
)
|
||||
transition_defaults = defaults.get('transition', {})
|
||||
if isinstance(transition_defaults, dict):
|
||||
value = transition_defaults.get('effect', default_effect)
|
||||
if isinstance(value, str) and _valid_transition_effect(value):
|
||||
default_effect = value
|
||||
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
errors.append('animations.json field "slides" must be an object')
|
||||
return errors
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if not isinstance(slide_cfg, dict):
|
||||
errors.append(f'animations.json slide "{slide_name}" must be an object')
|
||||
continue
|
||||
errors.extend(
|
||||
_transition_scope_errors(
|
||||
slide_cfg,
|
||||
f'slide "{slide_name}"',
|
||||
inherited_effect=default_effect,
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _transition_scope_errors(
|
||||
scope: dict[str, Any],
|
||||
label: str,
|
||||
*,
|
||||
inherited_effect: str,
|
||||
) -> list[str]:
|
||||
if 'transition' not in scope:
|
||||
return []
|
||||
transition = scope['transition']
|
||||
if not isinstance(transition, dict):
|
||||
return [f'animations.json {label} field "transition" must be an object']
|
||||
|
||||
errors = _unknown_field_errors(
|
||||
transition,
|
||||
frozenset({'effect', 'duration', 'auto_advance'}),
|
||||
f'{label} transition',
|
||||
)
|
||||
if 'effect' in transition:
|
||||
effect = transition['effect']
|
||||
if not isinstance(effect, str):
|
||||
errors.append(
|
||||
f'animations.json {label} transition effect must be a string'
|
||||
)
|
||||
elif not _valid_transition_effect(effect):
|
||||
errors.append(
|
||||
f'animations.json {label} has unknown transition effect: {effect}'
|
||||
)
|
||||
duration_allows_zero = transition.get('effect', inherited_effect) == 'none'
|
||||
for field, allow_zero in (
|
||||
('duration', duration_allows_zero),
|
||||
('auto_advance', True),
|
||||
):
|
||||
if field not in transition:
|
||||
continue
|
||||
try:
|
||||
validate_seconds(
|
||||
transition[field],
|
||||
f'animations.json {label} transition {field}',
|
||||
allow_zero=allow_zero,
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
return errors
|
||||
|
||||
|
||||
def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
|
||||
"""Return fatal object-animation errors that must block export."""
|
||||
errors = _unknown_field_errors(
|
||||
config,
|
||||
frozenset({'version', 'defaults', 'slides'}),
|
||||
'top level',
|
||||
)
|
||||
defaults = config.get('defaults', {})
|
||||
if not isinstance(defaults, dict):
|
||||
errors.append('animations.json field "defaults" must be an object')
|
||||
else:
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
defaults,
|
||||
frozenset({'transition', 'animation'}),
|
||||
'defaults',
|
||||
)
|
||||
)
|
||||
errors.extend(_animation_scope_errors(defaults, 'defaults'))
|
||||
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
errors.append('animations.json field "slides" must be an object')
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if not isinstance(slide_cfg, dict):
|
||||
errors.append(f'animations.json slide "{slide_name}" must be an object')
|
||||
continue
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
slide_cfg,
|
||||
frozenset({'transition', 'animation', 'groups'}),
|
||||
f'slide "{slide_name}"',
|
||||
)
|
||||
)
|
||||
errors.extend(
|
||||
_animation_scope_errors(slide_cfg, f'slide "{slide_name}"')
|
||||
)
|
||||
errors.extend(_animation_group_errors(slide_name, slide_cfg))
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
|
||||
def _animation_scope_errors(scope: dict[str, Any], label: str) -> list[str]:
|
||||
if 'animation' not in scope:
|
||||
return []
|
||||
animation = scope['animation']
|
||||
if not isinstance(animation, dict):
|
||||
return [f'animations.json {label} field "animation" must be an object']
|
||||
|
||||
errors = _unknown_field_errors(
|
||||
animation,
|
||||
frozenset({'effect', 'duration', 'stagger', 'trigger'}),
|
||||
f'{label} animation',
|
||||
)
|
||||
if 'effect' in animation:
|
||||
effect_error = _animation_effect_error(animation['effect'], label)
|
||||
if effect_error:
|
||||
errors.append(effect_error)
|
||||
|
||||
for field, allow_zero in (('duration', False), ('stagger', True)):
|
||||
if field not in animation:
|
||||
continue
|
||||
try:
|
||||
animation_seconds_to_milliseconds(
|
||||
animation[field],
|
||||
f'animations.json {label} animation {field}',
|
||||
allow_zero=allow_zero,
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if 'trigger' in animation:
|
||||
trigger_error = _animation_trigger_error(animation['trigger'], label)
|
||||
if trigger_error:
|
||||
errors.append(trigger_error)
|
||||
return errors
|
||||
|
||||
|
||||
def _animation_group_errors(
|
||||
slide_name: object,
|
||||
slide_cfg: dict[str, Any],
|
||||
) -> list[str]:
|
||||
if 'groups' not in slide_cfg:
|
||||
return []
|
||||
groups = slide_cfg['groups']
|
||||
if not isinstance(groups, dict):
|
||||
return [
|
||||
f'animations.json slide "{slide_name}" field "groups" must be an object'
|
||||
]
|
||||
|
||||
errors: list[str] = []
|
||||
for group_id, group_cfg in groups.items():
|
||||
label = f'group "{slide_name}/{group_id}"'
|
||||
if not isinstance(group_cfg, dict):
|
||||
errors.append(f'animations.json {label} must be an object')
|
||||
continue
|
||||
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
group_cfg,
|
||||
frozenset({'effect', 'duration', 'delay', 'order'}),
|
||||
label,
|
||||
)
|
||||
)
|
||||
|
||||
if 'effect' in group_cfg:
|
||||
effect_error = _animation_effect_error(group_cfg['effect'], label)
|
||||
if effect_error:
|
||||
errors.append(effect_error)
|
||||
|
||||
for field, allow_zero in (('duration', False), ('delay', True)):
|
||||
if field not in group_cfg:
|
||||
continue
|
||||
try:
|
||||
animation_seconds_to_milliseconds(
|
||||
group_cfg[field],
|
||||
f'animations.json {label} animation {field}',
|
||||
allow_zero=allow_zero,
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if 'order' in group_cfg:
|
||||
order = group_cfg['order']
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
|
||||
errors.append(
|
||||
f'animations.json {label} animation order must be a positive integer: '
|
||||
f'{order!r}'
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_animation_config(
|
||||
project_path: Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
config_path: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Validate sidecar references against current ``svg_output``."""
|
||||
"""Return sidecar-reference diagnostics for ``svg_output``.
|
||||
|
||||
Fatal field/type/value checks are owned by
|
||||
:func:`validate_animation_config_errors`. Anonymous groups are warnings;
|
||||
missing slides/groups and structural targets are fatal at export call sites.
|
||||
"""
|
||||
if config is None:
|
||||
config = load_animation_config(project_path, config_path)
|
||||
if not config:
|
||||
@@ -161,54 +430,39 @@ def validate_animation_config(
|
||||
|
||||
known_slides = set(targets_by_slide)
|
||||
slides = config.get('slides', {})
|
||||
if slides and not isinstance(slides, dict):
|
||||
return ['animations.json field "slides" must be an object']
|
||||
if not isinstance(slides, dict):
|
||||
return list(dict.fromkeys(warnings))
|
||||
for slide_name in sorted(known_slides - set(slides)):
|
||||
warnings.append(f'animations.json omits slide: {slide_name}')
|
||||
|
||||
defaults = config.get('defaults', {})
|
||||
if isinstance(defaults, dict):
|
||||
_validate_scope_effects(defaults, 'defaults', warnings)
|
||||
|
||||
for slide_name, slide_cfg in (slides or {}).items():
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if slide_name not in known_slides:
|
||||
warnings.append(f'animations.json references missing slide: {slide_name}')
|
||||
continue
|
||||
if not isinstance(slide_cfg, dict):
|
||||
warnings.append(f'animations.json slide "{slide_name}" must be an object')
|
||||
continue
|
||||
_validate_scope_effects(slide_cfg, f'slide "{slide_name}"', warnings)
|
||||
|
||||
known_groups = {target.group_id for target in targets_by_slide[slide_name]}
|
||||
known_groups = {
|
||||
target.group_id: target
|
||||
for target in targets_by_slide.get(slide_name, [])
|
||||
}
|
||||
groups = slide_cfg.get('groups', {})
|
||||
if groups and not isinstance(groups, dict):
|
||||
warnings.append(f'animations.json slide "{slide_name}" field "groups" must be an object')
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in (groups or {}).items():
|
||||
for group_id, group_cfg in groups.items():
|
||||
if group_id not in known_groups:
|
||||
warnings.append(
|
||||
f'animations.json references missing group: {slide_name}/{group_id}'
|
||||
)
|
||||
if not isinstance(group_cfg, dict):
|
||||
warnings.append(f'animations.json group "{slide_name}/{group_id}" must be an object')
|
||||
continue
|
||||
effect = group_cfg.get('effect')
|
||||
if effect is not None and not _valid_animation_effect(str(effect)):
|
||||
target = known_groups[group_id]
|
||||
effect = group_cfg.get('effect') if isinstance(group_cfg, dict) else None
|
||||
if target.structurally_static and effect != 'none':
|
||||
warnings.append(
|
||||
f'animations.json group "{slide_name}/{group_id}" has unknown effect: {effect}'
|
||||
'animations.json references non-animatable structural group: '
|
||||
f'{slide_name}/{group_id}'
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[str]) -> None:
|
||||
transition = scope.get('transition', {})
|
||||
if isinstance(transition, dict):
|
||||
effect = transition.get('effect')
|
||||
if effect is not None and not _valid_transition_effect(str(effect)):
|
||||
warnings.append(f'animations.json {label} has unknown transition effect: {effect}')
|
||||
animation = scope.get('animation', {})
|
||||
if isinstance(animation, dict):
|
||||
effect = animation.get('effect')
|
||||
if effect is not None and not _valid_animation_effect(str(effect)):
|
||||
warnings.append(f'animations.json {label} has unknown animation effect: {effect}')
|
||||
return list(dict.fromkeys(warnings))
|
||||
|
||||
|
||||
def build_scaffold(project_path: Path) -> dict[str, Any]:
|
||||
@@ -221,6 +475,13 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
|
||||
to remind the editor that deck-wide overrides exist and most pages should
|
||||
inherit them.
|
||||
"""
|
||||
transition_defaults = {'effect': 'fade', 'duration': 0.4}
|
||||
animation_defaults = {
|
||||
'effect': 'auto',
|
||||
'duration': 0.4,
|
||||
'stagger': 0.5,
|
||||
'trigger': 'after-previous',
|
||||
}
|
||||
targets_by_slide, _anonymous = scan_project_targets(project_path)
|
||||
slides: dict[str, Any] = {}
|
||||
for slide_name, targets in targets_by_slide.items():
|
||||
@@ -229,17 +490,16 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
|
||||
if target.chrome:
|
||||
continue
|
||||
groups[target.group_id] = {}
|
||||
slides[slide_name] = {'groups': groups}
|
||||
slides[slide_name] = {
|
||||
'transition': dict(transition_defaults),
|
||||
'animation': dict(animation_defaults),
|
||||
'groups': groups,
|
||||
}
|
||||
return {
|
||||
'version': 1,
|
||||
'defaults': {
|
||||
'transition': {'effect': 'fade', 'duration': 0.4},
|
||||
'animation': {
|
||||
'effect': 'auto',
|
||||
'duration': 0.4,
|
||||
'stagger': 0.5,
|
||||
'trigger': 'after-previous',
|
||||
},
|
||||
'transition': transition_defaults,
|
||||
'animation': animation_defaults,
|
||||
},
|
||||
'slides': slides,
|
||||
}
|
||||
|
||||
+13
-16
@@ -50,11 +50,18 @@ class ConvertContext:
|
||||
rel_id_counter: int = 2 # rId1 reserved for slideLayout
|
||||
svg_dir: Path | None = None
|
||||
inherited_styles: dict[str, str] = field(default_factory=dict)
|
||||
# SVG group opacity is post-compositing, not an inherited presentation
|
||||
# property. DrawingML has no equivalent group alpha, so native export
|
||||
# approximates it by multiplying this value into each descendant object.
|
||||
opacity_multiplier: float = 1.0
|
||||
# Recursion depth — only the depth==0 (root) context records anim targets.
|
||||
depth: int = 0
|
||||
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
|
||||
# Used by the PPTX builder to emit per-element entrance timing.
|
||||
anim_targets: list = field(default_factory=list)
|
||||
# Explicit sidecar group ids may override the legacy chrome-name heuristic.
|
||||
# Explicit structural layer/role/placeholder markers remain non-animatable.
|
||||
animation_group_overrides: frozenset[str] = frozenset()
|
||||
# Default-on flag: merge mergeable paragraph blocks into one editable
|
||||
# text frame with multiple <a:p>. Disable it for strict line fidelity.
|
||||
merge_paragraphs: bool = True
|
||||
@@ -99,6 +106,7 @@ class ConvertContext:
|
||||
transform_matrix: AffineMatrix | None = None,
|
||||
filter_id: str | None = None,
|
||||
style_overrides: dict[str, str] | None = None,
|
||||
opacity_multiplier: float = 1.0,
|
||||
) -> ConvertContext:
|
||||
"""Create a child context with accumulated translate / scale / styles.
|
||||
|
||||
@@ -111,6 +119,7 @@ class ConvertContext:
|
||||
converters that can faithfully map it to DrawingML.
|
||||
filter_id: Override filter ID.
|
||||
style_overrides: Style attribute overrides from child element.
|
||||
opacity_multiplier: Local group opacity to multiply into descendants.
|
||||
"""
|
||||
local_matrix = transform_matrix or IDENTITY_MATRIX
|
||||
# When first crossing from scalar to matrix mode, fold accumulated
|
||||
@@ -138,24 +147,10 @@ class ConvertContext:
|
||||
)
|
||||
|
||||
merged = dict(self.inherited_styles)
|
||||
|
||||
if style_overrides:
|
||||
# Opacity is multiplicative, not a simple override
|
||||
_OPACITY_KEYS = ('opacity', 'fill-opacity', 'stroke-opacity')
|
||||
for op_key in _OPACITY_KEYS:
|
||||
if op_key in style_overrides and op_key in merged:
|
||||
try:
|
||||
merged[op_key] = str(
|
||||
float(merged[op_key]) * float(style_overrides[op_key])
|
||||
)
|
||||
except ValueError:
|
||||
merged[op_key] = style_overrides[op_key]
|
||||
elif op_key in style_overrides:
|
||||
merged[op_key] = style_overrides[op_key]
|
||||
merged.update(style_overrides)
|
||||
|
||||
for k, v in style_overrides.items():
|
||||
if k not in _OPACITY_KEYS:
|
||||
merged[k] = v
|
||||
local_opacity = max(0.0, min(1.0, opacity_multiplier))
|
||||
|
||||
return ConvertContext(
|
||||
defs=self.defs,
|
||||
@@ -177,9 +172,11 @@ class ConvertContext:
|
||||
rel_id_counter=self.rel_id_counter,
|
||||
svg_dir=self.svg_dir,
|
||||
inherited_styles=merged,
|
||||
opacity_multiplier=self.opacity_multiplier * local_opacity,
|
||||
depth=self.depth + 1,
|
||||
# anim_targets is intentionally a fresh list on the child;
|
||||
# only the root-level context's list is read by the builder.
|
||||
animation_group_overrides=self.animation_group_overrides,
|
||||
merge_paragraphs=self.merge_paragraphs,
|
||||
native_objects_enabled=self.native_objects_enabled,
|
||||
image_optimize=self.image_optimize,
|
||||
|
||||
+168
-25
@@ -19,7 +19,8 @@ from .utils import (
|
||||
parse_svg_length,
|
||||
)
|
||||
from .styles import (
|
||||
build_effect_xml, build_fill_xml, get_fill_opacity, get_stroke_opacity,
|
||||
build_effect_xml, build_fill_xml,
|
||||
get_element_opacity, get_fill_opacity, get_stroke_opacity,
|
||||
)
|
||||
from .elements import (
|
||||
convert_rect, convert_circle, convert_ellipse,
|
||||
@@ -154,6 +155,9 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
filter_id = resolve_url_id(elem.get('filter', ''))
|
||||
style_overrides = _extract_inheritable_styles(elem)
|
||||
local_opacity = get_element_opacity(elem)
|
||||
if local_opacity is None:
|
||||
local_opacity = 1.0
|
||||
|
||||
elem_id = elem.get('id')
|
||||
semantic_role = elem.get('data-pptx-role')
|
||||
@@ -169,8 +173,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
should_animate_group = (
|
||||
ctx.depth == 0
|
||||
and elem_id
|
||||
and not is_chrome
|
||||
and not elem.get('data-pptx-layer')
|
||||
and (
|
||||
not is_chrome
|
||||
or (
|
||||
not has_explicit_semantics
|
||||
and elem_id in ctx.animation_group_overrides
|
||||
)
|
||||
)
|
||||
and elem.get('data-pptx-layer') is None
|
||||
)
|
||||
visual_children = [
|
||||
child for child in elem
|
||||
@@ -194,12 +204,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
transform_matrix=parse_transform_matrix(transform),
|
||||
filter_id=filter_id,
|
||||
style_overrides=style_overrides,
|
||||
opacity_multiplier=local_opacity,
|
||||
)
|
||||
elif rotate_pivot is not None:
|
||||
child_ctx = ctx.child(
|
||||
0, 0, 1.0, 1.0,
|
||||
filter_id=filter_id,
|
||||
style_overrides=style_overrides,
|
||||
opacity_multiplier=local_opacity,
|
||||
)
|
||||
else:
|
||||
child_ctx = ctx.child(
|
||||
@@ -209,6 +221,13 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
sy,
|
||||
filter_id=filter_id,
|
||||
style_overrides=style_overrides,
|
||||
opacity_multiplier=local_opacity,
|
||||
)
|
||||
|
||||
if native_subtree_active and child_ctx.opacity_multiplier < 1.0:
|
||||
raise SvgNativeConversionError(
|
||||
"Group opacity cannot be applied to data-pptx-native table/chart "
|
||||
"objects; export without --native-objects to use the SVG fallback"
|
||||
)
|
||||
|
||||
if child_ctx.native_objects_enabled:
|
||||
@@ -298,7 +317,10 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
group_effect = ''
|
||||
if filter_id and filter_id in ctx.defs:
|
||||
group_effect = build_effect_xml(ctx.defs[filter_id])
|
||||
group_effect = build_effect_xml(
|
||||
ctx.defs[filter_id],
|
||||
child_ctx.opacity_multiplier,
|
||||
)
|
||||
|
||||
rot_emu = 0 if matrix_supported else int(angle_deg * 60000)
|
||||
rot_attr = f' rot="{rot_emu}"' if rot_emu else ''
|
||||
@@ -466,7 +488,11 @@ def _extract_background_candidate(
|
||||
if child.get('transform') or child.get('filter') or child.get('clip-path'):
|
||||
return '', None
|
||||
style_overrides = _extract_inheritable_styles(child)
|
||||
child_ctx = ctx.child(style_overrides=style_overrides)
|
||||
local_opacity = get_element_opacity(child)
|
||||
child_ctx = ctx.child(
|
||||
style_overrides=style_overrides,
|
||||
opacity_multiplier=1.0 if local_opacity is None else local_opacity,
|
||||
)
|
||||
visual_children = [
|
||||
grandchild for grandchild in child
|
||||
if grandchild.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
|
||||
@@ -561,25 +587,50 @@ def _local_tag(elem: ET.Element) -> str:
|
||||
return elem.tag.split('}', 1)[-1] if isinstance(elem.tag, str) and '}' in elem.tag else str(elem.tag)
|
||||
|
||||
|
||||
def _collect_unsupported_visuals(root: ET.Element) -> list[str]:
|
||||
def collect_unsupported_visuals(
|
||||
root: ET.Element,
|
||||
*,
|
||||
allow_data_icon_use: bool = False,
|
||||
) -> list[str]:
|
||||
"""Return visual element paths that the native converter cannot dispatch."""
|
||||
issues: list[str] = []
|
||||
|
||||
def walk(elem: ET.Element, path: str, in_defs: bool = False) -> None:
|
||||
def walk(
|
||||
elem: ET.Element,
|
||||
path: str,
|
||||
in_defs: bool = False,
|
||||
parent_tag: str | None = None,
|
||||
) -> None:
|
||||
tag = _local_tag(elem)
|
||||
current = f'{path}/{tag}'
|
||||
if in_defs:
|
||||
return
|
||||
if tag in _NON_VISUAL_TAGS:
|
||||
return
|
||||
is_supported_visual_child = (
|
||||
tag in _SUPPORTED_VISUAL_CHILD_TAGS
|
||||
and parent_tag in {'text', 'tspan'}
|
||||
)
|
||||
is_data_icon_placeholder = (
|
||||
allow_data_icon_use
|
||||
and tag == 'use'
|
||||
and elem.get('data-icon') is not None
|
||||
)
|
||||
if (tag not in _CONVERTERS
|
||||
and tag not in _NON_VISUAL_TAGS
|
||||
and tag not in _SUPPORTED_VISUAL_CHILD_TAGS):
|
||||
and not is_supported_visual_child
|
||||
and not is_data_icon_placeholder):
|
||||
issues.append(current)
|
||||
for idx, child in enumerate(list(elem), start=1):
|
||||
walk(child, f'{current}[{idx}]', in_defs=(tag == 'defs'))
|
||||
walk(
|
||||
child,
|
||||
f'{current}[{idx}]',
|
||||
in_defs=(tag == 'defs'),
|
||||
parent_tag=tag,
|
||||
)
|
||||
|
||||
for idx, child in enumerate(list(root), start=1):
|
||||
walk(child, f'/svg[{idx}]')
|
||||
walk(child, f'/svg[{idx}]', parent_tag='svg')
|
||||
return issues
|
||||
|
||||
|
||||
@@ -594,6 +645,7 @@ def convert_svg_to_slide_shapes(
|
||||
image_scale: float = 2.0,
|
||||
image_quality: int = 85,
|
||||
native_objects: bool = False,
|
||||
animation_group_overrides: frozenset[str] | None = None,
|
||||
theme_font_spec: ThemeFontSpec | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
trace_out: list[dict[str, Any]] | None = None,
|
||||
@@ -623,6 +675,9 @@ def convert_svg_to_slide_shapes(
|
||||
image_quality: JPEG quality used for opaque optimized rasters.
|
||||
native_objects: Convert explicit ``data-pptx-native`` table/chart
|
||||
markers to native PowerPoint objects. Default off.
|
||||
animation_group_overrides: Explicit top-level SVG group ids from
|
||||
``animations.json`` that override the legacy chrome-name fallback.
|
||||
Explicit structural layer/role/placeholder markers remain excluded.
|
||||
theme_font_spec: Optional major/minor theme-font contract. Matching SVG
|
||||
families emit DrawingML theme tokens instead of fixed typefaces.
|
||||
theme_color_spec: Optional context-aware theme-color contract. Exact
|
||||
@@ -646,24 +701,84 @@ def convert_svg_to_slide_shapes(
|
||||
"""
|
||||
tree = ET.parse(str(svg_path))
|
||||
root = tree.getroot()
|
||||
viewport_width, viewport_height = _root_viewport_size(root)
|
||||
trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
|
||||
trace_steps: list[dict[str, Any]] = []
|
||||
|
||||
# Expand <use data-icon="..."/> placeholders in-memory so this dispatcher
|
||||
# can consume svg_output/ directly. Standard renderers and this converter
|
||||
# both ignore data-icon, so without expansion icons would silently drop.
|
||||
# The on-disk finalize_svg pipeline does the same expansion for svg_final/;
|
||||
# running this here makes the two pipelines behaviourally aligned.
|
||||
from ..geometry_properties import (
|
||||
GeometryStyleError,
|
||||
materialize_inline_geometry_properties,
|
||||
)
|
||||
|
||||
try:
|
||||
geometry_count = materialize_inline_geometry_properties(root)
|
||||
except GeometryStyleError as exc:
|
||||
raise SvgNativeConversionError(
|
||||
f'{svg_path.name}: inline geometry materialization failed: {exc}'
|
||||
) from exc
|
||||
geometry_trace = None
|
||||
if geometry_count:
|
||||
geometry_trace = {
|
||||
'action': 'materialize-inline-geometry',
|
||||
'count': geometry_count,
|
||||
}
|
||||
trace_steps.append(geometry_trace)
|
||||
if verbose:
|
||||
print(f' Materialized {geometry_count} inline geometry declaration(s)')
|
||||
|
||||
viewport_width, viewport_height = _root_viewport_size(root)
|
||||
|
||||
# Expand project icon placeholders and static same-document <use>
|
||||
# references before unsupported-element preflight.
|
||||
from ..use_expander import (
|
||||
UseExpansionError,
|
||||
expand_local_use_references,
|
||||
expand_use_data_icons,
|
||||
)
|
||||
|
||||
icons_dir, icons_fallback_dir = icon_search_dirs_for_svg(svg_path)
|
||||
if icons_dir.exists():
|
||||
from ..use_expander import expand_use_data_icons
|
||||
expanded = expand_use_data_icons(root, icons_dir, icons_fallback_dir)
|
||||
if expanded:
|
||||
trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded})
|
||||
if verbose and expanded:
|
||||
print(f' Expanded {expanded} <use data-icon="..."/> placeholder(s)')
|
||||
|
||||
try:
|
||||
injected_geometry_count = materialize_inline_geometry_properties(root)
|
||||
except GeometryStyleError as exc:
|
||||
raise SvgNativeConversionError(
|
||||
f'{svg_path.name}: expanded icon geometry materialization failed: {exc}'
|
||||
) from exc
|
||||
if injected_geometry_count:
|
||||
geometry_count += injected_geometry_count
|
||||
if geometry_trace is None:
|
||||
geometry_trace = {
|
||||
'action': 'materialize-inline-geometry',
|
||||
'count': geometry_count,
|
||||
}
|
||||
trace_steps.append(geometry_trace)
|
||||
else:
|
||||
geometry_trace['count'] = geometry_count
|
||||
if verbose:
|
||||
print(
|
||||
f' Materialized {injected_geometry_count} inline geometry '
|
||||
'declaration(s) from expanded icons'
|
||||
)
|
||||
|
||||
try:
|
||||
expanded_local = expand_local_use_references(root)
|
||||
except UseExpansionError as exc:
|
||||
raise SvgNativeConversionError(
|
||||
f'{svg_path.name}: local <use> expansion failed: {exc}'
|
||||
) from exc
|
||||
if expanded_local:
|
||||
trace_steps.append({
|
||||
'action': 'expand-local-use-references',
|
||||
'count': expanded_local,
|
||||
})
|
||||
if verbose:
|
||||
print(f' Expanded {expanded_local} local <use href="#..."/> instance(s)')
|
||||
|
||||
# Flatten positional <tspan> (those with x/y/non-zero dy) into independent
|
||||
# <text> elements. DrawingML runs cannot reposition mid-paragraph, so a
|
||||
# dy-stacked block of tspans would otherwise collapse onto one baseline,
|
||||
@@ -682,7 +797,7 @@ def convert_svg_to_slide_shapes(
|
||||
if verbose:
|
||||
print(' Flattened positional <tspan> into independent <text>')
|
||||
|
||||
unsupported = _collect_unsupported_visuals(root)
|
||||
unsupported = collect_unsupported_visuals(root)
|
||||
if unsupported:
|
||||
preview = '; '.join(unsupported[:8])
|
||||
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
|
||||
@@ -704,6 +819,7 @@ def convert_svg_to_slide_shapes(
|
||||
image_scale=image_scale,
|
||||
image_quality=image_quality,
|
||||
native_objects_enabled=native_objects,
|
||||
animation_group_overrides=animation_group_overrides or frozenset(),
|
||||
trace_events=trace_events,
|
||||
theme_font_spec=theme_font_spec,
|
||||
theme_color_spec=theme_color_spec,
|
||||
@@ -712,6 +828,10 @@ def convert_svg_to_slide_shapes(
|
||||
shapes: list[str] = []
|
||||
converted = 0
|
||||
skipped = 0
|
||||
has_top_level_group = any(
|
||||
child.tag.replace(f'{{{SVG_NS}}}', '') == 'g'
|
||||
for child in root
|
||||
)
|
||||
background_xml, background_skip_id = _extract_background_candidate(root, ctx)
|
||||
promoted_backgrounds = 1 if background_xml else 0
|
||||
if background_xml and trace_events is not None:
|
||||
@@ -735,12 +855,31 @@ def convert_svg_to_slide_shapes(
|
||||
shapes.append(result.xml)
|
||||
converted += 1
|
||||
m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
|
||||
if m and not child.get('data-pptx-layer'):
|
||||
if not is_static_page_frame(
|
||||
child.get('data-pptx-role'),
|
||||
child.get('data-pptx-placeholder'),
|
||||
):
|
||||
fallback_targets.append((int(m.group(1)), tag))
|
||||
elem_id = child.get('id')
|
||||
role = child.get('data-pptx-role')
|
||||
placeholder = child.get('data-pptx-placeholder')
|
||||
has_explicit_semantics = role is not None or placeholder is not None
|
||||
structurally_static = (
|
||||
child.get('data-pptx-layer') is not None
|
||||
or (
|
||||
has_explicit_semantics
|
||||
and is_static_page_frame(role, placeholder)
|
||||
)
|
||||
)
|
||||
legacy_chrome = (
|
||||
not has_explicit_semantics
|
||||
and is_chrome_id(elem_id)
|
||||
)
|
||||
explicit_legacy_override = (
|
||||
elem_id is not None
|
||||
and elem_id in ctx.animation_group_overrides
|
||||
)
|
||||
if (
|
||||
m
|
||||
and not structurally_static
|
||||
and (not legacy_chrome or explicit_legacy_override)
|
||||
):
|
||||
fallback_targets.append((int(m.group(1)), elem_id or tag))
|
||||
else:
|
||||
if tag not in _NON_VISUAL_TAGS:
|
||||
skipped += 1
|
||||
@@ -752,7 +891,11 @@ def convert_svg_to_slide_shapes(
|
||||
# semantic blocks, not atomized drawing primitives, so fallback is
|
||||
# intentionally capped at a low count.
|
||||
_ANIM_FALLBACK_CAP = 8
|
||||
if not ctx.anim_targets and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP:
|
||||
if (
|
||||
not has_top_level_group
|
||||
and not ctx.anim_targets
|
||||
and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP
|
||||
):
|
||||
ctx.anim_targets = fallback_targets
|
||||
|
||||
if verbose:
|
||||
|
||||
+124
-28
@@ -22,15 +22,16 @@ from .utils import (
|
||||
svg_length_x, svg_length_y, svg_length_size,
|
||||
ctx_x, ctx_y, ctx_w, ctx_h,
|
||||
rect_to_dml_xfrm,
|
||||
parse_hex_color, resolve_url_id, get_effective_filter_id,
|
||||
combine_opacity, parse_hex_color, parse_svg_color,
|
||||
resolve_url_id, get_effective_filter_id,
|
||||
parse_inline_style, parse_font_family, is_cjk_char, estimate_text_width,
|
||||
detect_text_lang, resolve_text_run_fonts,
|
||||
detect_text_lang, font_px_to_hpt, resolve_text_run_fonts,
|
||||
matrix_multiply, parse_transform_matrix, transform_point, _xml_escape,
|
||||
)
|
||||
from .styles import (
|
||||
build_solid_fill, build_gradient_fill,
|
||||
build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
|
||||
get_fill_opacity, get_stroke_opacity,
|
||||
get_element_opacity, get_fill_opacity, get_stroke_opacity,
|
||||
)
|
||||
from .paths import (
|
||||
PathCommand, parse_svg_path, svg_path_to_absolute,
|
||||
@@ -363,7 +364,10 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
effect = ''
|
||||
filt_id = get_effective_filter_id(elem, ctx)
|
||||
if filt_id and filt_id in ctx.defs:
|
||||
effect = build_effect_xml(ctx.defs[filt_id])
|
||||
effect = build_effect_xml(
|
||||
ctx.defs[filt_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
transform = elem.get('transform')
|
||||
|
||||
@@ -554,7 +558,7 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
# Use the stroke color/gradient as fill for the arc shape
|
||||
stroke_val = _get_attr(elem, 'stroke', ctx)
|
||||
op = get_fill_opacity(elem, ctx)
|
||||
op = get_stroke_opacity(elem, ctx)
|
||||
grad_id = resolve_url_id(stroke_val) if stroke_val else None
|
||||
if grad_id and grad_id in ctx.defs:
|
||||
fill = build_gradient_fill(
|
||||
@@ -564,9 +568,14 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"fill",
|
||||
)
|
||||
elif stroke_val:
|
||||
color = parse_hex_color(stroke_val)
|
||||
color, color_alpha = parse_svg_color(stroke_val)
|
||||
fill = (
|
||||
build_solid_fill(color, op, ctx.theme_color_spec, "fill")
|
||||
build_solid_fill(
|
||||
color,
|
||||
combine_opacity(op, color_alpha),
|
||||
ctx.theme_color_spec,
|
||||
"fill",
|
||||
)
|
||||
if color else '<a:noFill/>'
|
||||
)
|
||||
else:
|
||||
@@ -577,7 +586,10 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
effect = ''
|
||||
filt_id = get_effective_filter_id(elem, ctx)
|
||||
if filt_id and filt_id in ctx.defs:
|
||||
effect = build_effect_xml(ctx.defs[filt_id])
|
||||
effect = build_effect_xml(
|
||||
ctx.defs[filt_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
shape_id = ctx.next_id()
|
||||
return ShapeResult(
|
||||
@@ -609,7 +621,10 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
effect = ''
|
||||
filt_id = get_effective_filter_id(elem, ctx)
|
||||
if filt_id and filt_id in ctx.defs:
|
||||
effect = build_effect_xml(ctx.defs[filt_id])
|
||||
effect = build_effect_xml(
|
||||
ctx.defs[filt_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||||
|
||||
@@ -815,7 +830,10 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
effect = ''
|
||||
filt_id = get_effective_filter_id(elem, ctx)
|
||||
if filt_id and filt_id in ctx.defs:
|
||||
effect = build_effect_xml(ctx.defs[filt_id])
|
||||
effect = build_effect_xml(
|
||||
ctx.defs[filt_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
shape_id = ctx.next_id()
|
||||
off_x = px_to_emu(min_x)
|
||||
@@ -1198,6 +1216,8 @@ def _extract_text_bullet(
|
||||
bullet = {
|
||||
'char': _TEXT_BULLET_MARKERS.get(marker, marker),
|
||||
'fill': marker_run.get('fill'),
|
||||
'fill_raw': marker_run.get('fill_raw'),
|
||||
'opacity': marker_run.get('opacity'),
|
||||
'source_prefix_width_px': _estimate_text_runs_width(prefix_runs, include_headroom=False),
|
||||
'margin_px': max(
|
||||
_estimate_text_runs_width(replacement_runs, include_headroom=False),
|
||||
@@ -1226,10 +1246,21 @@ def _build_bullet_xml(
|
||||
if not bullet:
|
||||
return ''
|
||||
fill = bullet.get('fill')
|
||||
if isinstance(fill, str) and re.fullmatch(r'[0-9A-Fa-f]{6}', fill):
|
||||
fill_raw = bullet.get('fill_raw')
|
||||
color, color_alpha = parse_svg_color(
|
||||
fill_raw if isinstance(fill_raw, str) else ''
|
||||
)
|
||||
if color is None and isinstance(fill, str):
|
||||
color = parse_hex_color(fill)
|
||||
if color:
|
||||
opacity = combine_opacity(bullet.get('opacity'), color_alpha)
|
||||
alpha_xml = (
|
||||
f'<a:alphaMod val="{int(opacity * 100000)}"/>'
|
||||
if opacity is not None else ''
|
||||
)
|
||||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||||
color_xml = (
|
||||
f'<a:buClr>{color_node_xml(fill, theme_spec, "text")}</a:buClr>'
|
||||
f'<a:buClr>{color_node_xml(color, theme_spec, "text", alpha_xml)}</a:buClr>'
|
||||
)
|
||||
else:
|
||||
color_xml = '<a:buClrTx/>'
|
||||
@@ -1272,6 +1303,16 @@ def _textbox_padding(font_size: float) -> float:
|
||||
)
|
||||
|
||||
|
||||
def _text_opacity_ratio(value: str | None) -> float:
|
||||
"""Parse a text opacity component and clamp it to the SVG ``0..1`` range."""
|
||||
if value is None:
|
||||
return 1.0
|
||||
try:
|
||||
return max(0.0, min(1.0, float(value)))
|
||||
except ValueError:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _override_run_attrs(
|
||||
parent_attrs: dict[str, Any],
|
||||
tspan: ET.Element,
|
||||
@@ -1283,6 +1324,27 @@ def _override_run_attrs(
|
||||
def tspan_attr(name: str) -> str | None:
|
||||
return inline_style.get(name) or tspan.get(name)
|
||||
|
||||
object_opacity = float(run_attrs.get('_object_opacity', 1.0))
|
||||
fill_opacity = float(run_attrs.get('_fill_opacity', 1.0))
|
||||
stroke_opacity = float(run_attrs.get('_stroke_opacity', 1.0))
|
||||
if tspan_attr('opacity') is not None:
|
||||
object_opacity *= _text_opacity_ratio(tspan_attr('opacity'))
|
||||
if tspan_attr('fill-opacity') is not None:
|
||||
fill_opacity = _text_opacity_ratio(tspan_attr('fill-opacity'))
|
||||
if tspan_attr('stroke-opacity') is not None:
|
||||
stroke_opacity = _text_opacity_ratio(tspan_attr('stroke-opacity'))
|
||||
run_attrs['_object_opacity'] = object_opacity
|
||||
run_attrs['_fill_opacity'] = fill_opacity
|
||||
run_attrs['_stroke_opacity'] = stroke_opacity
|
||||
effective_fill_opacity = object_opacity * fill_opacity
|
||||
effective_stroke_opacity = object_opacity * stroke_opacity
|
||||
run_attrs['opacity'] = (
|
||||
effective_fill_opacity if effective_fill_opacity < 1.0 else None
|
||||
)
|
||||
run_attrs['stroke_opacity'] = (
|
||||
effective_stroke_opacity if effective_stroke_opacity < 1.0 else None
|
||||
)
|
||||
|
||||
if tspan_attr('font-weight'):
|
||||
run_attrs['font_weight'] = tspan_attr('font-weight')
|
||||
if tspan_attr('fill'):
|
||||
@@ -1299,11 +1361,6 @@ def _override_run_attrs(
|
||||
run_attrs.get('stroke_width', 1.0),
|
||||
font_size=float(run_attrs.get('font_size', 16)),
|
||||
)
|
||||
if tspan_attr('stroke-opacity'):
|
||||
try:
|
||||
run_attrs['stroke_opacity'] = float(tspan_attr('stroke-opacity') or '1')
|
||||
except ValueError:
|
||||
pass
|
||||
if tspan_attr('font-size'):
|
||||
run_attrs['font_size'] = parse_svg_length(
|
||||
tspan_attr('font-size'),
|
||||
@@ -1411,8 +1468,11 @@ def _build_text_fill_xml(
|
||||
"text",
|
||||
)
|
||||
|
||||
parsed_color, color_alpha = parse_svg_color(fill_raw)
|
||||
fill = parsed_color or fill
|
||||
opacity = combine_opacity(opacity, color_alpha)
|
||||
alpha_xml = ''
|
||||
if opacity is not None and opacity < 1.0:
|
||||
if opacity is not None:
|
||||
alpha_xml = f'<a:alphaMod val="{int(opacity * 100000)}"/>'
|
||||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||||
return (
|
||||
@@ -1431,14 +1491,14 @@ def _build_text_outline_xml(
|
||||
if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
|
||||
return ''
|
||||
|
||||
color = parse_hex_color(stroke_raw)
|
||||
color, color_alpha = parse_svg_color(stroke_raw)
|
||||
if not color:
|
||||
return ''
|
||||
|
||||
stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0)
|
||||
stroke_opacity = run.get('stroke_opacity')
|
||||
stroke_opacity = combine_opacity(run.get('stroke_opacity'), color_alpha)
|
||||
alpha_xml = ''
|
||||
if stroke_opacity is not None and stroke_opacity < 1.0:
|
||||
if stroke_opacity is not None:
|
||||
alpha_xml = f'<a:alphaMod val="{int(stroke_opacity * 100000)}"/>'
|
||||
|
||||
theme_spec = ctx.theme_color_spec if ctx is not None else None
|
||||
@@ -1474,7 +1534,7 @@ def _build_run_xml(
|
||||
# rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
|
||||
# / integer snapping — whatever the px works out to is the size, e.g.
|
||||
# 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
|
||||
sz = int(round(fs_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
|
||||
sz = font_px_to_hpt(fs_px)
|
||||
b_attr = ' b="1"' if fw in ('bold', '600', '700', '800', '900') else ''
|
||||
i_attr = ' i="1"' if fstyle == 'italic' else ''
|
||||
u_attr = ' u="sng"' if 'underline' in text_dec else ''
|
||||
@@ -1520,9 +1580,13 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
fill_raw = _get_attr(elem, 'fill', ctx) or '#000000'
|
||||
fill_color = parse_hex_color(fill_raw) or '000000'
|
||||
opacity = get_fill_opacity(elem, ctx)
|
||||
object_opacity = get_element_opacity(elem, ctx)
|
||||
object_opacity = 1.0 if object_opacity is None else object_opacity
|
||||
fill_opacity = _text_opacity_ratio(_get_attr(elem, 'fill-opacity', ctx))
|
||||
stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
|
||||
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0)
|
||||
stroke_opacity = get_stroke_opacity(elem, ctx)
|
||||
stroke_opacity_value = _text_opacity_ratio(_get_attr(elem, 'stroke-opacity', ctx))
|
||||
font_style = _get_attr(elem, 'font-style', ctx) or ''
|
||||
text_decoration = _get_attr(elem, 'text-decoration', ctx) or ''
|
||||
letter_spacing_px = _parse_letter_spacing_px(
|
||||
@@ -1543,6 +1607,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
'text_decoration': text_decoration,
|
||||
'letter_spacing': letter_spacing_px,
|
||||
'_scale_x': ctx.scale_x or 1.0,
|
||||
'_object_opacity': object_opacity,
|
||||
'_fill_opacity': fill_opacity,
|
||||
'_stroke_opacity': stroke_opacity_value,
|
||||
'opacity': opacity,
|
||||
'stroke_raw': stroke_raw,
|
||||
'stroke_width': stroke_width,
|
||||
@@ -1724,9 +1791,15 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
filter_elem = ctx.defs[filt_id]
|
||||
effect_kind = classify_filter_effect(filter_elem)
|
||||
if effect_kind == 'glow':
|
||||
text_effect_xml = build_effect_xml(filter_elem)
|
||||
text_effect_xml = build_effect_xml(
|
||||
filter_elem,
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
elif effect_kind == 'shadow':
|
||||
shape_effect_xml = build_effect_xml(filter_elem)
|
||||
shape_effect_xml = build_effect_xml(
|
||||
filter_elem,
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
shape_id = ctx.next_id()
|
||||
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
||||
@@ -2369,6 +2442,18 @@ def _resolve_image_meet_fit(
|
||||
return (dx, dy, fit_w, fit_h)
|
||||
|
||||
|
||||
def _build_image_blip_xml(r_id: str, opacity: float | None) -> str:
|
||||
"""Build an image blip with native DrawingML transparency when requested."""
|
||||
if opacity is None:
|
||||
return f'<a:blip r:embed="{r_id}"/>'
|
||||
alpha = int(round(opacity * 100000))
|
||||
return (
|
||||
f'<a:blip r:embed="{r_id}">'
|
||||
f'<a:alphaModFix amt="{alpha}"/>'
|
||||
'</a:blip>'
|
||||
)
|
||||
|
||||
|
||||
def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert SVG <image> to DrawingML picture element.
|
||||
|
||||
@@ -2439,6 +2524,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
# Image optimization only downscales the full source image; it never crops
|
||||
# pixels out of the embedded media.
|
||||
src_rect_xml = _resolve_image_src_rect(elem, img_data, w, h)
|
||||
blip_xml = _build_image_blip_xml(r_id, get_element_opacity(elem, ctx))
|
||||
|
||||
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
|
||||
# frame to match the image's aspect ratio. Skipped when a real clip-path
|
||||
@@ -2495,7 +2581,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
<p:nvPr/>
|
||||
</p:nvPicPr>
|
||||
<p:blipFill>
|
||||
<a:blip r:embed="{r_id}"/>
|
||||
{blip_xml}
|
||||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
@@ -2514,8 +2600,14 @@ def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
||||
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
||||
raw_cx = svg_length_x(elem.get('cx'), ctx)
|
||||
raw_cy = svg_length_y(elem.get('cy'), ctx)
|
||||
raw_rx = svg_length_x(elem.get('rx'), ctx)
|
||||
raw_ry = svg_length_y(elem.get('ry'), ctx)
|
||||
rx_attr = elem.get('rx')
|
||||
ry_attr = elem.get('ry')
|
||||
raw_rx = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
|
||||
raw_ry = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
|
||||
if rx_attr is not None and ry_attr is None:
|
||||
raw_ry = raw_rx
|
||||
elif ry_attr is not None and rx_attr is None:
|
||||
raw_rx = raw_ry
|
||||
cx_ = ctx_x(raw_cx, ctx)
|
||||
cy_ = ctx_y(raw_cy, ctx)
|
||||
rx = raw_rx * ctx.scale_x
|
||||
@@ -2668,6 +2760,10 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
|
||||
transform,
|
||||
)
|
||||
clip_geom = _resolve_clip_geometry(elem, ctx, svg_x, svg_y, svg_w, svg_h)
|
||||
blip_xml = _build_image_blip_xml(
|
||||
r_id,
|
||||
get_element_opacity(image_elem, ctx),
|
||||
)
|
||||
|
||||
return ShapeResult(xml=f'''<p:pic>
|
||||
<p:nvPicPr>
|
||||
@@ -2676,7 +2772,7 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
|
||||
<p:nvPr/>
|
||||
</p:nvPicPr>
|
||||
<p:blipFill>
|
||||
<a:blip r:embed="{r_id}"/>
|
||||
{blip_xml}
|
||||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
|
||||
+184
-60
@@ -11,7 +11,8 @@ from .theme_colors import ThemeColorSpec, color_node_xml
|
||||
from .utils import (
|
||||
SVG_NS, ANGLE_UNIT, DASH_PRESETS,
|
||||
px_to_emu, _f, _get_attr, parse_svg_length,
|
||||
parse_hex_color, parse_stop_style, resolve_url_id,
|
||||
combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
|
||||
parse_svg_color, resolve_url_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,24 +59,21 @@ def build_gradient_fill(
|
||||
|
||||
# Parse color from style attribute or direct attributes
|
||||
style = child.get('style', '')
|
||||
style_values = parse_inline_style(style)
|
||||
color, stop_opacity = parse_stop_style(style)
|
||||
if not color:
|
||||
color = parse_hex_color(child.get('stop-color', '#000000'))
|
||||
color, color_alpha = parse_svg_color(child.get('stop-color', '#000000'))
|
||||
stop_opacity *= color_alpha
|
||||
if color is None:
|
||||
color = '000000'
|
||||
|
||||
direct_stop_op = child.get('stop-opacity')
|
||||
if direct_stop_op is not None:
|
||||
try:
|
||||
stop_opacity = float(direct_stop_op)
|
||||
except ValueError:
|
||||
pass
|
||||
if direct_stop_op is not None and 'stop-opacity' not in style_values:
|
||||
stop_opacity *= parse_opacity(direct_stop_op)
|
||||
|
||||
alpha_xml = ''
|
||||
effective_opacity = stop_opacity
|
||||
if opacity is not None:
|
||||
effective_opacity *= opacity
|
||||
if effective_opacity < 1.0:
|
||||
effective_opacity = combine_opacity(stop_opacity, opacity)
|
||||
if effective_opacity is not None:
|
||||
alpha_xml = f'<a:alpha val="{int(effective_opacity * 100000)}"/>'
|
||||
|
||||
stops_xml.append(
|
||||
@@ -157,9 +155,14 @@ def build_fill_xml(
|
||||
usage,
|
||||
)
|
||||
|
||||
color = parse_hex_color(fill)
|
||||
color, color_alpha = parse_svg_color(fill)
|
||||
if color:
|
||||
return build_solid_fill(color, opacity, ctx.theme_color_spec, usage)
|
||||
return build_solid_fill(
|
||||
color,
|
||||
combine_opacity(opacity, color_alpha),
|
||||
ctx.theme_color_spec,
|
||||
usage,
|
||||
)
|
||||
|
||||
return '<a:noFill/>'
|
||||
|
||||
@@ -178,32 +181,108 @@ def build_pattern_fill(
|
||||
"""
|
||||
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
|
||||
|
||||
paint_entries = []
|
||||
for child in pattern_elem:
|
||||
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
style_values = parse_inline_style(child.get('style'))
|
||||
object_opacity = parse_opacity(
|
||||
style_values.get('opacity') or child.get('opacity')
|
||||
)
|
||||
for paint_attr in ('fill', 'stroke'):
|
||||
paint = style_values.get(paint_attr) or child.get(paint_attr)
|
||||
paint_hex, paint_alpha = parse_svg_color(paint) if paint else (None, 1.0)
|
||||
if paint_hex is None:
|
||||
continue
|
||||
paint_opacity = parse_opacity(
|
||||
style_values.get(f'{paint_attr}-opacity')
|
||||
or child.get(f'{paint_attr}-opacity')
|
||||
)
|
||||
paint_entries.append({
|
||||
'attr': paint_attr,
|
||||
'alpha': paint_alpha,
|
||||
'color': paint,
|
||||
'hex': paint_hex,
|
||||
'opacity': object_opacity * paint_opacity,
|
||||
'tag': tag,
|
||||
})
|
||||
|
||||
fallback_bg = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['tag'] == 'rect' and entry['attr'] == 'fill'
|
||||
), None)
|
||||
fallback_fg = next((
|
||||
entry for entry in paint_entries if entry['attr'] == 'stroke'
|
||||
), None)
|
||||
if fallback_fg is None:
|
||||
fallback_fg = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'fill' and entry is not fallback_bg
|
||||
), None)
|
||||
|
||||
fg_color = pattern_elem.get('data-pptx-fg')
|
||||
bg_color = pattern_elem.get('data-pptx-bg')
|
||||
fg_from_metadata = bool(fg_color)
|
||||
bg_from_metadata = bool(bg_color)
|
||||
if not fg_color and fallback_fg is not None:
|
||||
fg_color = fallback_fg['color']
|
||||
if not bg_color and fallback_bg is not None:
|
||||
bg_color = fallback_bg['color']
|
||||
|
||||
if not fg_color or not bg_color:
|
||||
# Hand-authored fallback: derive from child elements.
|
||||
for child in pattern_elem:
|
||||
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
if tag == 'rect' and not bg_color:
|
||||
bg_color = child.get('fill')
|
||||
elif tag == 'path' and not fg_color:
|
||||
fg_color = child.get('stroke')
|
||||
|
||||
fg_hex = parse_hex_color(fg_color) if fg_color else None
|
||||
bg_hex = parse_hex_color(bg_color) if bg_color else None
|
||||
fg_hex, fg_alpha = parse_svg_color(fg_color) if fg_color else (None, 1.0)
|
||||
bg_hex, bg_alpha = parse_svg_color(bg_color) if bg_color else (None, 1.0)
|
||||
if not fg_hex:
|
||||
return ''
|
||||
|
||||
alpha_xml = ''
|
||||
if opacity is not None and opacity < 1.0:
|
||||
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
|
||||
bg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['tag'] == 'rect'
|
||||
and entry['attr'] == 'fill'
|
||||
and entry['hex'] == bg_hex
|
||||
), None)
|
||||
fg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'stroke' and entry['hex'] == fg_hex
|
||||
), None)
|
||||
if fg_source is None:
|
||||
fg_source = next((
|
||||
entry
|
||||
for entry in paint_entries
|
||||
if entry['attr'] == 'fill'
|
||||
and entry['hex'] == fg_hex
|
||||
and entry is not bg_source
|
||||
), None)
|
||||
|
||||
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, alpha_xml)
|
||||
fg_child_opacity = 1.0
|
||||
if fg_source is not None:
|
||||
fg_child_opacity = fg_source['opacity'] * (
|
||||
fg_source['alpha'] if fg_from_metadata else 1.0
|
||||
)
|
||||
bg_child_opacity = 1.0
|
||||
if bg_source is not None:
|
||||
bg_child_opacity = bg_source['opacity'] * (
|
||||
bg_source['alpha'] if bg_from_metadata else 1.0
|
||||
)
|
||||
|
||||
fg_opacity = combine_opacity(opacity, fg_alpha, fg_child_opacity)
|
||||
bg_opacity = combine_opacity(opacity, bg_alpha, bg_child_opacity)
|
||||
fg_alpha_xml = (
|
||||
f'<a:alpha val="{int(fg_opacity * 100000)}"/>'
|
||||
if fg_opacity is not None else ''
|
||||
)
|
||||
bg_alpha_xml = (
|
||||
f'<a:alpha val="{int(bg_opacity * 100000)}"/>'
|
||||
if bg_opacity is not None else ''
|
||||
)
|
||||
|
||||
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, fg_alpha_xml)
|
||||
if bg_hex:
|
||||
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage)
|
||||
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage, bg_alpha_xml)
|
||||
else:
|
||||
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage)
|
||||
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage, bg_alpha_xml)
|
||||
|
||||
return (
|
||||
f'<a:pattFill prst="{prst}">'
|
||||
@@ -432,10 +511,11 @@ def build_stroke_xml(
|
||||
return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
|
||||
|
||||
# Solid color stroke
|
||||
color = parse_hex_color(stroke)
|
||||
color, color_alpha = parse_svg_color(stroke)
|
||||
if not color:
|
||||
return '<a:ln><a:noFill/></a:ln>'
|
||||
|
||||
opacity = combine_opacity(opacity, color_alpha)
|
||||
alpha_xml = ''
|
||||
if opacity is not None and opacity < 1.0:
|
||||
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
|
||||
@@ -457,12 +537,19 @@ def _parse_filter_params(
|
||||
std_dev = 4.0
|
||||
dx = 0.0
|
||||
dy = 0.0
|
||||
opacity = 0.3
|
||||
paint_opacity: float | None = None
|
||||
transfer_opacity: float | None = None
|
||||
color_alpha = 1.0
|
||||
color = '000000'
|
||||
has_offset = False
|
||||
|
||||
for child in filter_elem.iter():
|
||||
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
style_values = parse_inline_style(child.get('style'))
|
||||
|
||||
def effect_attr(name: str, default: str | None = None) -> str | None:
|
||||
return style_values.get(name) or child.get(name, default)
|
||||
|
||||
if tag == 'feDropShadow':
|
||||
# Shorthand element: all params in one place
|
||||
std_dev = _f(child.get('stdDeviation'), 4.0)
|
||||
@@ -470,10 +557,13 @@ def _parse_filter_params(
|
||||
dy = _f(child.get('dy'), 0.0)
|
||||
if abs(dx) > 0.01 or abs(dy) > 0.01:
|
||||
has_offset = True
|
||||
opacity = _f(child.get('flood-opacity'), 0.3)
|
||||
raw_color = child.get('flood-color', '').strip().lstrip('#')
|
||||
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
|
||||
color = raw_color.upper()
|
||||
paint_opacity = parse_opacity(effect_attr('flood-opacity'), 0.3)
|
||||
parsed_color, parsed_alpha = parse_svg_color(
|
||||
effect_attr('flood-color', '#000000')
|
||||
)
|
||||
if parsed_color:
|
||||
color = parsed_color
|
||||
color_alpha = parsed_alpha
|
||||
elif tag == 'feGaussianBlur':
|
||||
std_dev = _f(child.get('stdDeviation'), 4.0)
|
||||
elif tag == 'feOffset':
|
||||
@@ -482,13 +572,29 @@ def _parse_filter_params(
|
||||
if abs(dx) > 0.01 or abs(dy) > 0.01:
|
||||
has_offset = True
|
||||
elif tag == 'feFlood':
|
||||
opacity = _f(child.get('flood-opacity'), 0.3)
|
||||
raw_color = child.get('flood-color', '').strip().lstrip('#')
|
||||
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
|
||||
color = raw_color.upper()
|
||||
paint_opacity = parse_opacity(effect_attr('flood-opacity'), 0.3)
|
||||
parsed_color, parsed_alpha = parse_svg_color(
|
||||
effect_attr('flood-color', '#000000')
|
||||
)
|
||||
if parsed_color:
|
||||
color = parsed_color
|
||||
color_alpha = parsed_alpha
|
||||
elif tag == 'feFuncA':
|
||||
if child.get('type') == 'linear':
|
||||
opacity = _f(child.get('slope'), 0.3)
|
||||
slope = max(0.0, _f(child.get('slope'), 0.3))
|
||||
transfer_opacity = (
|
||||
slope
|
||||
if transfer_opacity is None
|
||||
else transfer_opacity * slope
|
||||
)
|
||||
|
||||
if paint_opacity is None:
|
||||
opacity = transfer_opacity if transfer_opacity is not None else 0.3
|
||||
elif transfer_opacity is None:
|
||||
opacity = paint_opacity
|
||||
else:
|
||||
opacity = paint_opacity * transfer_opacity
|
||||
opacity = max(0.0, min(1.0, opacity * color_alpha))
|
||||
|
||||
return {
|
||||
'std_dev': std_dev, 'dx': dx, 'dy': dy,
|
||||
@@ -534,7 +640,10 @@ def _shadow_dir_angle(dx: float, dy: float) -> int:
|
||||
return int(angle_deg * ANGLE_UNIT)
|
||||
|
||||
|
||||
def build_shadow_xml(filter_elem: ET.Element) -> str:
|
||||
def build_shadow_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build <a:effectLst> with <a:outerShdw> from SVG filter element.
|
||||
|
||||
SVG-to-DrawingML shadow mapping notes:
|
||||
@@ -564,7 +673,8 @@ def build_shadow_xml(filter_elem: ET.Element) -> str:
|
||||
# PowerPoint renders outerShdw alpha slightly heavier than SVG's filter
|
||||
# composite (different blending path). Scale by 0.75 to match the SVG
|
||||
# preview after blur has been corrected to 2.0× σ.
|
||||
alpha_val = int(p['opacity'] * 75000)
|
||||
opacity_multiplier = 1.0 if opacity is None else opacity
|
||||
alpha_val = int(p['opacity'] * opacity_multiplier * 75000)
|
||||
algn = _infer_shadow_alignment(dx, dy)
|
||||
|
||||
return f'''<a:effectLst>
|
||||
@@ -574,7 +684,10 @@ def build_shadow_xml(filter_elem: ET.Element) -> str:
|
||||
</a:effectLst>'''
|
||||
|
||||
|
||||
def build_glow_xml(filter_elem: ET.Element) -> str:
|
||||
def build_glow_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build <a:effectLst> with <a:glow> from SVG filter element.
|
||||
|
||||
Used for filters that have feGaussianBlur without meaningful feOffset,
|
||||
@@ -585,7 +698,8 @@ def build_glow_xml(filter_elem: ET.Element) -> str:
|
||||
|
||||
p = _parse_filter_params(filter_elem)
|
||||
rad = px_to_emu(p['std_dev'])
|
||||
alpha_val = int(p['opacity'] * 100000)
|
||||
opacity_multiplier = 1.0 if opacity is None else opacity
|
||||
alpha_val = int(p['opacity'] * opacity_multiplier * 100000)
|
||||
|
||||
return f'''<a:effectLst>
|
||||
<a:glow rad="{rad}">
|
||||
@@ -603,7 +717,10 @@ def classify_filter_effect(filter_elem: ET.Element) -> str | None:
|
||||
return 'shadow' if p['has_offset'] else 'glow'
|
||||
|
||||
|
||||
def build_effect_xml(filter_elem: ET.Element) -> str:
|
||||
def build_effect_xml(
|
||||
filter_elem: ET.Element,
|
||||
opacity: float | None = None,
|
||||
) -> str:
|
||||
"""Build effect XML by classifying the SVG filter as shadow or glow.
|
||||
|
||||
Classification rules:
|
||||
@@ -615,22 +732,29 @@ def build_effect_xml(filter_elem: ET.Element) -> str:
|
||||
|
||||
effect_kind = classify_filter_effect(filter_elem)
|
||||
if effect_kind == 'shadow':
|
||||
return build_shadow_xml(filter_elem)
|
||||
return build_shadow_xml(filter_elem, opacity)
|
||||
if effect_kind == 'glow':
|
||||
return build_glow_xml(filter_elem)
|
||||
return build_glow_xml(filter_elem, opacity)
|
||||
return ''
|
||||
|
||||
|
||||
def get_element_opacity(elem: ET.Element) -> float | None:
|
||||
"""Get opacity value from element. Returns None if 1.0 or not set."""
|
||||
op = elem.get('opacity')
|
||||
def get_element_opacity(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> float | None:
|
||||
"""Get local opacity multiplied by any approximated ancestor group alpha."""
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
if ctx is not None:
|
||||
op = _get_attr(elem, 'opacity', ctx)
|
||||
else:
|
||||
op = parse_inline_style(elem.get('style')).get('opacity') or elem.get('opacity')
|
||||
if op is None:
|
||||
return None
|
||||
return base if base < 1.0 else None
|
||||
try:
|
||||
val = float(op)
|
||||
val = base * max(0.0, min(1.0, float(op)))
|
||||
return val if val < 1.0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
return base if base < 1.0 else None
|
||||
|
||||
|
||||
def get_fill_opacity(
|
||||
@@ -642,19 +766,19 @@ def get_fill_opacity(
|
||||
Returns:
|
||||
Combined opacity value, or None if fully opaque.
|
||||
"""
|
||||
base = 1.0
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
|
||||
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
|
||||
if op:
|
||||
try:
|
||||
base = float(op)
|
||||
base *= max(0.0, min(1.0, float(op)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fill_op = _get_attr(elem, 'fill-opacity', ctx) if ctx else elem.get('fill-opacity')
|
||||
if fill_op:
|
||||
try:
|
||||
base *= float(fill_op)
|
||||
base *= max(0.0, min(1.0, float(fill_op)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -670,19 +794,19 @@ def get_stroke_opacity(
|
||||
Returns:
|
||||
Combined opacity value, or None if fully opaque.
|
||||
"""
|
||||
base = 1.0
|
||||
base = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
|
||||
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
|
||||
if op:
|
||||
try:
|
||||
base = float(op)
|
||||
base *= max(0.0, min(1.0, float(op)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
stroke_op = _get_attr(elem, 'stroke-opacity', ctx) if ctx else elem.get('stroke-opacity')
|
||||
if stroke_op:
|
||||
try:
|
||||
base *= float(stroke_op)
|
||||
base *= max(0.0, min(1.0, float(stroke_op)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
+134
-2
@@ -1,18 +1,22 @@
|
||||
"""Theme-font contracts shared by SVG conversion and PPTX package assembly."""
|
||||
"""Theme typography contracts shared by SVG conversion and PPTX assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .utils import parse_font_family
|
||||
from .utils import FONT_PX_TO_HUNDREDTHS_PT, font_px_to_hpt, parse_font_family
|
||||
|
||||
|
||||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
|
||||
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
|
||||
_TEXT_FONT_SIZE_MIN = 100
|
||||
_TEXT_FONT_SIZE_MAX = 400_000
|
||||
|
||||
|
||||
class ThemeFontError(RuntimeError):
|
||||
@@ -42,6 +46,14 @@ class ThemeFontSpec:
|
||||
minor_family: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MasterTextStyleSpec:
|
||||
"""Title/body defaults written to a template slide master's txStyles."""
|
||||
|
||||
title_hpt: int
|
||||
body_hpt: int
|
||||
|
||||
|
||||
def _font_face(font_family: str) -> ThemeFontFace:
|
||||
fonts = parse_font_family(font_family)
|
||||
return ThemeFontFace(
|
||||
@@ -91,6 +103,47 @@ def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
|
||||
)
|
||||
|
||||
|
||||
def _font_size_hpt(raw: str, field: str) -> int:
|
||||
try:
|
||||
px = float(raw)
|
||||
except (TypeError, ValueError, OverflowError) as exc:
|
||||
raise ThemeFontError(
|
||||
f"spec_lock.md typography {field} must be a numeric px value: {raw!r}"
|
||||
) from exc
|
||||
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
|
||||
if not math.isfinite(scaled):
|
||||
raise ThemeFontError(
|
||||
f"spec_lock.md typography {field} must be finite: {raw!r}"
|
||||
)
|
||||
size = font_px_to_hpt(px)
|
||||
if not _TEXT_FONT_SIZE_MIN <= size <= _TEXT_FONT_SIZE_MAX:
|
||||
raise ThemeFontError(
|
||||
f"spec_lock.md typography {field} is outside the PowerPoint "
|
||||
f"font-size range: {raw!r}"
|
||||
)
|
||||
return size
|
||||
|
||||
|
||||
def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec:
|
||||
"""Load required title/body defaults for template slide-master txStyles."""
|
||||
lock_path = project_path / "spec_lock.md"
|
||||
if not lock_path.is_file():
|
||||
raise ThemeFontError(
|
||||
"template mode requires spec_lock.md typography title and body rows"
|
||||
)
|
||||
rows = _typography_rows(lock_path)
|
||||
missing = [field for field in ("title", "body") if field not in rows]
|
||||
if missing:
|
||||
raise ThemeFontError(
|
||||
"template mode requires spec_lock.md typography rows: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return MasterTextStyleSpec(
|
||||
title_hpt=_font_size_hpt(rows["title"], "title"),
|
||||
body_hpt=_font_size_hpt(rows["body"], "body"),
|
||||
)
|
||||
|
||||
|
||||
def theme_font_tokens(
|
||||
fonts: dict[str, str],
|
||||
spec: ThemeFontSpec | None,
|
||||
@@ -151,3 +204,82 @@ def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None:
|
||||
_patch_font_collection(major, spec.major)
|
||||
_patch_font_collection(minor, spec.minor)
|
||||
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]:
|
||||
run_properties = list(style.iter(f"{{{DML_NS}}}defRPr"))
|
||||
if not run_properties:
|
||||
raise ThemeFontError(f"slide master {label} has no a:defRPr entries")
|
||||
return run_properties
|
||||
|
||||
|
||||
def apply_master_text_style_spec(
|
||||
extract_dir: Path,
|
||||
spec: MasterTextStyleSpec,
|
||||
) -> int:
|
||||
"""Install locked title/body sizes into template slide-master txStyles."""
|
||||
master_dir = extract_dir / "ppt" / "slideMasters"
|
||||
master_paths = sorted(master_dir.glob("slideMaster*.xml"))
|
||||
if not master_paths:
|
||||
raise ThemeFontError(f"PPTX package has no slide master under {master_dir}")
|
||||
|
||||
ET.register_namespace("a", DML_NS)
|
||||
ET.register_namespace("p", PML_NS)
|
||||
for master_path in master_paths:
|
||||
try:
|
||||
tree = ET.parse(master_path)
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc
|
||||
text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles")
|
||||
if text_styles is None:
|
||||
raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}")
|
||||
|
||||
style_sizes = (
|
||||
("titleStyle", spec.title_hpt),
|
||||
("bodyStyle", spec.body_hpt),
|
||||
("otherStyle", spec.body_hpt),
|
||||
)
|
||||
for style_name, size in style_sizes:
|
||||
style = text_styles.find(f"{{{PML_NS}}}{style_name}")
|
||||
if style is None:
|
||||
raise ThemeFontError(
|
||||
f"Slide master has no p:{style_name}: {master_path}"
|
||||
)
|
||||
for run_properties in _style_run_properties(
|
||||
style,
|
||||
f"p:{style_name}",
|
||||
):
|
||||
run_properties.set("sz", str(size))
|
||||
|
||||
tree.write(master_path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
try:
|
||||
read_back = ET.parse(master_path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise ThemeFontError(
|
||||
f"Cannot read back slide master {master_path}: {exc}"
|
||||
) from exc
|
||||
read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles")
|
||||
if read_back_styles is None:
|
||||
raise ThemeFontError(
|
||||
f"Slide master lost p:txStyles after update: {master_path}"
|
||||
)
|
||||
for style_name, expected_size in style_sizes:
|
||||
style = read_back_styles.find(f"{{{PML_NS}}}{style_name}")
|
||||
actual_sizes = (
|
||||
{
|
||||
item.get("sz")
|
||||
for item in _style_run_properties(
|
||||
style,
|
||||
f"p:{style_name}",
|
||||
)
|
||||
}
|
||||
if style is not None
|
||||
else set()
|
||||
)
|
||||
if actual_sizes != {str(expected_size)}:
|
||||
raise ThemeFontError(
|
||||
f"Slide master p:{style_name} size read-back failed: "
|
||||
f"{master_path}"
|
||||
)
|
||||
return len(master_paths)
|
||||
|
||||
+136
-28
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import colorsys
|
||||
import math
|
||||
import re
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
|
||||
@@ -22,7 +23,7 @@ ANGLE_UNIT = 60000 # DrawingML angle: 60000ths of a degree
|
||||
# SVG attributes inheritable from parent <g>
|
||||
INHERITABLE_ATTRS = [
|
||||
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
|
||||
'stroke-linejoin', 'opacity', 'fill-opacity', 'stroke-opacity',
|
||||
'stroke-linejoin', 'fill-opacity', 'stroke-opacity',
|
||||
'font-family', 'font-size', 'font-weight', 'font-style',
|
||||
'text-anchor', 'letter-spacing', 'text-decoration',
|
||||
]
|
||||
@@ -145,6 +146,11 @@ def px_to_emu(px: float) -> int:
|
||||
return round(px * EMU_PER_PX)
|
||||
|
||||
|
||||
def font_px_to_hpt(font_size_px: float) -> int:
|
||||
"""Convert SVG px to DrawingML hundredths-of-a-point at 0.1pt precision."""
|
||||
return int(round(font_size_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
|
||||
|
||||
|
||||
def _f(val: str | None, default: float = 0.0) -> float:
|
||||
"""Parse a float attribute value, returning default if missing."""
|
||||
if val is None:
|
||||
@@ -447,41 +453,146 @@ def parse_inline_style(style_str: str | None) -> dict[str, str]:
|
||||
return styles
|
||||
|
||||
|
||||
def _finite_float(raw: str) -> float:
|
||||
"""Parse a finite floating-point number."""
|
||||
value = float(raw)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f'Non-finite numeric value: {raw}')
|
||||
return value
|
||||
|
||||
|
||||
def _parse_color_channel(raw: str) -> int:
|
||||
raw = raw.strip()
|
||||
if raw.endswith('%'):
|
||||
value = float(raw[:-1]) * 255.0 / 100.0
|
||||
value = _finite_float(raw[:-1]) * 255.0 / 100.0
|
||||
else:
|
||||
value = float(raw)
|
||||
value = _finite_float(raw)
|
||||
return max(0, min(255, int(round(value))))
|
||||
|
||||
|
||||
def parse_hex_color(color_str: str) -> str | None:
|
||||
"""Parse SVG color values to 'RRGGBB'. Returns None on failure."""
|
||||
def _parse_alpha_channel(raw: str) -> float:
|
||||
"""Parse a CSS alpha channel as a clamped ``0..1`` ratio."""
|
||||
raw = raw.strip()
|
||||
value = (
|
||||
_finite_float(raw[:-1]) / 100.0
|
||||
if raw.endswith('%')
|
||||
else _finite_float(raw)
|
||||
)
|
||||
return max(0.0, min(1.0, value))
|
||||
|
||||
|
||||
def parse_opacity(raw: str | None, default: float = 1.0) -> float:
|
||||
"""Parse a number or percentage opacity, falling back to ``default``."""
|
||||
if raw is None:
|
||||
return max(0.0, min(1.0, default))
|
||||
try:
|
||||
return _parse_alpha_channel(raw)
|
||||
except ValueError:
|
||||
return max(0.0, min(1.0, default))
|
||||
|
||||
|
||||
def _functional_color_parts(body: str) -> tuple[list[str], str | None]:
|
||||
"""Split legacy comma or modern space/slash functional color syntax."""
|
||||
before, separator, after = body.partition('/')
|
||||
parts = [part for part in re.split(r'[\s,]+', before.strip()) if part]
|
||||
alpha = after.strip() if separator else None
|
||||
if alpha is None and len(parts) > 3:
|
||||
alpha = parts.pop()
|
||||
return parts, alpha
|
||||
|
||||
|
||||
def _parse_hue_degrees(raw: str) -> float:
|
||||
"""Normalize a CSS hue angle to degrees."""
|
||||
value = raw.strip().lower()
|
||||
for suffix, multiplier in (
|
||||
('turn', 360.0),
|
||||
('grad', 0.9),
|
||||
('rad', 180.0 / math.pi),
|
||||
('deg', 1.0),
|
||||
):
|
||||
if value.endswith(suffix):
|
||||
return _finite_float(value[:-len(suffix)]) * multiplier
|
||||
return _finite_float(value)
|
||||
|
||||
|
||||
def _parse_percentage(raw: str) -> float:
|
||||
"""Parse a CSS percentage channel as a clamped ``0..1`` ratio."""
|
||||
value = raw.strip()
|
||||
ratio = (
|
||||
_finite_float(value[:-1]) / 100.0
|
||||
if value.endswith('%')
|
||||
else _finite_float(value) / 100.0
|
||||
)
|
||||
return max(0.0, min(1.0, ratio))
|
||||
|
||||
|
||||
def parse_svg_color(color_str: str) -> tuple[str | None, float]:
|
||||
"""Parse an SVG/CSS color into ``(RRGGBB, alpha)``."""
|
||||
if not color_str:
|
||||
return None
|
||||
return None, 1.0
|
||||
color_str = color_str.strip()
|
||||
named = _CSS_NAMED_COLORS.get(color_str.lower())
|
||||
if named is not None or color_str.lower() in _CSS_NAMED_COLORS:
|
||||
return named
|
||||
if color_str.lower() == 'transparent':
|
||||
return '000000', 0.0
|
||||
return named, 1.0
|
||||
|
||||
rgb_match = re.match(r'rgba?\((.+)\)$', color_str, flags=re.IGNORECASE)
|
||||
if rgb_match:
|
||||
channels = re.findall(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?%?', rgb_match.group(1))
|
||||
if len(channels) >= 3:
|
||||
channels, alpha_raw = _functional_color_parts(rgb_match.group(1))
|
||||
if len(channels) == 3:
|
||||
try:
|
||||
r, g, b = (_parse_color_channel(ch) for ch in channels[:3])
|
||||
return f'{r:02X}{g:02X}{b:02X}'
|
||||
r, g, b = (_parse_color_channel(ch) for ch in channels)
|
||||
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
|
||||
return f'{r:02X}{g:02X}{b:02X}', alpha
|
||||
except ValueError:
|
||||
return None
|
||||
return None, 1.0
|
||||
|
||||
hsl_match = re.match(r'hsla?\((.+)\)$', color_str, flags=re.IGNORECASE)
|
||||
if hsl_match:
|
||||
channels, alpha_raw = _functional_color_parts(hsl_match.group(1))
|
||||
if len(channels) == 3:
|
||||
try:
|
||||
hue = (_parse_hue_degrees(channels[0]) % 360.0) / 360.0
|
||||
saturation = _parse_percentage(channels[1])
|
||||
lightness = _parse_percentage(channels[2])
|
||||
red, green, blue = colorsys.hls_to_rgb(hue, lightness, saturation)
|
||||
alpha = _parse_alpha_channel(alpha_raw) if alpha_raw is not None else 1.0
|
||||
return (
|
||||
f'{round(red * 255):02X}{round(green * 255):02X}{round(blue * 255):02X}',
|
||||
alpha,
|
||||
)
|
||||
except ValueError:
|
||||
return None, 1.0
|
||||
|
||||
if color_str.startswith('#'):
|
||||
color_str = color_str[1:]
|
||||
if len(color_str) == 3:
|
||||
color_str = ''.join(c * 2 for c in color_str)
|
||||
elif len(color_str) == 4:
|
||||
color_str = ''.join(c * 2 for c in color_str)
|
||||
if len(color_str) == 8 and all(c in '0123456789abcdefABCDEF' for c in color_str):
|
||||
return color_str[:6].upper(), int(color_str[6:], 16) / 255.0
|
||||
if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
|
||||
return color_str.upper()
|
||||
return None
|
||||
return color_str.upper(), 1.0
|
||||
return None, 1.0
|
||||
|
||||
|
||||
def parse_hex_color(color_str: str) -> str | None:
|
||||
"""Parse SVG color values to ``RRGGBB``, ignoring any alpha channel."""
|
||||
if color_str and color_str.strip().lower() == 'transparent':
|
||||
return None
|
||||
color, _alpha = parse_svg_color(color_str)
|
||||
return color
|
||||
|
||||
|
||||
def combine_opacity(*values: float | None) -> float | None:
|
||||
"""Multiply opacity components, returning ``None`` when fully opaque."""
|
||||
combined = 1.0
|
||||
for value in values:
|
||||
if value is not None:
|
||||
combined *= max(0.0, min(1.0, value))
|
||||
return combined if combined < 1.0 else None
|
||||
|
||||
|
||||
def parse_stop_style(style_str: str) -> tuple[str | None, float]:
|
||||
@@ -494,21 +605,18 @@ def parse_stop_style(style_str: str) -> tuple[str | None, float]:
|
||||
(color, opacity) tuple.
|
||||
"""
|
||||
color = None
|
||||
opacity = 1.0
|
||||
if not style_str:
|
||||
return color, opacity
|
||||
color_alpha = 1.0
|
||||
stop_opacity = 1.0
|
||||
style_values = parse_inline_style(style_str)
|
||||
if not style_values:
|
||||
return color, stop_opacity
|
||||
|
||||
for part in style_str.split(';'):
|
||||
part = part.strip()
|
||||
if part.startswith('stop-color:'):
|
||||
color = parse_hex_color(part.split(':', 1)[1].strip())
|
||||
elif part.startswith('stop-opacity:'):
|
||||
try:
|
||||
opacity = float(part.split(':', 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
if 'stop-color' in style_values:
|
||||
color, color_alpha = parse_svg_color(style_values['stop-color'])
|
||||
if 'stop-opacity' in style_values:
|
||||
stop_opacity = parse_opacity(style_values['stop-opacity'])
|
||||
|
||||
return color, opacity
|
||||
return color, color_alpha * stop_opacity
|
||||
|
||||
|
||||
def resolve_url_id(url_str: str) -> str | None:
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"""Materialize the supported inline SVG geometry-property subset.
|
||||
|
||||
SVG 2 lets selected geometry values participate in CSS. PPT Master does not
|
||||
run a CSS engine, but it can safely compile literal per-element ``style``
|
||||
declarations into the equivalent XML geometry attributes before any existing
|
||||
SVG post-processing or DrawingML conversion runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
INLINE_GEOMETRY_PROPERTIES = {
|
||||
'rect': frozenset({'x', 'y', 'width', 'height', 'rx', 'ry'}),
|
||||
'circle': frozenset({'cx', 'cy', 'r'}),
|
||||
'ellipse': frozenset({'cx', 'cy', 'rx', 'ry'}),
|
||||
'image': frozenset({'x', 'y', 'width', 'height'}),
|
||||
'svg': frozenset({'x', 'y', 'width', 'height'}),
|
||||
'use': frozenset({'x', 'y', 'width', 'height'}),
|
||||
}
|
||||
|
||||
_GEOMETRY_LIKE_PROPERTIES = frozenset({
|
||||
'x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r',
|
||||
'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points', 'd',
|
||||
})
|
||||
_NON_NEGATIVE_PROPERTIES = frozenset({'width', 'height', 'rx', 'ry', 'r'})
|
||||
_PX_LENGTH_RE = re.compile(
|
||||
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(px)?\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class GeometryStyleError(ValueError):
|
||||
"""Reject inline geometry that cannot be compiled deterministically."""
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
tag = str(elem.tag)
|
||||
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
||||
|
||||
|
||||
def _format_number(value: float) -> str:
|
||||
if abs(value) < 1e-12:
|
||||
value = 0.0
|
||||
return f'{value:.12g}'
|
||||
|
||||
|
||||
def _normalize_px_length(raw: str, tag: str, prop: str) -> str:
|
||||
"""Return one finite CSS px literal as a unitless SVG user value."""
|
||||
match = _PX_LENGTH_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} requires a finite px literal; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
value = float(match.group(1))
|
||||
if not math.isfinite(value):
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} must be finite; got {raw!r}'
|
||||
)
|
||||
if match.group(2) is None and value != 0:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} requires px for non-zero values; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
if prop in _NON_NEGATIVE_PROPERTIES and value < 0:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> inline geometry {prop} cannot be negative; got {raw!r}'
|
||||
)
|
||||
return _format_number(value)
|
||||
|
||||
|
||||
def materialize_inline_geometry_properties(root: ET.Element) -> int:
|
||||
"""Compile supported inline geometry declarations into XML attributes."""
|
||||
materialized = 0
|
||||
for elem in root.iter():
|
||||
style = elem.get('style')
|
||||
if not style:
|
||||
continue
|
||||
tag = _local_tag(elem)
|
||||
supported = INLINE_GEOMETRY_PROPERTIES.get(tag, frozenset())
|
||||
retained: list[str] = []
|
||||
element_materialized = 0
|
||||
for raw_declaration in style.split(';'):
|
||||
declaration = raw_declaration.strip()
|
||||
if not declaration:
|
||||
continue
|
||||
if ':' not in declaration:
|
||||
retained.append(declaration)
|
||||
continue
|
||||
raw_name, raw_value = declaration.split(':', 1)
|
||||
name = raw_name.strip().lower()
|
||||
value = raw_value.strip()
|
||||
if name not in _GEOMETRY_LIKE_PROPERTIES:
|
||||
retained.append(declaration)
|
||||
continue
|
||||
if name not in supported:
|
||||
raise GeometryStyleError(
|
||||
f'<{tag}> does not support inline geometry property {name!r}; '
|
||||
'use the element\'s XML geometry attribute instead'
|
||||
)
|
||||
elem.set(name, _normalize_px_length(value, tag, name))
|
||||
materialized += 1
|
||||
element_materialized += 1
|
||||
|
||||
if element_materialized == 0:
|
||||
continue
|
||||
if retained:
|
||||
elem.set('style', '; '.join(retained))
|
||||
else:
|
||||
elem.attrib.pop('style', None)
|
||||
return materialized
|
||||
|
||||
|
||||
def validate_inline_geometry_properties(root: ET.Element) -> list[str]:
|
||||
"""Return inline geometry errors without mutating the caller's SVG tree."""
|
||||
try:
|
||||
materialize_inline_geometry_properties(copy.deepcopy(root))
|
||||
except GeometryStyleError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
|
||||
|
||||
def materialize_inline_geometry_in_file(svg_path: Path) -> int:
|
||||
"""Materialize inline geometry in one SVG file in place."""
|
||||
tree = ET.parse(str(svg_path))
|
||||
count = materialize_inline_geometry_properties(tree.getroot())
|
||||
if count:
|
||||
ET.register_namespace('', SVG_NS)
|
||||
ET.register_namespace('xlink', XLINK_NS)
|
||||
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
|
||||
return count
|
||||
+2
-1
@@ -17,6 +17,7 @@ from ..drawingml.utils import (
|
||||
ctx_w,
|
||||
ctx_x,
|
||||
ctx_y,
|
||||
font_px_to_hpt,
|
||||
matrix_multiply,
|
||||
parse_transform_matrix,
|
||||
transform_point,
|
||||
@@ -822,7 +823,7 @@ def _font_size_hpt(value: Any, default_px: int = 18) -> int:
|
||||
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
|
||||
if not math.isfinite(scaled):
|
||||
return None
|
||||
size = int(round(scaled / 10.0)) * 10
|
||||
size = font_px_to_hpt(px)
|
||||
if not _TEXT_FONT_SIZE_MIN <= size <= _TEXT_FONT_SIZE_MAX:
|
||||
return None
|
||||
return size
|
||||
|
||||
+418
-156
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import posixpath
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
@@ -15,7 +17,7 @@ import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,6 +28,25 @@ from xml.sax.saxutils import escape
|
||||
from pptx import Presentation
|
||||
from pptx.util import Emu
|
||||
|
||||
from pptx_transitions import (
|
||||
TRANSITIONS,
|
||||
create_transition_xml,
|
||||
normalize_transition_effect,
|
||||
set_directory_use_timings,
|
||||
validate_generated_transition_xml,
|
||||
validate_pptx_transition_package,
|
||||
validate_seconds,
|
||||
)
|
||||
from pptx_animations import (
|
||||
animation_seconds_to_milliseconds,
|
||||
create_sequence_timing_xml,
|
||||
normalize_animation_effect,
|
||||
normalize_animation_trigger,
|
||||
pick_animation_effect,
|
||||
validate_generated_animation_xml,
|
||||
validate_pptx_animation_package,
|
||||
)
|
||||
|
||||
from ..drawingml.converter import convert_svg_to_slide_shapes
|
||||
from ..drawingml.theme_colors import (
|
||||
ThemeColorSpec,
|
||||
@@ -33,7 +54,9 @@ from ..drawingml.theme_colors import (
|
||||
rewrite_chart_accent_colors,
|
||||
)
|
||||
from ..drawingml.theme_fonts import (
|
||||
MasterTextStyleSpec,
|
||||
ThemeFontSpec,
|
||||
apply_master_text_style_spec,
|
||||
apply_theme_font_spec,
|
||||
)
|
||||
from ..drawingml.utils import EMU_PER_PX
|
||||
@@ -69,31 +92,21 @@ from .narration import (
|
||||
probe_audio_duration,
|
||||
)
|
||||
from .slide_xml import (
|
||||
ANIMATIONS_AVAILABLE, TRANSITIONS,
|
||||
create_slide_xml_with_svg, create_slide_rels_xml,
|
||||
)
|
||||
from .template_structure import (
|
||||
NativeStructureContract,
|
||||
OOXML_UINT32_MAX,
|
||||
TEMPLATE_PLACEHOLDER_TYPES,
|
||||
TemplateElementSpec,
|
||||
TemplateSlideSpec,
|
||||
TemplateStructureError,
|
||||
match_native_placeholders,
|
||||
parse_preserve_slides,
|
||||
parse_template_slides,
|
||||
template_placeholder_bindings,
|
||||
)
|
||||
|
||||
# Re-import create_transition_xml only if available
|
||||
try:
|
||||
from pptx_animations import (
|
||||
create_transition_xml,
|
||||
create_sequence_timing_xml,
|
||||
pick_animation_effect,
|
||||
)
|
||||
except ImportError:
|
||||
create_transition_xml = None
|
||||
create_sequence_timing_xml = None
|
||||
pick_animation_effect = None
|
||||
|
||||
from .template_validation import validate_pptx_template_package
|
||||
|
||||
SLIDE_LAYOUT_REL_TYPE = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
|
||||
@@ -658,7 +671,13 @@ def _next_slide_layout_id(extract_dir: Path, master_xml: str) -> int:
|
||||
r'\bid="(\d{9,})"', presentation_path.read_text(encoding="utf-8")
|
||||
)
|
||||
)
|
||||
return max([*ids, 2147483648]) + 1
|
||||
next_id = max([*ids, 2147483648]) + 1
|
||||
if next_id > OOXML_UINT32_MAX:
|
||||
raise TemplateStructureError(
|
||||
"Cannot register another Slide Layout because the OOXML UInt32 "
|
||||
"identifier range is exhausted"
|
||||
)
|
||||
return next_id
|
||||
|
||||
|
||||
def _create_cover_layout(extract_dir: Path, master_part: str, base_layout_part: str) -> str:
|
||||
@@ -1150,19 +1169,6 @@ def _promote_common_chrome_shapes_to_layouts(
|
||||
return promoted
|
||||
|
||||
|
||||
_TEMPLATE_PLACEHOLDER_TYPES = {
|
||||
"title": "title",
|
||||
"subtitle": "subTitle",
|
||||
"body": "body",
|
||||
"picture": "pic",
|
||||
"chart": "chart",
|
||||
"table": "tbl",
|
||||
"object": "obj",
|
||||
"media": "media",
|
||||
"date": "dt",
|
||||
"footer": "ftr",
|
||||
"slide-number": "sldNum",
|
||||
}
|
||||
_TEMPLATE_PLACEHOLDER_PROMPTS = {
|
||||
"title": "Click to add title",
|
||||
"subtitle": "Click to add subtitle",
|
||||
@@ -1611,6 +1617,46 @@ def _replace_shape_xfrm(
|
||||
sp_pr.insert(0, xfrm)
|
||||
|
||||
|
||||
def _set_layout_level_one_default_size(
|
||||
list_style: ET.Element,
|
||||
source_run_pr: ET.Element | None,
|
||||
) -> None:
|
||||
"""Persist the prototype run size as the Layout's level-one text default."""
|
||||
if source_run_pr is None or source_run_pr.get("sz") is None:
|
||||
return
|
||||
level_tag = f"{{{DML_NS}}}lvl1pPr"
|
||||
level_props = list_style.find(level_tag)
|
||||
if level_props is None:
|
||||
level_props = ET.Element(level_tag)
|
||||
trailing_tags = {
|
||||
f"{{{DML_NS}}}lvl{level}pPr" for level in range(2, 10)
|
||||
}
|
||||
trailing_tags.add(f"{{{DML_NS}}}extLst")
|
||||
insert_at = next(
|
||||
(
|
||||
index
|
||||
for index, child in enumerate(list_style)
|
||||
if child.tag in trailing_tags
|
||||
),
|
||||
len(list_style),
|
||||
)
|
||||
list_style.insert(insert_at, level_props)
|
||||
default_props = level_props.find(f"{{{DML_NS}}}defRPr")
|
||||
if default_props is None:
|
||||
default_props = ET.Element(f"{{{DML_NS}}}defRPr")
|
||||
ext_tag = f"{{{DML_NS}}}extLst"
|
||||
insert_at = next(
|
||||
(
|
||||
index
|
||||
for index, child in enumerate(level_props)
|
||||
if child.tag == ext_tag
|
||||
),
|
||||
len(level_props),
|
||||
)
|
||||
level_props.insert(insert_at, default_props)
|
||||
default_props.set("sz", source_run_pr.get("sz", ""))
|
||||
|
||||
|
||||
def _placeholder_text_body(
|
||||
source_shape: ET.Element,
|
||||
item: TemplateElementSpec,
|
||||
@@ -1627,26 +1673,28 @@ def _placeholder_text_body(
|
||||
if source_tx_body is not None
|
||||
else None
|
||||
)
|
||||
tx_body.append(
|
||||
ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
|
||||
if source_body_pr is not None
|
||||
else ET.Element(f"{{{DML_NS}}}bodyPr")
|
||||
)
|
||||
tx_body.append(
|
||||
ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
|
||||
if source_lst_style is not None
|
||||
else ET.Element(f"{{{DML_NS}}}lstStyle")
|
||||
)
|
||||
|
||||
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
|
||||
if item.placeholder == "body":
|
||||
paragraph_props = ET.SubElement(paragraph, f"{{{DML_NS}}}pPr")
|
||||
ET.SubElement(paragraph_props, f"{{{DML_NS}}}buNone")
|
||||
source_run_pr = (
|
||||
source_tx_body.find(f".//{{{DML_NS}}}rPr")
|
||||
if source_tx_body is not None
|
||||
else None
|
||||
)
|
||||
tx_body.append(
|
||||
ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
|
||||
if source_body_pr is not None
|
||||
else ET.Element(f"{{{DML_NS}}}bodyPr")
|
||||
)
|
||||
list_style = (
|
||||
ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
|
||||
if source_lst_style is not None
|
||||
else ET.Element(f"{{{DML_NS}}}lstStyle")
|
||||
)
|
||||
_set_layout_level_one_default_size(list_style, source_run_pr)
|
||||
tx_body.append(list_style)
|
||||
|
||||
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
|
||||
if item.placeholder == "body":
|
||||
paragraph_props = ET.SubElement(paragraph, f"{{{DML_NS}}}pPr")
|
||||
ET.SubElement(paragraph_props, f"{{{DML_NS}}}buNone")
|
||||
if item.placeholder in {"slide-number", "date"}:
|
||||
field_type = (
|
||||
"slidenum"
|
||||
@@ -1734,7 +1782,7 @@ def _set_placeholder_theme_font_role(
|
||||
return
|
||||
if item.placeholder == "title":
|
||||
prefix = "+mj"
|
||||
elif item.placeholder in _TEMPLATE_PLACEHOLDER_TYPES:
|
||||
elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
|
||||
prefix = "+mn"
|
||||
else:
|
||||
return
|
||||
@@ -1753,7 +1801,7 @@ def _layout_placeholder_shape(
|
||||
theme_font_spec: ThemeFontSpec | None = None,
|
||||
) -> ET.Element:
|
||||
"""Build one reusable p:sp placeholder from a prototype slide object."""
|
||||
placeholder_type = _TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
|
||||
placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
|
||||
if placeholder_type is None:
|
||||
raise TemplateStructureError(
|
||||
f"Unsupported placeholder type: {item.placeholder!r}"
|
||||
@@ -1806,7 +1854,7 @@ def _patch_slide_placeholder(
|
||||
placeholder_type: str | None = None,
|
||||
theme_font_spec: ThemeFontSpec | None = None,
|
||||
) -> None:
|
||||
resolved_type = placeholder_type or _TEMPLATE_PLACEHOLDER_TYPES.get(
|
||||
resolved_type = placeholder_type or TEMPLATE_PLACEHOLDER_TYPES.get(
|
||||
item.placeholder or ""
|
||||
)
|
||||
if resolved_type is None:
|
||||
@@ -1948,8 +1996,10 @@ def _apply_template_structure(
|
||||
layout_path = extract_dir / layout_part
|
||||
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
|
||||
|
||||
placeholder_idx = 1
|
||||
assigned_placeholder_indices: set[int] = set()
|
||||
placeholder_bindings = {
|
||||
binding.element.element_id: binding
|
||||
for binding in template_placeholder_bindings(prototype)
|
||||
}
|
||||
for item in prototype.elements:
|
||||
if item.layer == "layout":
|
||||
_move_template_static_shape(
|
||||
@@ -1968,21 +2018,8 @@ def _apply_template_structure(
|
||||
raise TemplateStructureError(
|
||||
f"Placeholder {item.element_id!r} cannot be a slide background"
|
||||
)
|
||||
if item.placeholder == "title":
|
||||
assigned_idx = item.placeholder_idx
|
||||
else:
|
||||
assigned_idx = (
|
||||
item.placeholder_idx
|
||||
if item.placeholder_idx is not None
|
||||
else placeholder_idx
|
||||
)
|
||||
if (
|
||||
assigned_idx is not None
|
||||
and assigned_idx in assigned_placeholder_indices
|
||||
):
|
||||
raise TemplateStructureError(
|
||||
f"Layout {layout_key!r} repeats placeholder idx {assigned_idx}"
|
||||
)
|
||||
binding = placeholder_bindings[item.element_id]
|
||||
assigned_idx = binding.assigned_idx
|
||||
layout_placeholder = _layout_placeholder_shape(
|
||||
prototype_shape,
|
||||
item,
|
||||
@@ -2003,9 +2040,6 @@ def _apply_template_structure(
|
||||
assigned_idx,
|
||||
theme_font_spec=theme_font_spec,
|
||||
)
|
||||
if assigned_idx is not None:
|
||||
assigned_placeholder_indices.add(assigned_idx)
|
||||
placeholder_idx = max(placeholder_idx, assigned_idx + 1)
|
||||
placeholder_count += 1
|
||||
|
||||
_set_template_layout_header_footer(layout_path, prototype.placeholders)
|
||||
@@ -2755,21 +2789,18 @@ def _ensure_notes_master(extract_dir: Path) -> None:
|
||||
presentation_path.write_text(presentation_xml, encoding='utf-8')
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float) -> float:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if number >= 0 else default
|
||||
|
||||
|
||||
def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dict[str, Any]:
|
||||
if not animation_config:
|
||||
return {}
|
||||
slides = _as_dict(animation_config.get('slides'))
|
||||
return _as_dict(slides.get(svg_stem))
|
||||
slides_value = animation_config.get('slides', {})
|
||||
if not isinstance(slides_value, dict):
|
||||
raise ValueError('animations.json field "slides" must be an object')
|
||||
slide_value = slides_value.get(svg_stem, {})
|
||||
if not isinstance(slide_value, dict):
|
||||
raise ValueError(
|
||||
f'animations.json slide "{svg_stem}" must be an object'
|
||||
)
|
||||
return slide_value
|
||||
|
||||
|
||||
def _slide_transition_settings(
|
||||
@@ -2779,15 +2810,30 @@ def _slide_transition_settings(
|
||||
auto_advance: float | None,
|
||||
cli_overrides: dict[str, bool],
|
||||
) -> tuple[str | None, float, float | None]:
|
||||
trans_cfg = _as_dict(slide_cfg.get('transition'))
|
||||
trans_value = slide_cfg.get('transition', {})
|
||||
if not isinstance(trans_value, dict):
|
||||
raise ValueError('animations.json slide transition must be an object')
|
||||
trans_cfg = trans_value
|
||||
effect = transition
|
||||
if not cli_overrides.get('transition') and 'effect' in trans_cfg:
|
||||
cfg_effect = str(trans_cfg.get('effect'))
|
||||
effect = None if cfg_effect == 'none' else cfg_effect
|
||||
raw_effect = trans_cfg['effect']
|
||||
if not isinstance(raw_effect, str):
|
||||
raise ValueError('animations.json transition effect must be a string')
|
||||
cfg_effect = normalize_transition_effect(raw_effect)
|
||||
effect = cfg_effect
|
||||
if not cli_overrides.get('transition_duration'):
|
||||
duration = _to_float(trans_cfg.get('duration'), duration)
|
||||
if 'duration' in trans_cfg:
|
||||
duration = validate_seconds(
|
||||
trans_cfg.get('duration'),
|
||||
"transition duration",
|
||||
allow_zero=effect is None,
|
||||
)
|
||||
if not cli_overrides.get('auto_advance') and 'auto_advance' in trans_cfg:
|
||||
auto_advance = _to_float(trans_cfg.get('auto_advance'), auto_advance or 0)
|
||||
auto_advance = validate_seconds(
|
||||
trans_cfg.get('auto_advance'),
|
||||
"transition auto_advance",
|
||||
allow_zero=True,
|
||||
)
|
||||
return effect, duration, auto_advance
|
||||
|
||||
|
||||
@@ -2799,65 +2845,131 @@ def _slide_animation_settings(
|
||||
trigger: str,
|
||||
cli_overrides: dict[str, bool],
|
||||
) -> tuple[str | None, float, float, str]:
|
||||
anim_cfg = _as_dict(slide_cfg.get('animation'))
|
||||
effect = animation
|
||||
anim_value = slide_cfg.get('animation', {})
|
||||
if not isinstance(anim_value, dict):
|
||||
raise ValueError('animations.json slide animation must be an object')
|
||||
anim_cfg = anim_value
|
||||
effect = normalize_animation_effect(
|
||||
animation,
|
||||
allow_none=True,
|
||||
allow_modes=True,
|
||||
)
|
||||
if not cli_overrides.get('animation') and 'effect' in anim_cfg:
|
||||
cfg_effect = str(anim_cfg.get('effect'))
|
||||
effect = None if cfg_effect == 'none' else cfg_effect
|
||||
effect = normalize_animation_effect(
|
||||
anim_cfg.get('effect'),
|
||||
allow_none=True,
|
||||
allow_modes=True,
|
||||
)
|
||||
if not cli_overrides.get('animation_duration'):
|
||||
duration = _to_float(anim_cfg.get('duration'), duration)
|
||||
duration = validate_seconds(
|
||||
anim_cfg.get('duration', duration),
|
||||
'animation duration',
|
||||
allow_zero=False,
|
||||
)
|
||||
if not cli_overrides.get('animation_stagger'):
|
||||
stagger = _to_float(anim_cfg.get('stagger'), stagger)
|
||||
if not cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
|
||||
trigger = str(anim_cfg.get('trigger'))
|
||||
stagger = validate_seconds(
|
||||
anim_cfg.get('stagger', stagger),
|
||||
'animation stagger',
|
||||
allow_zero=True,
|
||||
)
|
||||
if not cli_overrides.get('animation_trigger') and 'trigger' in anim_cfg:
|
||||
trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
|
||||
else:
|
||||
trigger = normalize_animation_trigger(trigger)
|
||||
animation_seconds_to_milliseconds(
|
||||
duration,
|
||||
'animation duration',
|
||||
allow_zero=False,
|
||||
)
|
||||
animation_seconds_to_milliseconds(
|
||||
stagger,
|
||||
'animation stagger',
|
||||
allow_zero=True,
|
||||
)
|
||||
return effect, duration, stagger, trigger
|
||||
|
||||
|
||||
def _build_sequence_targets(
|
||||
anim_targets: list[tuple[int, str]],
|
||||
slide_cfg: dict[str, Any],
|
||||
animation: str,
|
||||
animation: str | None,
|
||||
duration: float,
|
||||
stagger: float,
|
||||
mixed_animation_offset: int,
|
||||
animation_rng: random.Random,
|
||||
) -> tuple[list[tuple[int, int, str, float]], int]:
|
||||
groups_cfg = _as_dict(slide_cfg.get('groups'))
|
||||
ordered: list[tuple[int, int, int, str, dict[str, Any]]] = []
|
||||
groups_value = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups_value, dict):
|
||||
raise ValueError('animations.json slide groups must be an object')
|
||||
groups_cfg = groups_value
|
||||
ordered: list[tuple[int, int, str, dict[str, Any]]] = []
|
||||
for idx, (sid, svg_id) in enumerate(anim_targets):
|
||||
group_cfg = _as_dict(groups_cfg.get(svg_id))
|
||||
if str(group_cfg.get('effect', '')).lower() == 'none':
|
||||
group_value = groups_cfg.get(svg_id, {})
|
||||
if not isinstance(group_value, dict):
|
||||
raise ValueError(
|
||||
f'animations.json group "{svg_id}" must be an object'
|
||||
)
|
||||
group_cfg = group_value
|
||||
raw_effect = group_cfg.get('effect')
|
||||
if raw_effect is not None:
|
||||
normalized_effect = normalize_animation_effect(
|
||||
raw_effect,
|
||||
allow_none=True,
|
||||
allow_modes=True,
|
||||
)
|
||||
else:
|
||||
normalized_effect = None
|
||||
if 'effect' in group_cfg and normalized_effect is None:
|
||||
continue
|
||||
if animation is None and normalized_effect is None:
|
||||
continue
|
||||
order_value = group_cfg.get('order')
|
||||
try:
|
||||
order = int(order_value)
|
||||
has_order = 0
|
||||
except (TypeError, ValueError):
|
||||
order = idx
|
||||
has_order = 1
|
||||
order = order_value if order_value is not None else idx + 1
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
|
||||
raise ValueError(
|
||||
f'animations.json group "{svg_id}" order must be a positive integer'
|
||||
)
|
||||
group_entry = dict(group_cfg)
|
||||
group_entry['_shape_id'] = sid
|
||||
ordered.append((has_order, order, idx, svg_id, group_entry))
|
||||
group_entry['_effect'] = normalized_effect
|
||||
ordered.append((order, idx, svg_id, group_entry))
|
||||
|
||||
ordered.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
ordered.sort(key=lambda item: (item[0], item[1]))
|
||||
|
||||
seq_targets: list[tuple[int, int, str, float]] = []
|
||||
for seq_idx, (_has_order, _order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
|
||||
resolved_group_modes: list[str | None] = []
|
||||
for seq_idx, (_order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
|
||||
shape_id = int(group_cfg['_shape_id'])
|
||||
raw_effect = group_cfg.get('effect')
|
||||
raw_effect = group_cfg.get('_effect')
|
||||
resolved_group_modes.append(
|
||||
raw_effect if raw_effect in ('auto', 'mixed', 'random') else None
|
||||
)
|
||||
if raw_effect in ('auto', 'mixed', 'random'):
|
||||
effect = pick_animation_effect(
|
||||
str(raw_effect), seq_idx, mixed_animation_offset, group_id=_svg_id,
|
||||
rng=animation_rng,
|
||||
)
|
||||
else:
|
||||
effect = str(raw_effect or pick_animation_effect(
|
||||
animation, seq_idx, mixed_animation_offset, group_id=_svg_id,
|
||||
rng=animation_rng,
|
||||
))
|
||||
item_duration = _to_float(group_cfg.get('duration'), duration)
|
||||
delay_seconds = _to_float(
|
||||
group_cfg.get('delay'),
|
||||
0 if seq_idx == 0 else stagger,
|
||||
item_duration = validate_seconds(
|
||||
group_cfg.get('duration', duration),
|
||||
f'animation duration for group "{_svg_id}"',
|
||||
allow_zero=False,
|
||||
)
|
||||
seq_targets.append((shape_id, int(delay_seconds * 1000), effect, item_duration))
|
||||
delay_seconds = validate_seconds(
|
||||
group_cfg.get('delay', 0 if seq_idx == 0 else stagger),
|
||||
f'animation delay for group "{_svg_id}"',
|
||||
allow_zero=True,
|
||||
)
|
||||
delay_ms = animation_seconds_to_milliseconds(
|
||||
delay_seconds,
|
||||
f'animation delay for group "{_svg_id}"',
|
||||
allow_zero=True,
|
||||
)
|
||||
seq_targets.append((shape_id, delay_ms, effect, item_duration))
|
||||
|
||||
mixed_count = 0
|
||||
if animation == 'mixed':
|
||||
@@ -2868,6 +2980,12 @@ def _build_sequence_targets(
|
||||
# semantic matches (title→fade, chart→wipe etc.) are unaffected
|
||||
# because they ignore the offset.
|
||||
mixed_count = len(seq_targets)
|
||||
else:
|
||||
mixed_count = sum(
|
||||
1
|
||||
for seq_idx, mode in enumerate(resolved_group_modes)
|
||||
if mode == 'auto' or (mode == 'mixed' and seq_idx > 0)
|
||||
)
|
||||
return seq_targets, mixed_count
|
||||
|
||||
|
||||
@@ -3220,7 +3338,7 @@ def create_pptx_with_native_svg(
|
||||
use_compat_mode: bool = True,
|
||||
notes: dict[str, str] | None = None,
|
||||
enable_notes: bool = True,
|
||||
use_native_shapes: bool = False,
|
||||
use_native_shapes: bool = True,
|
||||
animation: str | None = None,
|
||||
animation_duration: float = 0.4,
|
||||
animation_stagger: float = 0.5,
|
||||
@@ -3244,9 +3362,10 @@ def create_pptx_with_native_svg(
|
||||
pptx_structure: str = "baseline",
|
||||
native_structure_contract: NativeStructureContract | None = None,
|
||||
theme_font_spec: ThemeFontSpec | None = None,
|
||||
master_text_style_spec: MasterTextStyleSpec | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
) -> bool:
|
||||
"""Create a PPTX file with native SVG.
|
||||
"""Create a PPTX file with native DrawingML shapes.
|
||||
|
||||
Args:
|
||||
svg_files: List of SVG files.
|
||||
@@ -3256,10 +3375,10 @@ def create_pptx_with_native_svg(
|
||||
transition: Transition effect name.
|
||||
transition_duration: Transition duration in seconds.
|
||||
auto_advance: Auto-advance interval in seconds.
|
||||
use_compat_mode: Use Office compatibility mode (PNG + SVG dual format).
|
||||
use_compat_mode: Retained for API compatibility; ignored in native mode.
|
||||
notes: Notes dict, key is SVG stem, value is notes content.
|
||||
enable_notes: Whether to enable notes embedding.
|
||||
use_native_shapes: Convert SVG to native DrawingML shapes.
|
||||
use_native_shapes: Must remain true; SVG-image PPTX export is unsupported.
|
||||
animation: Per-element entrance animation mode (single effect name,
|
||||
'mixed', 'random', or None to disable). Native shapes mode only.
|
||||
animation_duration: Per-element entrance duration in seconds.
|
||||
@@ -3293,6 +3412,8 @@ def create_pptx_with_native_svg(
|
||||
``preserve`` mode.
|
||||
theme_font_spec: Locked project major/minor fonts for baseline/template
|
||||
theme inheritance. Preserve and flat modes ignore this value.
|
||||
master_text_style_spec: Required locked title/body sizes for template
|
||||
slide-master text styles. Other structure modes ignore this value.
|
||||
theme_color_spec: Locked project color scheme for context-aware
|
||||
baseline/template theme inheritance. Preserve and flat modes
|
||||
ignore this value.
|
||||
@@ -3300,15 +3421,23 @@ def create_pptx_with_native_svg(
|
||||
Returns:
|
||||
Whether all slides were successfully created.
|
||||
"""
|
||||
if not use_native_shapes:
|
||||
raise ValueError(
|
||||
"SVG-image PPTX export is no longer supported; use svg_final/ "
|
||||
"directly for preview and native DrawingML PPTX for delivery"
|
||||
)
|
||||
if not svg_files:
|
||||
print("Error: No SVG files found")
|
||||
return False
|
||||
|
||||
# Native shapes mode takes priority over compat mode
|
||||
if use_native_shapes:
|
||||
use_compat_mode = False
|
||||
use_compat_mode = False
|
||||
if pptx_structure not in {"baseline", "template", "preserve", "flat"}:
|
||||
raise ValueError(f"Unsupported pptx_structure: {pptx_structure}")
|
||||
if pptx_structure == "template" and master_text_style_spec is None:
|
||||
raise ValueError(
|
||||
"Template export requires locked typography title/body sizes "
|
||||
"in master_text_style_spec"
|
||||
)
|
||||
if use_native_shapes and pptx_structure == "template":
|
||||
template_specs = parse_template_slides(svg_files)
|
||||
elif use_native_shapes and pptx_structure == "preserve":
|
||||
@@ -3453,6 +3582,18 @@ def create_pptx_with_native_svg(
|
||||
)
|
||||
if active_theme_color_spec is not None:
|
||||
apply_theme_color_spec(extract_dir, active_theme_color_spec)
|
||||
if pptx_structure == "template":
|
||||
master_count = apply_master_text_style_spec(
|
||||
extract_dir,
|
||||
master_text_style_spec,
|
||||
)
|
||||
if verbose:
|
||||
print(
|
||||
" Template master text styles: "
|
||||
f"{master_count} master(s), "
|
||||
f"title {master_text_style_spec.title_hpt / 100:g}pt, "
|
||||
f"body {master_text_style_spec.body_hpt / 100:g}pt"
|
||||
)
|
||||
structure = _read_slide_layout_targets(extract_dir, len(svg_files))
|
||||
|
||||
media_dir = extract_dir / 'ppt' / 'media'
|
||||
@@ -3484,7 +3625,19 @@ def create_pptx_with_native_svg(
|
||||
notes_slides_created: set[int] = set()
|
||||
narration_slides_created: set[int] = set()
|
||||
audio_exts_used: set[str] = set()
|
||||
package_uses_timings = False
|
||||
mixed_animation_offset = 0
|
||||
animation_seed = json.dumps(
|
||||
{
|
||||
'animation': animation,
|
||||
'config': animation_config,
|
||||
'slides': [path.name for path in svg_files],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
)
|
||||
animation_rng = random.Random(animation_seed)
|
||||
conversion_trace: list[dict[str, Any]] | None = [] if conversion_trace_path else None
|
||||
structure_trace: list[dict[str, Any]] | None = (
|
||||
[]
|
||||
@@ -3494,35 +3647,14 @@ def create_pptx_with_native_svg(
|
||||
|
||||
for i, svg_path in enumerate(svg_files, 1):
|
||||
slide_num = i
|
||||
expected_animation_targets: list[tuple[int, int, str, float]] = []
|
||||
expected_animation_duration = animation_duration
|
||||
expected_animation_trigger = normalize_animation_trigger(animation_trigger)
|
||||
|
||||
try:
|
||||
# ---- Native shapes mode ----
|
||||
if use_native_shapes:
|
||||
slide_cfg = _slide_config(animation_config, svg_path.stem)
|
||||
(
|
||||
slide_xml,
|
||||
media_files_dict,
|
||||
rel_entries,
|
||||
anim_targets,
|
||||
package_files_dict,
|
||||
content_type_overrides,
|
||||
) = (
|
||||
convert_svg_to_slide_shapes(
|
||||
svg_path, slide_num=slide_num, verbose=verbose,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
image_optimize=image_optimize,
|
||||
image_max_dimension=image_max_dimension,
|
||||
image_sizing=image_sizing,
|
||||
image_scale=image_scale,
|
||||
image_quality=image_quality,
|
||||
native_objects=native_objects,
|
||||
theme_font_spec=active_theme_font_spec,
|
||||
theme_color_spec=active_theme_color_spec,
|
||||
trace_out=conversion_trace
|
||||
if conversion_trace is not None
|
||||
else structure_trace,
|
||||
)
|
||||
)
|
||||
slide_transition, slide_transition_duration, slide_auto_advance = (
|
||||
_slide_transition_settings(
|
||||
slide_cfg,
|
||||
@@ -3545,26 +3677,79 @@ def create_pptx_with_native_svg(
|
||||
animation_trigger,
|
||||
animation_cli_overrides,
|
||||
)
|
||||
|
||||
groups_value = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups_value, dict):
|
||||
raise ValueError(
|
||||
'animations.json slide groups must be an object'
|
||||
)
|
||||
animation_hard_disabled = (
|
||||
animation_cli_overrides.get('animation', False)
|
||||
and animation is None
|
||||
)
|
||||
explicit_animation_groups = (
|
||||
frozenset(
|
||||
str(group_id)
|
||||
for group_id, group_cfg in groups_value.items()
|
||||
if isinstance(group_cfg, dict)
|
||||
and group_cfg.get('effect') != 'none'
|
||||
and (
|
||||
slide_animation is not None
|
||||
or 'effect' in group_cfg
|
||||
)
|
||||
)
|
||||
if not animation_hard_disabled
|
||||
else frozenset()
|
||||
)
|
||||
(
|
||||
slide_xml,
|
||||
media_files_dict,
|
||||
rel_entries,
|
||||
anim_targets,
|
||||
package_files_dict,
|
||||
content_type_overrides,
|
||||
) = (
|
||||
convert_svg_to_slide_shapes(
|
||||
svg_path, slide_num=slide_num, verbose=verbose,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
image_optimize=image_optimize,
|
||||
image_max_dimension=image_max_dimension,
|
||||
image_sizing=image_sizing,
|
||||
image_scale=image_scale,
|
||||
image_quality=image_quality,
|
||||
native_objects=native_objects,
|
||||
animation_group_overrides=explicit_animation_groups,
|
||||
theme_font_spec=active_theme_font_spec,
|
||||
theme_color_spec=active_theme_color_spec,
|
||||
trace_out=conversion_trace
|
||||
if conversion_trace is not None
|
||||
else structure_trace,
|
||||
)
|
||||
)
|
||||
# Order matters: OOXML schema requires <p:transition>
|
||||
# to precede <p:timing> inside <p:sld>. Both use the same
|
||||
# </p:sld> string-replace anchor, so transition must be
|
||||
# injected first and timing second.
|
||||
if slide_transition and ANIMATIONS_AVAILABLE and create_transition_xml:
|
||||
transition_xml = '\n' + create_transition_xml(
|
||||
if slide_transition is not None or slide_auto_advance is not None:
|
||||
transition_fragment = create_transition_xml(
|
||||
effect=slide_transition,
|
||||
duration=slide_transition_duration,
|
||||
advance_after=slide_auto_advance,
|
||||
)
|
||||
slide_xml = slide_xml.replace(
|
||||
'</p:sld>',
|
||||
transition_xml + '\n</p:sld>',
|
||||
)
|
||||
if transition_fragment:
|
||||
slide_xml = slide_xml.replace(
|
||||
'</p:sld>',
|
||||
'\n' + transition_fragment + '\n</p:sld>',
|
||||
)
|
||||
if slide_auto_advance is not None:
|
||||
package_uses_timings = True
|
||||
|
||||
if (slide_animation and slide_animation != 'none'
|
||||
and create_sequence_timing_xml
|
||||
and pick_animation_effect
|
||||
and anim_targets):
|
||||
expected_animation_duration = slide_animation_duration
|
||||
expected_animation_trigger = slide_animation_trigger
|
||||
if (
|
||||
not animation_hard_disabled
|
||||
and (slide_animation or explicit_animation_groups)
|
||||
and anim_targets
|
||||
):
|
||||
seq_targets, mixed_count = _build_sequence_targets(
|
||||
anim_targets,
|
||||
slide_cfg,
|
||||
@@ -3572,8 +3757,10 @@ def create_pptx_with_native_svg(
|
||||
slide_animation_duration,
|
||||
slide_animation_stagger,
|
||||
mixed_animation_offset,
|
||||
animation_rng,
|
||||
)
|
||||
if slide_animation in ('mixed', 'auto'):
|
||||
expected_animation_targets = seq_targets
|
||||
if mixed_count:
|
||||
mixed_animation_offset += mixed_count
|
||||
timing_xml = '\n' + create_sequence_timing_xml(
|
||||
seq_targets, duration=slide_animation_duration,
|
||||
@@ -3729,6 +3916,9 @@ def create_pptx_with_native_svg(
|
||||
with open(rels_path, 'w', encoding='utf-8') as f:
|
||||
f.write(rels_xml)
|
||||
|
||||
resolved_advance_after = slide_auto_advance
|
||||
resolved_advance_on_click = True
|
||||
|
||||
# --- Process notes (shared between native and legacy mode) ---
|
||||
notes_content = ''
|
||||
if enable_notes:
|
||||
@@ -3816,11 +4006,47 @@ def create_pptx_with_native_svg(
|
||||
slide_xml,
|
||||
advance_after=duration + narration_padding,
|
||||
transition_duration=slide_transition_duration,
|
||||
transition_effect=slide_transition or 'fade',
|
||||
transition_effect=slide_transition,
|
||||
)
|
||||
resolved_advance_after = duration + narration_padding
|
||||
resolved_advance_on_click = False
|
||||
package_uses_timings = True
|
||||
slide_xml_path.write_text(slide_xml, encoding='utf-8')
|
||||
narration_slides_created.add(slide_num)
|
||||
|
||||
final_slide_xml = slide_xml_path.read_text(encoding='utf-8')
|
||||
try:
|
||||
resolved_motion = validate_generated_transition_xml(
|
||||
final_slide_xml,
|
||||
effect=slide_transition,
|
||||
duration=slide_transition_duration,
|
||||
advance_on_click=resolved_advance_on_click,
|
||||
advance_after=resolved_advance_after,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f'Slide {slide_num} transition validation failed: {exc}'
|
||||
) from exc
|
||||
try:
|
||||
resolved_animation = validate_generated_animation_xml(
|
||||
final_slide_xml,
|
||||
expected_animation_targets,
|
||||
duration=expected_animation_duration,
|
||||
trigger=expected_animation_trigger,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f'Slide {slide_num} animation validation failed: {exc}'
|
||||
) from exc
|
||||
|
||||
if conversion_trace is not None:
|
||||
motion_summary = asdict(resolved_motion)
|
||||
for trace_entry in reversed(conversion_trace):
|
||||
if trace_entry.get('slide_num') == slide_num:
|
||||
trace_entry['motion'] = motion_summary
|
||||
trace_entry['animation'] = asdict(resolved_animation)
|
||||
break
|
||||
|
||||
if verbose:
|
||||
if use_native_shapes:
|
||||
mode_str = " (Native)"
|
||||
@@ -4003,6 +4229,9 @@ def create_pptx_with_native_svg(
|
||||
with open(content_types_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content_types)
|
||||
|
||||
if package_uses_timings:
|
||||
set_directory_use_timings(extract_dir)
|
||||
|
||||
rels_problems = _verify_internal_rels_targets(extract_dir)
|
||||
if rels_problems:
|
||||
details = '\n'.join(f' - {p}' for p in rels_problems)
|
||||
@@ -4025,6 +4254,39 @@ def create_pptx_with_native_svg(
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(extract_dir)
|
||||
zf.write(file_path, arcname)
|
||||
if (
|
||||
use_native_shapes
|
||||
and pptx_structure == "template"
|
||||
and success_count == len(svg_files)
|
||||
):
|
||||
if template_specs is None:
|
||||
raise TemplateStructureError(
|
||||
"Template structure metadata was not parsed before validation"
|
||||
)
|
||||
try:
|
||||
validate_pptx_template_package(temp_output_path, template_specs)
|
||||
except ValueError as exc:
|
||||
raise TemplateStructureError(
|
||||
f"PPTX template package validation failed: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_transition_package(
|
||||
temp_output_path,
|
||||
require_use_timings=package_uses_timings,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f'PPTX transition package validation failed: {exc}'
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_animation_package(
|
||||
temp_output_path,
|
||||
require_supported_effects=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f'PPTX animation package validation failed: {exc}'
|
||||
) from exc
|
||||
shutil.move(str(temp_output_path), str(output_path))
|
||||
permission_warnings = _relax_output_permissions(output_path)
|
||||
|
||||
|
||||
+188
-231
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
@@ -15,6 +16,13 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from pptx_animations import ( # noqa: E402
|
||||
ANIMATIONS,
|
||||
animation_seconds_to_milliseconds,
|
||||
normalize_animation_effect,
|
||||
normalize_animation_trigger,
|
||||
)
|
||||
from pptx_transitions import validate_seconds # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -30,7 +38,11 @@ from .dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
|
||||
from .discovery import find_svg_files, find_notes_files
|
||||
from .builder import create_pptx_with_native_svg
|
||||
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
|
||||
from ..drawingml.theme_fonts import ThemeFontError, load_theme_font_spec
|
||||
from ..drawingml.theme_fonts import (
|
||||
ThemeFontError,
|
||||
load_master_text_style_spec,
|
||||
load_theme_font_spec,
|
||||
)
|
||||
from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
|
||||
from .slide_xml import TRANSITIONS
|
||||
from .template_structure import (
|
||||
@@ -42,12 +54,12 @@ from .template_structure import (
|
||||
parse_template_slides,
|
||||
template_lock_errors,
|
||||
)
|
||||
from ..animation_config import load_animation_config, validate_animation_config
|
||||
|
||||
try:
|
||||
from pptx_animations import ANIMATIONS as _ANIMATIONS
|
||||
except ImportError:
|
||||
_ANIMATIONS = {}
|
||||
from ..animation_config import (
|
||||
load_animation_config,
|
||||
validate_animation_config,
|
||||
validate_animation_config_errors,
|
||||
validate_transition_config,
|
||||
)
|
||||
|
||||
|
||||
def _as_dict(value: object) -> dict:
|
||||
@@ -79,6 +91,8 @@ def _recorded_narration_on_click_slides(
|
||||
animation_cli_overrides: dict[str, bool],
|
||||
) -> list[str]:
|
||||
"""Return slides whose effective recorded-video animation trigger is on-click."""
|
||||
if animation_cli_overrides.get('animation') and animation is None:
|
||||
return []
|
||||
slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
|
||||
blocked: list[str] = []
|
||||
for svg_path in ref_files:
|
||||
@@ -87,14 +101,20 @@ def _recorded_narration_on_click_slides(
|
||||
|
||||
slide_animation = animation
|
||||
if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
|
||||
cfg_effect = str(anim_cfg.get('effect'))
|
||||
slide_animation = None if cfg_effect == 'none' else cfg_effect
|
||||
if slide_animation is None:
|
||||
slide_animation = normalize_animation_effect(anim_cfg.get('effect'))
|
||||
groups_cfg = _as_dict(slide_cfg.get('groups'))
|
||||
has_explicit_animation = any(
|
||||
isinstance(group_cfg, dict)
|
||||
and 'effect' in group_cfg
|
||||
and normalize_animation_effect(group_cfg.get('effect')) is not None
|
||||
for group_cfg in groups_cfg.values()
|
||||
)
|
||||
if slide_animation is None and not has_explicit_animation:
|
||||
continue
|
||||
|
||||
slide_trigger = animation_trigger
|
||||
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
|
||||
slide_trigger = str(anim_cfg.get('trigger'))
|
||||
slide_trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
|
||||
if slide_trigger == 'on-click':
|
||||
blocked.append(svg_path.stem)
|
||||
return blocked
|
||||
@@ -107,20 +127,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
|
||||
)
|
||||
|
||||
animation_choices = (
|
||||
['none'] + (list(_ANIMATIONS.keys()) if _ANIMATIONS
|
||||
else ['fade', 'fly', 'zoom', 'appear'])
|
||||
+ ['auto', 'mixed', 'random']
|
||||
)
|
||||
animation_choices = ['none', *ANIMATIONS, 'auto', 'mixed', 'random']
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
|
||||
description='PPT Master - SVG to native DrawingML PPTX Tool',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f'''
|
||||
Examples:
|
||||
%(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
|
||||
%(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx
|
||||
%(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native)
|
||||
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
|
||||
|
||||
# Disable transition / change transition effect
|
||||
@@ -129,9 +143,9 @@ Examples:
|
||||
|
||||
SVG source directory (-s):
|
||||
output - svg_output (hand-authored source; native default)
|
||||
final - svg_final (post-processed; legacy SVG-image path source)
|
||||
final - svg_final (post-processed preview; diagnostic native input only)
|
||||
<any> - Specify a subdirectory name directly
|
||||
Omit -s to use the default: native reads svg_output, legacy reads svg_final.
|
||||
Omit -s to use the default: native export reads svg_output.
|
||||
|
||||
Transition effects (-t/--transition):
|
||||
{', '.join(transition_choices)}
|
||||
@@ -154,13 +168,6 @@ Per-element entrance animation (-a/--animation, native shapes mode):
|
||||
random samples from the same legacy pool. Use "-a none" to disable
|
||||
element builds explicitly.
|
||||
|
||||
Compatibility mode (enabled by default):
|
||||
- Automatically generates PNG fallback images, SVG embedded as extension
|
||||
- Compatible with all Office versions (including Office LTSC 2021)
|
||||
- Newer Office still displays SVG (editable), older versions display PNG
|
||||
- Requires svglib: pip install svglib reportlab
|
||||
- Use --no-compat to disable (only Office 2019+ supported)
|
||||
|
||||
Speaker notes (enabled by default):
|
||||
- Automatically reads Markdown notes files from the notes/ directory
|
||||
- Supports two naming conventions:
|
||||
@@ -186,24 +193,13 @@ Recorded narration:
|
||||
parser.add_argument('project_path', type=str, help='Project directory path')
|
||||
parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
|
||||
parser.add_argument('-s', '--source', type=str, default=None,
|
||||
help='SVG source directory. Default: native reads '
|
||||
'svg_output/ (high-fidelity, preserves icons / '
|
||||
'preserveAspectRatio / rx-ry); legacy reads '
|
||||
'svg_final/ (PPT-internal SVG parser fallback). '
|
||||
'Pass output/final/<name> to force one source.')
|
||||
help='Native SVG source directory. Default: svg_output/. '
|
||||
'Pass output/final/<name> only for diagnostics.')
|
||||
parser.add_argument('-f', '--format', type=str,
|
||||
choices=list(CANVAS_FORMATS.keys()), default=None,
|
||||
help='Specify canvas format')
|
||||
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||||
|
||||
parser.add_argument('--no-compat', action='store_true',
|
||||
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
|
||||
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
|
||||
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
|
||||
mode_group.add_argument('--native', action='store_true', default=False,
|
||||
help='(Deprecated, now default) Convert SVG to native DrawingML shapes')
|
||||
merge_group = parser.add_mutually_exclusive_group()
|
||||
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
|
||||
help='Compatibility no-op: mergeable paragraph blocks are merged '
|
||||
@@ -238,14 +234,6 @@ Recorded narration:
|
||||
'flat leaves generated structure slide-local for debugging/comparison.'
|
||||
),
|
||||
)
|
||||
parser.add_argument('--svg-snapshot', action='store_true', default=False,
|
||||
help='Also emit the SVG-rendered snapshot pptx alongside '
|
||||
'the native pptx in exports/ (named '
|
||||
'<project>_<ts>_svg.pptx). Off by default — the '
|
||||
'native pptx is the canonical output; live preview '
|
||||
'already provides the SVG visual reference. '
|
||||
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ '
|
||||
'regardless of this flag.')
|
||||
parser.add_argument('--no-image-optimize', action='store_true',
|
||||
help='Disable native PPTX raster image optimization; embeds original image bytes.')
|
||||
parser.add_argument('--image-max-dimension', type=int, default=2560,
|
||||
@@ -264,12 +252,20 @@ Recorded narration:
|
||||
number = float(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
|
||||
if not math.isfinite(number):
|
||||
raise argparse.ArgumentTypeError("must be finite")
|
||||
if number < 0:
|
||||
raise argparse.ArgumentTypeError("must be non-negative")
|
||||
return number
|
||||
|
||||
def positive_float(value: str) -> float:
|
||||
number = non_negative_float(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("must be greater than zero")
|
||||
return number
|
||||
|
||||
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
|
||||
help='Page transition effect (default: fade, use "none" to disable)')
|
||||
help='Page transition effect (default: fade; "none" removes visual motion)')
|
||||
parser.add_argument('--transition-duration', type=non_negative_float, default=None,
|
||||
help='Transition duration in seconds (default: 0.4)')
|
||||
parser.add_argument('--auto-advance', type=non_negative_float, default=None,
|
||||
@@ -284,7 +280,7 @@ Recorded narration:
|
||||
'richer pool for visual variation, fallback cycles fade/'
|
||||
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
|
||||
'"random".')
|
||||
parser.add_argument('--animation-duration', type=non_negative_float, default=None,
|
||||
parser.add_argument('--animation-duration', type=positive_float, default=None,
|
||||
help='Per-element entrance duration in seconds (default: 0.4)')
|
||||
parser.add_argument('--animation-trigger', type=str,
|
||||
choices=['on-click', 'with-previous', 'after-previous'],
|
||||
@@ -311,23 +307,9 @@ Recorded narration:
|
||||
help='Prepare PowerPoint recorded timings and narrations from a complete audio '
|
||||
'directory. Default-flow exports get the _narrated name suffix '
|
||||
'(<project>_<ts>_narrated.pptx) to tell them apart from silent exports.')
|
||||
parser.add_argument('--narration-padding', type=float, default=0.5,
|
||||
parser.add_argument('--narration-padding', type=non_negative_float, default=0.5,
|
||||
help='Seconds to add after each narration before auto-advance (default: 0.5)')
|
||||
|
||||
parser.add_argument('--cache-dir', type=str, default=None,
|
||||
help='Cache directory for SVG→PNG renders (default: '
|
||||
'<project>/.cache/svg_png). Cache key uses SVG content '
|
||||
'hash + size + renderer; safe across renderer switches. '
|
||||
'Removed automatically after a successful export.')
|
||||
parser.add_argument('--no-cache', action='store_true',
|
||||
help='Disable the SVG→PNG cache for this run (still parallel).')
|
||||
parser.add_argument('--keep-cache', action='store_true',
|
||||
help='Keep the SVG→PNG cache directory after export '
|
||||
'(default: removed on success to keep project clean).')
|
||||
parser.add_argument('--workers', type=int, default=None,
|
||||
help='Parallel workers for SVG→PNG pre-rendering. '
|
||||
'Default: min(cpu, pages, 8). Set 1 for sequential.')
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project_path = Path(args.project_path)
|
||||
@@ -367,10 +349,13 @@ Recorded narration:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
theme_font_spec = None
|
||||
master_text_style_spec = None
|
||||
theme_color_spec = None
|
||||
if pptx_structure in {'baseline', 'template'}:
|
||||
try:
|
||||
theme_font_spec = load_theme_font_spec(project_path)
|
||||
if pptx_structure == 'template':
|
||||
master_text_style_spec = load_master_text_style_spec(project_path)
|
||||
theme_color_spec = load_theme_color_spec(project_path)
|
||||
except (ThemeFontError, ThemeColorError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
@@ -397,49 +382,17 @@ Recorded narration:
|
||||
if canvas_format is None and detected_format and detected_format != 'unknown':
|
||||
canvas_format = detected_format
|
||||
|
||||
# Determine which versions to generate.
|
||||
# Default is native-only; SVG snapshot is opt-in via --svg-snapshot.
|
||||
# --only native / --only legacy still force a single version explicitly.
|
||||
only_mode = args.only
|
||||
if only_mode == 'native':
|
||||
gen_native, gen_legacy = True, False
|
||||
elif only_mode == 'legacy':
|
||||
gen_native, gen_legacy = False, True
|
||||
else:
|
||||
gen_native = True
|
||||
gen_legacy = args.svg_snapshot
|
||||
|
||||
# Pipeline split: native pptx gets the high-fidelity svg_output/ source
|
||||
# (icons, preserveAspectRatio, rounded-rect rx/ry are all preserved by the
|
||||
# converter); legacy pptx still needs svg_final/ because PowerPoint's
|
||||
# internal SVG parser cannot handle <use data-icon> or honour
|
||||
# preserveAspectRatio. An explicit -s overrides both branches so callers
|
||||
# can keep the previous single-source behaviour for unusual workflows.
|
||||
explicit_source = args.source is not None
|
||||
native_source = args.source if explicit_source else 'output'
|
||||
legacy_source = args.source if explicit_source else 'final'
|
||||
|
||||
native_files: list[Path] = []
|
||||
legacy_files: list[Path] = []
|
||||
native_source_dir = ''
|
||||
legacy_source_dir = ''
|
||||
|
||||
if gen_native:
|
||||
native_files, native_source_dir = find_svg_files(project_path, native_source)
|
||||
if gen_legacy:
|
||||
legacy_files, legacy_source_dir = find_svg_files(project_path, legacy_source)
|
||||
|
||||
# Reference list for cross-product lookups (notes / narration matching).
|
||||
# native_files and legacy_files share filenames because svg_final/ is
|
||||
# copytree'd from svg_output/, so either list works for matching.
|
||||
ref_files = native_files or legacy_files
|
||||
if not ref_files:
|
||||
# Native DrawingML is the only PPTX product. ``-s`` remains an explicit
|
||||
# diagnostic source override; standard export always reads svg_output/.
|
||||
native_source = args.source or 'output'
|
||||
native_files, native_source_dir = find_svg_files(project_path, native_source)
|
||||
ref_files = native_files
|
||||
if not native_files:
|
||||
print("Error: No SVG files found")
|
||||
return 1
|
||||
|
||||
if (
|
||||
gen_native
|
||||
and pptx_structure in {'template', 'preserve'}
|
||||
pptx_structure in {'template', 'preserve'}
|
||||
and structure_lock is not None
|
||||
):
|
||||
try:
|
||||
@@ -491,37 +444,24 @@ Recorded narration:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
backup_dir: Path | None = None
|
||||
legacy_path: Path | None = None
|
||||
if args.output:
|
||||
output_base = Path(args.output)
|
||||
native_path = output_base
|
||||
if gen_legacy:
|
||||
stem = output_base.stem
|
||||
legacy_path = output_base.parent / f"{stem}_svg{output_base.suffix}"
|
||||
native_path = Path(args.output)
|
||||
else:
|
||||
exports_dir = project_path / "exports"
|
||||
exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
# --native-objects yields a materially different file (real editable
|
||||
# PowerPoint chart/table objects instead of flattened shapes), so mark
|
||||
# it in the default-flow name to tell it apart from a plain shape export
|
||||
# and the _svg snapshot. Narration flags likewise mark _narrated (audio
|
||||
# embedded per slide + auto-advance timings); both entry points share
|
||||
# the tag. Flag-driven (not content-sniffed) so the name is predictable;
|
||||
# an explicit -o keeps the caller's exact name untouched.
|
||||
# it in the default-flow name to tell it apart from a plain shape export.
|
||||
# Narration flags likewise mark _narrated (audio embedded per slide +
|
||||
# auto-advance timings). Flag-driven (not content-sniffed) so the name
|
||||
# is predictable; an explicit -o keeps the caller's exact name untouched.
|
||||
native_tag = "_native_charts" if args.native_objects else ""
|
||||
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
|
||||
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
|
||||
# svg_output/ snapshot always goes under backup/<ts>/ in default-flow
|
||||
# mode (no -o). --svg-snapshot only controls the optional legacy
|
||||
# SVG-rendered pptx, which now sits alongside the native pptx in
|
||||
# exports/ rather than nested inside backup/.
|
||||
# Preserve the authored svg_output/ beside every default-flow export.
|
||||
backup_dir = project_path / "backup" / timestamp
|
||||
if gen_legacy:
|
||||
legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"
|
||||
|
||||
native_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if legacy_path is not None:
|
||||
legacy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
verbose = not args.quiet
|
||||
|
||||
@@ -620,15 +560,41 @@ Recorded narration:
|
||||
except Exception as exc:
|
||||
print(f"Error: Failed to load animation config: {exc}")
|
||||
return 1
|
||||
config_errors: list[str] = []
|
||||
if animation_config:
|
||||
config_errors.extend(validate_transition_config(animation_config))
|
||||
config_errors.extend(validate_animation_config_errors(animation_config))
|
||||
config_errors = list(dict.fromkeys(config_errors))
|
||||
if config_errors:
|
||||
for error in config_errors:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
config_warnings: list[str] = []
|
||||
if animation_config:
|
||||
reference_messages = validate_animation_config(project_path, animation_config)
|
||||
config_warnings = [
|
||||
message for message in reference_messages
|
||||
if ' has no id and cannot be customized in animations.json' in message
|
||||
]
|
||||
reference_errors = [
|
||||
message for message in reference_messages
|
||||
if message not in config_warnings
|
||||
]
|
||||
if reference_errors:
|
||||
for error in reference_errors:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if animation_config and verbose:
|
||||
config_label = args.animation_config or str(project_path / 'animations.json')
|
||||
print(f" Animation config: {config_label}")
|
||||
for warning in validate_animation_config(project_path, animation_config):
|
||||
for warning in config_warnings:
|
||||
print(f" [warn] {warning}")
|
||||
|
||||
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
||||
transition_defaults = defaults.get('transition', {}) if isinstance(defaults, dict) else {}
|
||||
animation_defaults = defaults.get('animation', {}) if isinstance(defaults, dict) else {}
|
||||
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
||||
animation_defaults = _as_dict(defaults.get('animation')) if isinstance(defaults, dict) else {}
|
||||
|
||||
transition_arg = args.transition
|
||||
transition_effect = (
|
||||
@@ -637,37 +603,77 @@ Recorded narration:
|
||||
else transition_defaults.get('effect', 'fade')
|
||||
)
|
||||
transition = None if transition_effect == 'none' else transition_effect
|
||||
transition_duration = (
|
||||
args.transition_duration
|
||||
if args.transition_duration is not None
|
||||
else float(transition_defaults.get('duration', 0.4))
|
||||
)
|
||||
try:
|
||||
transition_duration = validate_seconds(
|
||||
(
|
||||
args.transition_duration
|
||||
if args.transition_duration is not None
|
||||
else transition_defaults.get('duration', 0.4)
|
||||
),
|
||||
"transition duration",
|
||||
allow_zero=transition is None,
|
||||
)
|
||||
auto_advance = (
|
||||
args.auto_advance
|
||||
if args.auto_advance is not None
|
||||
else transition_defaults.get('auto_advance')
|
||||
)
|
||||
if auto_advance is not None:
|
||||
auto_advance = validate_seconds(
|
||||
auto_advance,
|
||||
"transition auto_advance",
|
||||
allow_zero=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
animation_arg = args.animation
|
||||
animation_effect = (
|
||||
animation_arg
|
||||
if animation_arg is not None
|
||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
)
|
||||
animation = None if animation_effect == 'none' else animation_effect
|
||||
animation_duration = (
|
||||
args.animation_duration
|
||||
if args.animation_duration is not None
|
||||
else float(animation_defaults.get('duration', 0.4))
|
||||
)
|
||||
animation_stagger = (
|
||||
args.animation_stagger
|
||||
if args.animation_stagger is not None
|
||||
else float(animation_defaults.get('stagger', 0.5))
|
||||
)
|
||||
animation_trigger = (
|
||||
args.animation_trigger
|
||||
if args.animation_trigger is not None
|
||||
else animation_defaults.get('trigger', 'after-previous')
|
||||
)
|
||||
try:
|
||||
animation_effect = (
|
||||
args.animation
|
||||
if args.animation is not None
|
||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
)
|
||||
animation = normalize_animation_effect(animation_effect)
|
||||
animation_duration = validate_seconds(
|
||||
(
|
||||
args.animation_duration
|
||||
if args.animation_duration is not None
|
||||
else animation_defaults.get('duration', 0.4)
|
||||
),
|
||||
"animation duration",
|
||||
allow_zero=False,
|
||||
)
|
||||
animation_seconds_to_milliseconds(
|
||||
animation_duration,
|
||||
"animation duration",
|
||||
allow_zero=False,
|
||||
)
|
||||
animation_stagger = validate_seconds(
|
||||
(
|
||||
args.animation_stagger
|
||||
if args.animation_stagger is not None
|
||||
else animation_defaults.get('stagger', 0.5)
|
||||
),
|
||||
"animation stagger",
|
||||
allow_zero=True,
|
||||
)
|
||||
animation_seconds_to_milliseconds(
|
||||
animation_stagger,
|
||||
"animation stagger",
|
||||
allow_zero=True,
|
||||
)
|
||||
animation_trigger = normalize_animation_trigger(
|
||||
args.animation_trigger
|
||||
if args.animation_trigger is not None
|
||||
else animation_defaults.get('trigger', 'after-previous')
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
animation_cli_overrides = {
|
||||
'transition': args.transition is not None,
|
||||
@@ -679,7 +685,7 @@ Recorded narration:
|
||||
'animation_trigger': args.animation_trigger is not None,
|
||||
}
|
||||
|
||||
if args.recorded_narration and gen_native:
|
||||
if args.recorded_narration:
|
||||
on_click_slides = _recorded_narration_on_click_slides(
|
||||
ref_files,
|
||||
animation_config,
|
||||
@@ -699,17 +705,6 @@ Recorded narration:
|
||||
print(f" ... and {len(on_click_slides) - 20} more", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.no_cache:
|
||||
cache_dir: Path | None = None
|
||||
elif args.cache_dir:
|
||||
cache_dir = Path(args.cache_dir)
|
||||
if not cache_dir.is_absolute():
|
||||
cache_dir = project_path / cache_dir
|
||||
else:
|
||||
cache_dir = project_path / '.cache' / 'svg_png'
|
||||
|
||||
# svg_files is per-product (native vs legacy may now read different
|
||||
# directories); everything else is shared.
|
||||
# Optional per-project document properties. Absent file → factual fields
|
||||
# are still stamped at export; only the authored fields stay blank.
|
||||
doc_metadata = None
|
||||
@@ -733,8 +728,7 @@ Recorded narration:
|
||||
verbose=verbose,
|
||||
transition=transition,
|
||||
transition_duration=transition_duration,
|
||||
auto_advance=args.auto_advance,
|
||||
use_compat_mode=not args.no_compat,
|
||||
auto_advance=auto_advance,
|
||||
notes=notes,
|
||||
enable_notes=enable_notes,
|
||||
animation=animation,
|
||||
@@ -746,8 +740,6 @@ Recorded narration:
|
||||
narration_audio=narration_audio,
|
||||
use_narration_timings=use_narration_timings,
|
||||
narration_padding=args.narration_padding,
|
||||
cache_dir=cache_dir,
|
||||
workers=args.workers,
|
||||
merge_paragraphs=args.merge_paragraphs,
|
||||
image_optimize=not args.no_image_optimize,
|
||||
image_max_dimension=args.image_max_dimension,
|
||||
@@ -758,61 +750,36 @@ Recorded narration:
|
||||
pptx_structure=pptx_structure,
|
||||
native_structure_contract=native_structure_contract,
|
||||
theme_font_spec=theme_font_spec,
|
||||
master_text_style_spec=master_text_style_spec,
|
||||
theme_color_spec=theme_color_spec,
|
||||
)
|
||||
|
||||
success = True
|
||||
if verbose:
|
||||
print("PPT Master - SVG to native DrawingML PPTX Tool")
|
||||
print("=" * 50)
|
||||
print(f" Project path: {project_path}")
|
||||
print(f" SVG directory: {native_source_dir}")
|
||||
print(f" Output file: {native_path}")
|
||||
print()
|
||||
|
||||
# --- Native shapes version (primary) ---
|
||||
if gen_native:
|
||||
if verbose:
|
||||
print("PPT Master - SVG to PPTX Tool")
|
||||
print("=" * 50)
|
||||
print(f" Project path: {project_path}")
|
||||
print(f" SVG directory: {native_source_dir}")
|
||||
print(f" Output file: {native_path}")
|
||||
print()
|
||||
|
||||
try:
|
||||
ok = create_pptx_with_native_svg(
|
||||
output_path=native_path,
|
||||
use_native_shapes=True,
|
||||
svg_files=native_files,
|
||||
conversion_trace_path=(
|
||||
native_path.with_name(native_path.name + '.trace.json')
|
||||
if args.conversion_trace else None
|
||||
),
|
||||
**shared_kwargs,
|
||||
)
|
||||
except TemplateStructureError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
success = success and ok
|
||||
|
||||
# --- SVG image reference version ---
|
||||
if gen_legacy:
|
||||
if verbose:
|
||||
if gen_native:
|
||||
print()
|
||||
print("-" * 50)
|
||||
print("PPT Master - SVG to PPTX Tool (SVG Reference)")
|
||||
print("=" * 50)
|
||||
print(f" Project path: {project_path}")
|
||||
print(f" SVG directory: {legacy_source_dir}")
|
||||
print(f" Output file: {legacy_path}")
|
||||
print()
|
||||
|
||||
ok = create_pptx_with_native_svg(
|
||||
output_path=legacy_path,
|
||||
use_native_shapes=False,
|
||||
svg_files=legacy_files,
|
||||
try:
|
||||
success = create_pptx_with_native_svg(
|
||||
output_path=native_path,
|
||||
use_native_shapes=True,
|
||||
svg_files=native_files,
|
||||
conversion_trace_path=(
|
||||
native_path.with_name(native_path.name + '.trace.json')
|
||||
if args.conversion_trace else None
|
||||
),
|
||||
**shared_kwargs,
|
||||
)
|
||||
success = success and ok
|
||||
except (TemplateStructureError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# svg_output/ snapshot — runs once per export in default-flow mode,
|
||||
# decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
|
||||
# under backup/<ts>/svg_output/ for later inspection / re-export.
|
||||
# Archive svg_output/ once per default-flow export. This preserves the
|
||||
# authored SVG sources under backup/<ts>/svg_output/ for inspection and
|
||||
# deterministic re-export.
|
||||
if success and backup_dir is not None:
|
||||
svg_output_src = project_path / "svg_output"
|
||||
if svg_output_src.is_dir():
|
||||
@@ -848,16 +815,6 @@ Recorded narration:
|
||||
if verbose:
|
||||
print(f" [warn] preserve contract backup skipped: {exc}")
|
||||
|
||||
if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
cache_parent = cache_dir.parent
|
||||
if cache_parent.is_dir() and cache_parent.name == '.cache' and not any(cache_parent.iterdir()):
|
||||
cache_parent.rmdir()
|
||||
except Exception as exc:
|
||||
if verbose:
|
||||
print(f" [warn] cache cleanup skipped: {exc}")
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ def find_svg_files(
|
||||
project_path: Project directory path.
|
||||
source: SVG source directory alias or name.
|
||||
- 'output': svg_output (hand-authored source; native default)
|
||||
- 'final': svg_final (post-processed; legacy SVG-image path source)
|
||||
- 'final': svg_final (post-processed preview; diagnostic input)
|
||||
- or any subdirectory name
|
||||
|
||||
Returns:
|
||||
|
||||
+352
-93
@@ -6,8 +6,25 @@ import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_transitions import (
|
||||
AdvanceUpdate,
|
||||
EnterUpdate,
|
||||
MAX_OOXML_UNSIGNED_INT,
|
||||
P14_NS,
|
||||
PML_NS,
|
||||
apply_slide_motion_xml,
|
||||
parse_source_xml,
|
||||
read_slide_transition_xml,
|
||||
serialize_source_xml,
|
||||
)
|
||||
|
||||
|
||||
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
|
||||
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
|
||||
@@ -36,6 +53,22 @@ AUDIO_MARKER_PNG_BYTES = base64.b64decode(
|
||||
)
|
||||
|
||||
|
||||
for _prefix, _uri in (
|
||||
("p", PML_NS),
|
||||
("a", DRAWINGML_NS),
|
||||
("r", RELATIONSHIPS_NS),
|
||||
("p14", P14_NS),
|
||||
):
|
||||
try:
|
||||
ET.register_namespace(_prefix, _uri)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _qn(namespace: str, tag: str) -> str:
|
||||
return f"{{{namespace}}}{tag}"
|
||||
|
||||
|
||||
def _normalize_title(title: str) -> str:
|
||||
text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
|
||||
return re.sub(r"_+", "_", text).strip("_").lower()
|
||||
@@ -102,8 +135,94 @@ def probe_audio_duration(audio_path: Path) -> float | None:
|
||||
|
||||
|
||||
def next_shape_id(slide_xml: str) -> int:
|
||||
ids = [int(value) for value in re.findall(r'<p:cNvPr[^>]*\sid="(\d+)"', slide_xml)]
|
||||
return max(ids, default=1) + 1
|
||||
"""Return the next slide-local non-visual shape id."""
|
||||
root = parse_source_xml(slide_xml)
|
||||
if root.tag != _qn(PML_NS, "sld"):
|
||||
raise ValueError("narration source XML root must be p:sld")
|
||||
ids = _numeric_ids(
|
||||
root.iter(_qn(PML_NS, "cNvPr")),
|
||||
"shape",
|
||||
minimum=1,
|
||||
)
|
||||
next_id = max(ids, default=1) + 1
|
||||
if next_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError("narration source has no available shape identifiers")
|
||||
return next_id
|
||||
|
||||
|
||||
def _create_audio_pic_element(
|
||||
shape_id: int,
|
||||
shape_name: str,
|
||||
audio_rid: str,
|
||||
media_rid: str,
|
||||
poster_rid: str,
|
||||
) -> ET.Element:
|
||||
pic = ET.Element(_qn(PML_NS, "pic"))
|
||||
nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
|
||||
c_nv_pr = ET.SubElement(
|
||||
nv_pic_pr,
|
||||
_qn(PML_NS, "cNvPr"),
|
||||
{"id": str(shape_id), "name": shape_name},
|
||||
)
|
||||
ET.SubElement(
|
||||
c_nv_pr,
|
||||
_qn(DRAWINGML_NS, "hlinkClick"),
|
||||
{
|
||||
_qn(RELATIONSHIPS_NS, "id"): "",
|
||||
"action": "ppaction://media",
|
||||
},
|
||||
)
|
||||
c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
|
||||
ET.SubElement(
|
||||
c_nv_pic_pr,
|
||||
_qn(DRAWINGML_NS, "picLocks"),
|
||||
{"noChangeAspect": "1"},
|
||||
)
|
||||
nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
|
||||
ET.SubElement(
|
||||
nv_pr,
|
||||
_qn(DRAWINGML_NS, "audioFile"),
|
||||
{_qn(RELATIONSHIPS_NS, "link"): audio_rid},
|
||||
)
|
||||
ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
|
||||
extension = ET.SubElement(
|
||||
ext_list,
|
||||
_qn(PML_NS, "ext"),
|
||||
{"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
|
||||
)
|
||||
ET.SubElement(
|
||||
extension,
|
||||
_qn(P14_NS, "media"),
|
||||
{_qn(RELATIONSHIPS_NS, "embed"): media_rid},
|
||||
)
|
||||
|
||||
blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
|
||||
ET.SubElement(
|
||||
blip_fill,
|
||||
_qn(DRAWINGML_NS, "blip"),
|
||||
{_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
|
||||
)
|
||||
stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
|
||||
ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
|
||||
|
||||
shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
|
||||
transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
|
||||
ET.SubElement(transform, _qn(DRAWINGML_NS, "off"), {"x": "0", "y": "0"})
|
||||
ET.SubElement(
|
||||
transform,
|
||||
_qn(DRAWINGML_NS, "ext"),
|
||||
{
|
||||
"cx": str(AUDIO_MARKER_SIZE_EMU),
|
||||
"cy": str(AUDIO_MARKER_SIZE_EMU),
|
||||
},
|
||||
)
|
||||
geometry = ET.SubElement(
|
||||
shape_properties,
|
||||
_qn(DRAWINGML_NS, "prstGeom"),
|
||||
{"prst": "rect"},
|
||||
)
|
||||
ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
|
||||
return pic
|
||||
|
||||
|
||||
def create_audio_pic_xml(
|
||||
@@ -114,51 +233,158 @@ def create_audio_pic_xml(
|
||||
poster_rid: str,
|
||||
) -> str:
|
||||
"""Create a visible audio picture shape carrying narration media."""
|
||||
return f'''<p:pic>
|
||||
<p:nvPicPr>
|
||||
<p:cNvPr id="{shape_id}" name="{shape_name}">
|
||||
<a:hlinkClick r:id="" action="ppaction://media"/>
|
||||
</p:cNvPr>
|
||||
<p:cNvPicPr>
|
||||
<a:picLocks noChangeAspect="1"/>
|
||||
</p:cNvPicPr>
|
||||
<p:nvPr>
|
||||
<a:audioFile r:link="{audio_rid}"/>
|
||||
<p:extLst>
|
||||
<p:ext uri="{{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}}">
|
||||
<p14:media xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" r:embed="{media_rid}"/>
|
||||
</p:ext>
|
||||
</p:extLst>
|
||||
</p:nvPr>
|
||||
</p:nvPicPr>
|
||||
<p:blipFill>
|
||||
<a:blip r:embed="{poster_rid}"/>
|
||||
<a:stretch><a:fillRect/></a:stretch>
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="{AUDIO_MARKER_SIZE_EMU}" cy="{AUDIO_MARKER_SIZE_EMU}"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
</p:pic>'''
|
||||
element = _create_audio_pic_element(
|
||||
shape_id,
|
||||
shape_name,
|
||||
audio_rid,
|
||||
media_rid,
|
||||
poster_rid,
|
||||
)
|
||||
return ET.tostring(element, encoding="unicode")
|
||||
|
||||
|
||||
def _next_timing_id(slide_xml: str) -> int:
|
||||
ids = [int(value) for value in re.findall(r'<p:cTn[^>]*\sid="(\d+)"', slide_xml)]
|
||||
return max(ids, default=1) + 1
|
||||
def _numeric_ids(
|
||||
elements: Iterable[ET.Element],
|
||||
label: str,
|
||||
*,
|
||||
minimum: int = 0,
|
||||
maximum: int = MAX_OOXML_UNSIGNED_INT,
|
||||
) -> list[int]:
|
||||
ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for element in elements:
|
||||
raw_id = element.get("id")
|
||||
try:
|
||||
numeric_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
|
||||
if numeric_id < minimum:
|
||||
raise ValueError(
|
||||
f"narration source has {label} id below {minimum}: {numeric_id}"
|
||||
)
|
||||
if numeric_id > maximum:
|
||||
raise ValueError(
|
||||
f"narration source has {label} id above {maximum}: {numeric_id}"
|
||||
)
|
||||
if numeric_id in seen:
|
||||
raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
|
||||
ids.append(numeric_id)
|
||||
seen.add(numeric_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _create_audio_timing_xml(shape_id: int, ctn_id: int) -> str:
|
||||
return f'''<p:audio>
|
||||
<p:cMediaNode vol="80000">
|
||||
<p:cTn id="{ctn_id}" fill="hold" display="0">
|
||||
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
||||
</p:cTn>
|
||||
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
|
||||
</p:cMediaNode>
|
||||
</p:audio>'''
|
||||
def _create_audio_timing_element(shape_id: int, ctn_id: int) -> ET.Element:
|
||||
audio = ET.Element(_qn(PML_NS, "audio"))
|
||||
media_node = ET.SubElement(
|
||||
audio,
|
||||
_qn(PML_NS, "cMediaNode"),
|
||||
{"vol": "80000"},
|
||||
)
|
||||
time_node = ET.SubElement(
|
||||
media_node,
|
||||
_qn(PML_NS, "cTn"),
|
||||
{"id": str(ctn_id), "fill": "hold", "display": "0"},
|
||||
)
|
||||
start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
|
||||
ET.SubElement(start_conditions, _qn(PML_NS, "cond"), {"delay": "0"})
|
||||
target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
|
||||
ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
|
||||
return audio
|
||||
|
||||
|
||||
def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
|
||||
children = [child for child in parent if child.tag == tag]
|
||||
if len(children) != 1:
|
||||
raise ValueError(
|
||||
f"narration source must contain exactly one direct {label}; found {len(children)}"
|
||||
)
|
||||
return children[0]
|
||||
|
||||
|
||||
def _existing_timing_root(timing: ET.Element) -> ET.Element:
|
||||
children = list(timing)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "tnLst"), "p:tnLst"),
|
||||
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
||||
(_qn(PML_NS, "extLst"), "p:extLst"),
|
||||
):
|
||||
if sum(child.tag == tag for child in children) > 1:
|
||||
raise ValueError(f"narration source timing has multiple {label} elements")
|
||||
node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
|
||||
node_index = children.index(node_list)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
||||
(_qn(PML_NS, "extLst"), "p:extLst"),
|
||||
):
|
||||
sibling = next((child for child in children if child.tag == tag), None)
|
||||
if sibling is not None and node_index > children.index(sibling):
|
||||
raise ValueError(f"narration source p:tnLst must precede {label}")
|
||||
timing_roots = [
|
||||
element
|
||||
for element in node_list.iter(_qn(PML_NS, "cTn"))
|
||||
if element.get("nodeType") == "tmRoot"
|
||||
]
|
||||
if len(timing_roots) != 1:
|
||||
raise ValueError(
|
||||
"narration source timing must contain exactly one tmRoot; "
|
||||
f"found {len(timing_roots)}"
|
||||
)
|
||||
return timing_roots[0]
|
||||
|
||||
|
||||
def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
|
||||
timing = ET.Element(_qn(PML_NS, "timing"))
|
||||
node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
|
||||
parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
|
||||
timing_root = ET.SubElement(
|
||||
parallel,
|
||||
_qn(PML_NS, "cTn"),
|
||||
{
|
||||
"id": str(root_id),
|
||||
"dur": "indefinite",
|
||||
"restart": "never",
|
||||
"nodeType": "tmRoot",
|
||||
},
|
||||
)
|
||||
child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
|
||||
child_nodes.append(audio_timing)
|
||||
return timing
|
||||
|
||||
|
||||
def _root_extension_index(slide: ET.Element) -> int | None:
|
||||
extension_lists = [
|
||||
index
|
||||
for index, child in enumerate(slide)
|
||||
if child.tag == _qn(PML_NS, "extLst")
|
||||
]
|
||||
if len(extension_lists) > 1:
|
||||
raise ValueError("narration source has multiple root p:extLst elements")
|
||||
if extension_lists and extension_lists[0] != len(slide) - 1:
|
||||
raise ValueError("narration source root p:extLst is not the last slide child")
|
||||
return extension_lists[0] if extension_lists else None
|
||||
|
||||
|
||||
def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
|
||||
children = list(slide)
|
||||
timing_index = children.index(timing)
|
||||
for tag, label in (
|
||||
(_qn(PML_NS, "cSld"), "p:cSld"),
|
||||
(_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
|
||||
):
|
||||
siblings = [index for index, child in enumerate(children) if child.tag == tag]
|
||||
if len(siblings) > 1:
|
||||
raise ValueError(f"narration source has multiple root {label} elements")
|
||||
if siblings and siblings[0] > timing_index:
|
||||
raise ValueError(f"narration source root p:timing must follow {label}")
|
||||
extension_index = _root_extension_index(slide)
|
||||
if extension_index is not None and timing_index > extension_index:
|
||||
raise ValueError("narration source root p:timing must precede p:extLst")
|
||||
|
||||
|
||||
def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
|
||||
extension_index = _root_extension_index(slide)
|
||||
insert_at = extension_index if extension_index is not None else len(slide)
|
||||
slide.insert(insert_at, timing)
|
||||
|
||||
|
||||
def inject_narration(
|
||||
@@ -171,34 +397,75 @@ def inject_narration(
|
||||
poster_rid: str,
|
||||
) -> str:
|
||||
"""Inject a hidden narration media shape and slide-entry autoplay timing."""
|
||||
audio_pic_xml = create_audio_pic_xml(
|
||||
shape_id=shape_id,
|
||||
shape_name=shape_name,
|
||||
audio_rid=audio_rid,
|
||||
media_rid=media_rid,
|
||||
poster_rid=poster_rid,
|
||||
if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
|
||||
raise ValueError("narration shape_id must be a positive integer")
|
||||
if shape_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration shape_id exceeds the OOXML unsigned-integer limit: "
|
||||
f"{shape_id}"
|
||||
)
|
||||
|
||||
root = parse_source_xml(slide_xml)
|
||||
if root.tag != _qn(PML_NS, "sld"):
|
||||
raise ValueError("narration source XML root must be p:sld")
|
||||
common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
|
||||
shape_tree = _direct_child(
|
||||
common_slide_data,
|
||||
_qn(PML_NS, "spTree"),
|
||||
"p:cSld/p:spTree",
|
||||
)
|
||||
slide_xml = slide_xml.replace("</p:spTree>", audio_pic_xml + "\n </p:spTree>", 1)
|
||||
|
||||
audio_timing_xml = _create_audio_timing_xml(shape_id, _next_timing_id(slide_xml))
|
||||
if "<p:timing>" not in slide_xml:
|
||||
timing_xml = f''' <p:timing>
|
||||
<p:tnLst>
|
||||
<p:par>
|
||||
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
|
||||
<p:childTnLst>
|
||||
{audio_timing_xml}
|
||||
</p:childTnLst>
|
||||
</p:cTn>
|
||||
</p:par>
|
||||
</p:tnLst>
|
||||
</p:timing>'''
|
||||
return slide_xml.replace("</p:sld>", timing_xml + "\n</p:sld>", 1)
|
||||
shape_ids = _numeric_ids(
|
||||
root.iter(_qn(PML_NS, "cNvPr")),
|
||||
"shape",
|
||||
minimum=1,
|
||||
)
|
||||
if shape_id in shape_ids:
|
||||
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
|
||||
timing_ids = _numeric_ids(root.iter(_qn(PML_NS, "cTn")), "timing node")
|
||||
next_timing_id = max(timing_ids, default=0) + 1
|
||||
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError("narration source has no available timing node identifiers")
|
||||
|
||||
pattern = re.compile(r'(<p:cTn\s+id="1"[^>]*>\s*<p:childTnLst>)', re.S)
|
||||
if pattern.search(slide_xml):
|
||||
return pattern.sub(r"\1\n " + audio_timing_xml, slide_xml, count=1)
|
||||
return slide_xml.replace("</p:tnLst>", audio_timing_xml + "\n </p:tnLst>", 1)
|
||||
root_timings = [child for child in root if child.tag == _qn(PML_NS, "timing")]
|
||||
all_timings = list(root.iter(_qn(PML_NS, "timing")))
|
||||
if len(all_timings) != len(root_timings):
|
||||
raise ValueError(
|
||||
"narration source contains non-root p:timing; only direct p:sld/p:timing "
|
||||
"can be merged safely"
|
||||
)
|
||||
if len(root_timings) > 1:
|
||||
raise ValueError(
|
||||
f"narration source has multiple root p:timing elements: {len(root_timings)}"
|
||||
)
|
||||
if not root_timings and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
|
||||
raise ValueError(
|
||||
"narration source has no identifiers available for a new timing root"
|
||||
)
|
||||
|
||||
audio_picture = _create_audio_pic_element(
|
||||
shape_id,
|
||||
shape_name,
|
||||
audio_rid,
|
||||
media_rid,
|
||||
poster_rid,
|
||||
)
|
||||
shape_tree.append(audio_picture)
|
||||
|
||||
if root_timings:
|
||||
_validate_root_timing_position(root, root_timings[0])
|
||||
timing_root = _existing_timing_root(root_timings[0])
|
||||
child_nodes = _direct_child(
|
||||
timing_root,
|
||||
_qn(PML_NS, "childTnLst"),
|
||||
"tmRoot/p:childTnLst",
|
||||
)
|
||||
child_nodes.append(_create_audio_timing_element(shape_id, next_timing_id))
|
||||
else:
|
||||
audio_timing = _create_audio_timing_element(shape_id, next_timing_id + 1)
|
||||
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
|
||||
|
||||
return serialize_source_xml(root, slide_xml).decode("utf-8")
|
||||
|
||||
|
||||
def apply_recorded_timing(
|
||||
@@ -209,28 +476,20 @@ def apply_recorded_timing(
|
||||
transition_effect: str | None = "fade",
|
||||
) -> str:
|
||||
"""Set slide auto-advance timing so exported video follows narration length."""
|
||||
adv_ms = max(1, int(advance_after * 1000))
|
||||
dur_ms = max(1, int(transition_duration * 1000))
|
||||
|
||||
transition_match = re.search(r"<p:transition\b[^>]*>", slide_xml)
|
||||
if transition_match:
|
||||
tag = transition_match.group(0)
|
||||
is_self_closing = tag.rstrip().endswith("/>")
|
||||
base_tag = tag.rstrip()
|
||||
if is_self_closing:
|
||||
base_tag = re.sub(r"\s*/>$", ">", base_tag, count=1)
|
||||
if "advTm=" in base_tag:
|
||||
new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', base_tag, count=1)
|
||||
else:
|
||||
new_tag = base_tag[:-1] + f' advTm="{adv_ms}">'
|
||||
if is_self_closing:
|
||||
new_tag = new_tag[:-1] + "/>"
|
||||
return slide_xml[:transition_match.start()] + new_tag + slide_xml[transition_match.end():]
|
||||
|
||||
effect = transition_effect or "fade"
|
||||
transition_xml = f''' <p:transition p14:dur="{dur_ms}" xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" advTm="{adv_ms}">
|
||||
<p:{effect}/>
|
||||
</p:transition>'''
|
||||
if "<p:timing>" in slide_xml:
|
||||
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
|
||||
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)
|
||||
summary = read_slide_transition_xml(slide_xml)
|
||||
if summary.logical_count:
|
||||
enter = EnterUpdate(policy="preserve")
|
||||
elif transition_effect is None or transition_effect == "none":
|
||||
enter = EnterUpdate(policy="none")
|
||||
else:
|
||||
enter = EnterUpdate(
|
||||
policy="replace",
|
||||
effect=transition_effect,
|
||||
duration=transition_duration,
|
||||
)
|
||||
updated, _uses_timings = apply_slide_motion_xml(
|
||||
slide_xml,
|
||||
enter=enter,
|
||||
advance=AdvanceUpdate(mode="narration", after=advance_after),
|
||||
)
|
||||
return updated
|
||||
|
||||
+5
-9
@@ -2,13 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Import animation module (optional)
|
||||
try:
|
||||
from pptx_animations import create_transition_xml, TRANSITIONS
|
||||
ANIMATIONS_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANIMATIONS_AVAILABLE = False
|
||||
TRANSITIONS = {}
|
||||
from pptx_transitions import TRANSITIONS, create_transition_xml
|
||||
|
||||
|
||||
def create_slide_xml_with_svg(
|
||||
@@ -36,12 +30,14 @@ def create_slide_xml_with_svg(
|
||||
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
|
||||
"""
|
||||
transition_xml = ''
|
||||
if transition and ANIMATIONS_AVAILABLE:
|
||||
transition_xml = '\n' + create_transition_xml(
|
||||
if transition is not None or auto_advance is not None:
|
||||
transition_fragment = create_transition_xml(
|
||||
effect=transition,
|
||||
duration=transition_duration,
|
||||
advance_after=auto_advance,
|
||||
)
|
||||
if transition_fragment:
|
||||
transition_xml = '\n' + transition_fragment
|
||||
|
||||
if use_compat_mode:
|
||||
blip_xml = f'''<a:blip r:embed="{png_rid}">
|
||||
|
||||
+95
-1
@@ -26,6 +26,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..geometry_properties import (
|
||||
GeometryStyleError,
|
||||
materialize_inline_geometry_properties,
|
||||
)
|
||||
|
||||
|
||||
_NON_VISUAL_TAGS = frozenset({"defs", "title", "desc", "metadata", "style"})
|
||||
_STRUCTURE_ATTRS = frozenset({
|
||||
@@ -51,6 +56,19 @@ _PLACEHOLDERS = frozenset({
|
||||
"footer",
|
||||
"slide-number",
|
||||
})
|
||||
TEMPLATE_PLACEHOLDER_TYPES = {
|
||||
"title": "title",
|
||||
"subtitle": "subTitle",
|
||||
"body": "body",
|
||||
"picture": "pic",
|
||||
"chart": "chart",
|
||||
"table": "tbl",
|
||||
"object": "obj",
|
||||
"media": "media",
|
||||
"date": "dt",
|
||||
"footer": "ftr",
|
||||
"slide-number": "sldNum",
|
||||
}
|
||||
_TEXT_PLACEHOLDERS = frozenset({
|
||||
"title",
|
||||
"subtitle",
|
||||
@@ -76,6 +94,7 @@ _LOCK_PAGE_RE = re.compile(r"^P(\d+)$")
|
||||
PPTX_STRUCTURE_MODES = frozenset({"baseline", "template", "preserve", "flat"})
|
||||
TEMPLATE_ADHERENCE_MODES = frozenset({"strict", "adaptive"})
|
||||
NATIVE_STRUCTURE_SCHEMA = "ppt-master.native-structure.v1"
|
||||
OOXML_UINT32_MAX = (1 << 32) - 1
|
||||
|
||||
|
||||
class TemplateStructureError(RuntimeError):
|
||||
@@ -199,6 +218,67 @@ class TemplateSlideSpec:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplatePlaceholderBinding:
|
||||
"""Resolved PowerPoint identity for one template placeholder."""
|
||||
|
||||
element: TemplateElementSpec
|
||||
placeholder_type: str
|
||||
assigned_idx: int | None
|
||||
|
||||
@property
|
||||
def effective_idx(self) -> int:
|
||||
"""Return the OOXML idx value after applying its default of zero."""
|
||||
return self.assigned_idx if self.assigned_idx is not None else 0
|
||||
|
||||
|
||||
def template_placeholder_bindings(
|
||||
spec: TemplateSlideSpec,
|
||||
) -> tuple[TemplatePlaceholderBinding, ...]:
|
||||
"""Assign deterministic, collision-free PowerPoint placeholder identities."""
|
||||
next_idx = 1
|
||||
used_indices: dict[int, str] = {}
|
||||
bindings: list[TemplatePlaceholderBinding] = []
|
||||
for item in spec.placeholders:
|
||||
placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
|
||||
if placeholder_type is None:
|
||||
raise TemplateStructureError(
|
||||
f"{spec.svg_path.name}: unsupported placeholder type "
|
||||
f"{item.placeholder!r}"
|
||||
)
|
||||
if item.placeholder == "title" and item.placeholder_idx is None:
|
||||
assigned_idx = None
|
||||
else:
|
||||
assigned_idx = (
|
||||
item.placeholder_idx
|
||||
if item.placeholder_idx is not None
|
||||
else next_idx
|
||||
)
|
||||
effective_idx = assigned_idx if assigned_idx is not None else 0
|
||||
if effective_idx > OOXML_UINT32_MAX:
|
||||
raise TemplateStructureError(
|
||||
f"{spec.svg_path.name}: layout {spec.layout_key!r} placeholder "
|
||||
f"{item.element_id!r} idx exceeds the OOXML UInt32 maximum "
|
||||
f"{OOXML_UINT32_MAX}"
|
||||
)
|
||||
previous = used_indices.get(effective_idx)
|
||||
if previous is not None:
|
||||
raise TemplateStructureError(
|
||||
f"{spec.svg_path.name}: layout {spec.layout_key!r} gives "
|
||||
f"placeholders {previous!r} and {item.element_id!r} the same "
|
||||
f"effective idx {effective_idx}; omitted idx defaults to 0 in OOXML"
|
||||
)
|
||||
used_indices[effective_idx] = item.element_id
|
||||
if assigned_idx is not None:
|
||||
next_idx = max(next_idx, assigned_idx + 1)
|
||||
bindings.append(TemplatePlaceholderBinding(
|
||||
element=item,
|
||||
placeholder_type=placeholder_type,
|
||||
assigned_idx=assigned_idx,
|
||||
))
|
||||
return tuple(bindings)
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
return elem.tag.rsplit("}", 1)[-1] if isinstance(elem.tag, str) else ""
|
||||
|
||||
@@ -656,7 +736,13 @@ def _parse_placeholder_idx(
|
||||
f"{svg_path.name}: {element_id} data-pptx-placeholder-idx must be "
|
||||
"a non-negative integer"
|
||||
)
|
||||
return int(value)
|
||||
parsed = int(value)
|
||||
if parsed > OOXML_UINT32_MAX:
|
||||
raise TemplateStructureError(
|
||||
f"{svg_path.name}: {element_id} data-pptx-placeholder-idx must be "
|
||||
f"at most {OOXML_UINT32_MAX}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_placeholder_element(
|
||||
@@ -705,6 +791,13 @@ def parse_template_slide(svg_path: Path, slide_num: int) -> TemplateSlideSpec:
|
||||
f"{svg_path.name}: unable to parse SVG structure metadata: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
materialize_inline_geometry_properties(root)
|
||||
except GeometryStyleError as exc:
|
||||
raise TemplateStructureError(
|
||||
f"{svg_path.name}: invalid inline geometry: {exc}"
|
||||
) from exc
|
||||
|
||||
if _local_tag(root) != "svg":
|
||||
raise TemplateStructureError(f"{svg_path.name}: root element must be <svg>")
|
||||
|
||||
@@ -933,6 +1026,7 @@ def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]:
|
||||
by_layout.setdefault(spec.layout_key, []).append(spec)
|
||||
for layout_key, layout_specs in by_layout.items():
|
||||
prototype = layout_specs[0]
|
||||
template_placeholder_bindings(prototype)
|
||||
for spec in layout_specs[1:]:
|
||||
if spec.layout_name != prototype.layout_name:
|
||||
raise TemplateStructureError(
|
||||
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Template Package Validation
|
||||
|
||||
Read a generated template-mode PPTX back and verify its reusable Layout graph.
|
||||
|
||||
Usage:
|
||||
Imported by svg_to_pptx.pptx_package.builder.
|
||||
|
||||
Examples:
|
||||
validate_pptx_template_package(Path("output.pptx"), template_specs)
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library and local modules)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import sys
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
if __name__ == "__main__":
|
||||
if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]):
|
||||
print(__doc__)
|
||||
raise SystemExit(0)
|
||||
print(
|
||||
"Use this validator through the template-mode SVG-to-PPTX exporter.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
from ..drawingml.utils import EMU_PER_PX
|
||||
from .template_structure import (
|
||||
OOXML_UINT32_MAX,
|
||||
TemplatePlaceholderBinding,
|
||||
TemplateSlideSpec,
|
||||
TemplateStructureError,
|
||||
template_placeholder_bindings,
|
||||
)
|
||||
|
||||
|
||||
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
SLIDE_LAYOUT_REL_TYPE = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
|
||||
)
|
||||
SLIDE_MASTER_REL_TYPE = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
|
||||
)
|
||||
SLIDE_LAYOUT_CONTENT_TYPE = (
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
|
||||
)
|
||||
PRESENTATION_COLLECTION_ID_MIN = 1 << 31
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Relationship:
|
||||
rel_id: str
|
||||
rel_type: str
|
||||
target: str
|
||||
target_mode: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Placeholder:
|
||||
shape: ET.Element
|
||||
placeholder_type: str
|
||||
raw_type: str | None
|
||||
idx: int
|
||||
raw_idx: int | None
|
||||
|
||||
|
||||
class _PackageReader:
|
||||
"""Cache package XML while accumulating deterministic read-back errors."""
|
||||
|
||||
def __init__(self, package: zipfile.ZipFile, errors: list[str]) -> None:
|
||||
self.package = package
|
||||
self.errors = errors
|
||||
self.names = frozenset(package.namelist())
|
||||
self._xml_cache: dict[str, ET.Element | None] = {}
|
||||
self._rels_cache: dict[str, tuple[_Relationship, ...]] = {}
|
||||
|
||||
def xml(self, part: str) -> ET.Element | None:
|
||||
if part in self._xml_cache:
|
||||
return self._xml_cache[part]
|
||||
if part not in self.names:
|
||||
self.errors.append(f"missing package part {part}")
|
||||
self._xml_cache[part] = None
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(self.package.read(part))
|
||||
except (KeyError, ET.ParseError) as exc:
|
||||
self.errors.append(f"{part} is not valid XML: {exc}")
|
||||
root = None
|
||||
self._xml_cache[part] = root
|
||||
return root
|
||||
|
||||
def relationships(self, source_part: str) -> tuple[_Relationship, ...]:
|
||||
if source_part in self._rels_cache:
|
||||
return self._rels_cache[source_part]
|
||||
rels_part = _relationships_part_for(source_part)
|
||||
root = self.xml(rels_part)
|
||||
relationships: list[_Relationship] = []
|
||||
seen_ids: set[str] = set()
|
||||
if root is not None:
|
||||
for elem in root:
|
||||
attrs = {
|
||||
key.rsplit("}", 1)[-1]: value
|
||||
for key, value in elem.attrib.items()
|
||||
}
|
||||
rel_id = attrs.get("Id", "")
|
||||
rel_type = attrs.get("Type", "")
|
||||
target = attrs.get("Target", "")
|
||||
if not rel_id or not rel_type or not target:
|
||||
self.errors.append(
|
||||
f"{rels_part} contains a relationship without Id, Type, or Target"
|
||||
)
|
||||
continue
|
||||
if rel_id in seen_ids:
|
||||
self.errors.append(f"{rels_part} repeats relationship id {rel_id}")
|
||||
continue
|
||||
seen_ids.add(rel_id)
|
||||
relationships.append(_Relationship(
|
||||
rel_id=rel_id,
|
||||
rel_type=rel_type,
|
||||
target=target,
|
||||
target_mode=attrs.get("TargetMode"),
|
||||
))
|
||||
result = tuple(relationships)
|
||||
self._rels_cache[source_part] = result
|
||||
return result
|
||||
|
||||
|
||||
def _relationships_part_for(source_part: str) -> str:
|
||||
directory = posixpath.dirname(source_part)
|
||||
filename = posixpath.basename(source_part)
|
||||
return posixpath.join(directory, "_rels", f"{filename}.rels")
|
||||
|
||||
|
||||
def _resolve_relationship_target(
|
||||
source_part: str,
|
||||
relationship: _Relationship,
|
||||
errors: list[str],
|
||||
context: str,
|
||||
) -> str | None:
|
||||
target_mode = (relationship.target_mode or "Internal").lower()
|
||||
if target_mode != "internal":
|
||||
errors.append(f"{context} relationship must be internal")
|
||||
return None
|
||||
raw_target = relationship.target
|
||||
if "\\" in raw_target or any(ord(char) <= 0x20 for char in raw_target):
|
||||
errors.append(f"{context} has invalid target {raw_target!r}")
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(raw_target)
|
||||
except ValueError:
|
||||
errors.append(f"{context} has invalid target {raw_target!r}")
|
||||
return None
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
errors.append(f"{context} has invalid target {raw_target!r}")
|
||||
return None
|
||||
decoded = unquote(parsed.path)
|
||||
if not decoded:
|
||||
errors.append(f"{context} has an empty target")
|
||||
return None
|
||||
if decoded.startswith("/"):
|
||||
candidate = decoded[1:]
|
||||
else:
|
||||
candidate = posixpath.join(posixpath.dirname(source_part), decoded)
|
||||
normalized = posixpath.normpath(candidate)
|
||||
if (
|
||||
normalized in {"", ".", ".."}
|
||||
or normalized.startswith("../")
|
||||
or normalized.startswith("/")
|
||||
):
|
||||
errors.append(f"{context} target escapes the package: {raw_target!r}")
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _single_relationship_target(
|
||||
reader: _PackageReader,
|
||||
source_part: str,
|
||||
rel_type: str,
|
||||
context: str,
|
||||
) -> tuple[_Relationship, str] | None:
|
||||
matches = [
|
||||
relationship
|
||||
for relationship in reader.relationships(source_part)
|
||||
if relationship.rel_type == rel_type
|
||||
]
|
||||
if len(matches) != 1:
|
||||
reader.errors.append(
|
||||
f"{context} must have exactly one {rel_type.rsplit('/', 1)[-1]} "
|
||||
f"relationship, found {len(matches)}"
|
||||
)
|
||||
return None
|
||||
target = _resolve_relationship_target(
|
||||
source_part,
|
||||
matches[0],
|
||||
reader.errors,
|
||||
context,
|
||||
)
|
||||
if target is None:
|
||||
return None
|
||||
return matches[0], target
|
||||
|
||||
|
||||
def _placeholder_for_shape(shape: ET.Element) -> ET.Element | None:
|
||||
paths = {
|
||||
f"{{{PML_NS}}}sp": (
|
||||
f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
|
||||
),
|
||||
f"{{{PML_NS}}}pic": (
|
||||
f"{{{PML_NS}}}nvPicPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
|
||||
),
|
||||
f"{{{PML_NS}}}graphicFrame": (
|
||||
f"{{{PML_NS}}}nvGraphicFramePr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
|
||||
),
|
||||
}
|
||||
path = paths.get(shape.tag)
|
||||
return shape.find(path) if path else None
|
||||
|
||||
|
||||
def _read_placeholders(
|
||||
root: ET.Element,
|
||||
context: str,
|
||||
errors: list[str],
|
||||
) -> dict[int, _Placeholder]:
|
||||
sp_tree = root.find(f"{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||||
if sp_tree is None:
|
||||
errors.append(f"{context} has no p:cSld/p:spTree")
|
||||
return {}
|
||||
placeholders: dict[int, _Placeholder] = {}
|
||||
for shape in sp_tree:
|
||||
ph = _placeholder_for_shape(shape)
|
||||
if ph is None:
|
||||
continue
|
||||
raw_idx_value = ph.get("idx")
|
||||
try:
|
||||
raw_idx = int(raw_idx_value) if raw_idx_value is not None else None
|
||||
except ValueError:
|
||||
errors.append(f"{context} contains invalid placeholder idx {raw_idx_value!r}")
|
||||
continue
|
||||
idx = raw_idx if raw_idx is not None else 0
|
||||
if not 0 <= idx <= OOXML_UINT32_MAX:
|
||||
errors.append(
|
||||
f"{context} placeholder idx {idx} is outside the OOXML UInt32 range"
|
||||
)
|
||||
continue
|
||||
if idx in placeholders:
|
||||
errors.append(f"{context} repeats effective placeholder idx {idx}")
|
||||
continue
|
||||
raw_type = ph.get("type")
|
||||
placeholders[idx] = _Placeholder(
|
||||
shape=shape,
|
||||
placeholder_type=raw_type or "obj",
|
||||
raw_type=raw_type,
|
||||
idx=idx,
|
||||
raw_idx=raw_idx,
|
||||
)
|
||||
return placeholders
|
||||
|
||||
|
||||
def _validate_placeholder_roster(
|
||||
root: ET.Element,
|
||||
bindings: tuple[TemplatePlaceholderBinding, ...],
|
||||
context: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
is_layout: bool,
|
||||
) -> dict[int, _Placeholder]:
|
||||
actual = _read_placeholders(root, context, errors)
|
||||
expected = {binding.effective_idx: binding for binding in bindings}
|
||||
if set(actual) != set(expected):
|
||||
errors.append(
|
||||
f"{context} placeholder idx roster is {sorted(actual)}, "
|
||||
f"expected {sorted(expected)}"
|
||||
)
|
||||
for idx in sorted(set(actual).intersection(expected)):
|
||||
placeholder = actual[idx]
|
||||
binding = expected[idx]
|
||||
if placeholder.placeholder_type != binding.placeholder_type:
|
||||
errors.append(
|
||||
f"{context} placeholder idx {idx} has type "
|
||||
f"{placeholder.placeholder_type!r}, expected "
|
||||
f"{binding.placeholder_type!r}"
|
||||
)
|
||||
if binding.assigned_idx is None:
|
||||
if placeholder.raw_idx is not None:
|
||||
errors.append(
|
||||
f"{context} title placeholder must use the omitted idx=0 form"
|
||||
)
|
||||
elif placeholder.raw_idx != binding.assigned_idx:
|
||||
errors.append(
|
||||
f"{context} placeholder idx {idx} must serialize explicit idx "
|
||||
f"{binding.assigned_idx}"
|
||||
)
|
||||
if is_layout and placeholder.raw_type is None:
|
||||
errors.append(
|
||||
f"{context} Layout placeholder idx {idx} must serialize its type"
|
||||
)
|
||||
if (
|
||||
not is_layout
|
||||
and binding.placeholder_type != "obj"
|
||||
and placeholder.raw_type is None
|
||||
):
|
||||
errors.append(
|
||||
f"{context} Slide placeholder idx {idx} must serialize type "
|
||||
f"{binding.placeholder_type!r}"
|
||||
)
|
||||
return actual
|
||||
|
||||
|
||||
def _validate_layout_header_footer(
|
||||
root: ET.Element,
|
||||
bindings: tuple[TemplatePlaceholderBinding, ...],
|
||||
context: str,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
placeholder_roles = {
|
||||
binding.element.placeholder for binding in bindings
|
||||
}
|
||||
expected = {
|
||||
"hdr": False,
|
||||
"dt": "date" in placeholder_roles,
|
||||
"ftr": "footer" in placeholder_roles,
|
||||
"sldNum": "slide-number" in placeholder_roles,
|
||||
}
|
||||
header_footer = root.find(f"{{{PML_NS}}}hf")
|
||||
if header_footer is None:
|
||||
if any(expected.values()):
|
||||
errors.append(
|
||||
f"{context} has date/footer/slide-number placeholders but no p:hf"
|
||||
)
|
||||
return
|
||||
for attr, expected_value in expected.items():
|
||||
# CT_HeaderFooter boolean attributes default to true when omitted.
|
||||
raw_value = header_footer.get(attr, "1").lower()
|
||||
if raw_value not in {"0", "1", "false", "true"}:
|
||||
errors.append(f"{context} p:hf@{attr} is not a valid boolean")
|
||||
continue
|
||||
actual_value = raw_value in {"1", "true"}
|
||||
if actual_value != expected_value:
|
||||
errors.append(
|
||||
f"{context} p:hf@{attr} is {raw_value!r}, expected "
|
||||
f"{'1' if expected_value else '0'}"
|
||||
)
|
||||
|
||||
|
||||
def _shape_bounds(
|
||||
shape: ET.Element,
|
||||
context: str,
|
||||
errors: list[str],
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
if shape.tag == f"{{{PML_NS}}}graphicFrame":
|
||||
xfrm = shape.find(f"{{{PML_NS}}}xfrm")
|
||||
else:
|
||||
xfrm = shape.find(f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm")
|
||||
if xfrm is None:
|
||||
errors.append(f"{context} has no direct placeholder transform")
|
||||
return None
|
||||
off = xfrm.find(f"{{{DML_NS}}}off")
|
||||
ext = xfrm.find(f"{{{DML_NS}}}ext")
|
||||
if off is None or ext is None:
|
||||
errors.append(f"{context} placeholder transform has no a:off/a:ext")
|
||||
return None
|
||||
try:
|
||||
bounds = (
|
||||
int(off.attrib["x"]),
|
||||
int(off.attrib["y"]),
|
||||
int(ext.attrib["cx"]),
|
||||
int(ext.attrib["cy"]),
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
errors.append(f"{context} placeholder transform is invalid")
|
||||
return None
|
||||
if bounds[2] <= 0 or bounds[3] <= 0:
|
||||
errors.append(f"{context} placeholder width/height must be positive")
|
||||
return bounds
|
||||
|
||||
|
||||
def _direct_text_body(shape: ET.Element) -> ET.Element | None:
|
||||
return shape.find(f"{{{PML_NS}}}txBody")
|
||||
|
||||
|
||||
def _first_run_size(shape: ET.Element) -> str | None:
|
||||
text_body = _direct_text_body(shape)
|
||||
if text_body is None:
|
||||
return None
|
||||
run_props = text_body.find(f".//{{{DML_NS}}}rPr")
|
||||
return run_props.get("sz") if run_props is not None else None
|
||||
|
||||
|
||||
def _level_one_default_size(shape: ET.Element) -> str | None:
|
||||
text_body = _direct_text_body(shape)
|
||||
if text_body is None:
|
||||
return None
|
||||
default_props = text_body.find(
|
||||
f"{{{DML_NS}}}lstStyle/"
|
||||
f"{{{DML_NS}}}lvl1pPr/"
|
||||
f"{{{DML_NS}}}defRPr"
|
||||
)
|
||||
return default_props.get("sz") if default_props is not None else None
|
||||
|
||||
|
||||
def _content_type_overrides(
|
||||
reader: _PackageReader,
|
||||
) -> dict[str, list[str]]:
|
||||
root = reader.xml("[Content_Types].xml")
|
||||
overrides: dict[str, list[str]] = {}
|
||||
if root is None:
|
||||
return overrides
|
||||
for elem in root:
|
||||
if elem.tag.rsplit("}", 1)[-1] != "Override":
|
||||
continue
|
||||
part_name = elem.get("PartName", "")
|
||||
content_type = elem.get("ContentType", "")
|
||||
if part_name and content_type:
|
||||
overrides.setdefault(part_name, []).append(content_type)
|
||||
return overrides
|
||||
|
||||
|
||||
def _validate_uint32_id_roster(
|
||||
elements: list[ET.Element],
|
||||
context: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
min_value: int = 0,
|
||||
) -> set[int]:
|
||||
values: set[int] = set()
|
||||
for element in elements:
|
||||
raw_value = element.get("id")
|
||||
try:
|
||||
value = int(raw_value) if raw_value is not None else -1
|
||||
except ValueError:
|
||||
value = -1
|
||||
if not min_value <= value <= OOXML_UINT32_MAX:
|
||||
errors.append(
|
||||
f"{context} contains id {raw_value!r} outside the OOXML range "
|
||||
f"{min_value}..{OOXML_UINT32_MAX}"
|
||||
)
|
||||
continue
|
||||
if value in values:
|
||||
errors.append(f"{context} repeats numeric id {value}")
|
||||
continue
|
||||
values.add(value)
|
||||
return values
|
||||
|
||||
|
||||
def _validate_registered_part_roster(
|
||||
reader: _PackageReader,
|
||||
source_part: str,
|
||||
rel_type: str,
|
||||
entries: list[ET.Element],
|
||||
expected_targets: set[str],
|
||||
context: str,
|
||||
) -> set[int]:
|
||||
relationships = [
|
||||
relationship
|
||||
for relationship in reader.relationships(source_part)
|
||||
if relationship.rel_type == rel_type
|
||||
]
|
||||
targets_by_id: dict[str, str] = {}
|
||||
ids_by_target: dict[str, list[str]] = {}
|
||||
for relationship in relationships:
|
||||
target = _resolve_relationship_target(
|
||||
source_part,
|
||||
relationship,
|
||||
reader.errors,
|
||||
context,
|
||||
)
|
||||
if target is None:
|
||||
continue
|
||||
targets_by_id[relationship.rel_id] = target
|
||||
ids_by_target.setdefault(target, []).append(relationship.rel_id)
|
||||
for target, rel_ids in sorted(ids_by_target.items()):
|
||||
if len(rel_ids) > 1:
|
||||
reader.errors.append(
|
||||
f"{context} targets {target} through multiple relationships: "
|
||||
+ ", ".join(rel_ids)
|
||||
)
|
||||
|
||||
actual_targets = set(targets_by_id.values())
|
||||
if actual_targets != expected_targets:
|
||||
missing = sorted(expected_targets - actual_targets)
|
||||
extra = sorted(actual_targets - expected_targets)
|
||||
reader.errors.append(
|
||||
f"{context} registered target roster differs; missing={missing}, "
|
||||
f"extra={extra}"
|
||||
)
|
||||
|
||||
entry_rel_ids: list[str] = []
|
||||
for entry in entries:
|
||||
rel_id = entry.get(f"{{{REL_NS}}}id")
|
||||
if not rel_id:
|
||||
reader.errors.append(f"{context} contains an entry without r:id")
|
||||
continue
|
||||
entry_rel_ids.append(rel_id)
|
||||
if len(entry_rel_ids) != len(set(entry_rel_ids)):
|
||||
reader.errors.append(f"{context} repeats an entry r:id")
|
||||
if set(entry_rel_ids) != {relationship.rel_id for relationship in relationships}:
|
||||
reader.errors.append(
|
||||
f"{context} entry r:id roster does not match its relationships"
|
||||
)
|
||||
return _validate_uint32_id_roster(
|
||||
entries,
|
||||
context,
|
||||
reader.errors,
|
||||
min_value=PRESENTATION_COLLECTION_ID_MIN,
|
||||
)
|
||||
|
||||
|
||||
def _validate_presentation_master_registration(
|
||||
reader: _PackageReader,
|
||||
master_parts: set[str],
|
||||
) -> None:
|
||||
presentation_part = "ppt/presentation.xml"
|
||||
presentation_root = reader.xml(presentation_part)
|
||||
if presentation_root is None:
|
||||
return
|
||||
master_id_entries = presentation_root.findall(
|
||||
f"{{{PML_NS}}}sldMasterIdLst/{{{PML_NS}}}sldMasterId"
|
||||
)
|
||||
_validate_registered_part_roster(
|
||||
reader,
|
||||
presentation_part,
|
||||
SLIDE_MASTER_REL_TYPE,
|
||||
master_id_entries,
|
||||
master_parts,
|
||||
"Presentation p:sldMasterIdLst",
|
||||
)
|
||||
|
||||
|
||||
def validate_pptx_template_package(
|
||||
pptx_path: str | Path,
|
||||
specs: list[TemplateSlideSpec],
|
||||
) -> None:
|
||||
"""Validate a finished template-mode PPTX against its explicit SVG contract."""
|
||||
if not specs:
|
||||
raise ValueError("template package validation requires at least one slide spec")
|
||||
|
||||
specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
|
||||
bindings_by_layout: dict[str, tuple[TemplatePlaceholderBinding, ...]] = {}
|
||||
try:
|
||||
for spec in specs:
|
||||
specs_by_layout.setdefault(spec.layout_key, []).append(spec)
|
||||
for layout_key, layout_specs in specs_by_layout.items():
|
||||
bindings_by_layout[layout_key] = template_placeholder_bindings(
|
||||
layout_specs[0]
|
||||
)
|
||||
except TemplateStructureError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
errors: list[str] = []
|
||||
path = Path(pptx_path)
|
||||
try:
|
||||
with zipfile.ZipFile(path) as package:
|
||||
reader = _PackageReader(package, errors)
|
||||
overrides = _content_type_overrides(reader)
|
||||
layout_parts_by_key: dict[str, str] = {}
|
||||
keys_by_layout_part: dict[str, str] = {}
|
||||
slide_roots: dict[int, ET.Element] = {}
|
||||
|
||||
for spec in specs:
|
||||
slide_part = f"ppt/slides/slide{spec.slide_num}.xml"
|
||||
slide_root = reader.xml(slide_part)
|
||||
if slide_root is not None:
|
||||
slide_roots[spec.slide_num] = slide_root
|
||||
relationship_target = _single_relationship_target(
|
||||
reader,
|
||||
slide_part,
|
||||
SLIDE_LAYOUT_REL_TYPE,
|
||||
f"Slide {spec.slide_num}",
|
||||
)
|
||||
if relationship_target is None:
|
||||
continue
|
||||
_relationship, layout_part = relationship_target
|
||||
if not (
|
||||
layout_part.startswith("ppt/slideLayouts/")
|
||||
and layout_part.endswith(".xml")
|
||||
):
|
||||
errors.append(
|
||||
f"Slide {spec.slide_num} targets non-Layout part {layout_part}"
|
||||
)
|
||||
continue
|
||||
reader.xml(layout_part)
|
||||
previous_part = layout_parts_by_key.setdefault(
|
||||
spec.layout_key,
|
||||
layout_part,
|
||||
)
|
||||
if previous_part != layout_part:
|
||||
errors.append(
|
||||
f"layout key {spec.layout_key!r} targets both {previous_part} "
|
||||
f"and {layout_part}"
|
||||
)
|
||||
previous_key = keys_by_layout_part.setdefault(
|
||||
layout_part,
|
||||
spec.layout_key,
|
||||
)
|
||||
if previous_key != spec.layout_key:
|
||||
errors.append(
|
||||
f"layout keys {previous_key!r} and {spec.layout_key!r} both "
|
||||
f"target {layout_part}"
|
||||
)
|
||||
|
||||
used_master_parts: set[str] = set()
|
||||
layout_parts_by_master: dict[str, set[str]] = {}
|
||||
for layout_key, layout_specs in specs_by_layout.items():
|
||||
layout_part = layout_parts_by_key.get(layout_key)
|
||||
if layout_part is None:
|
||||
errors.append(f"layout key {layout_key!r} has no resolved Layout part")
|
||||
continue
|
||||
layout_root = reader.xml(layout_part)
|
||||
if layout_root is None:
|
||||
continue
|
||||
if layout_root.tag != f"{{{PML_NS}}}sldLayout":
|
||||
errors.append(f"{layout_part} is not a p:sldLayout part")
|
||||
if layout_root.get("type") != "cust":
|
||||
errors.append(f"{layout_part} must have type='cust'")
|
||||
if layout_root.get("preserve") != "1":
|
||||
errors.append(f"{layout_part} must have preserve='1'")
|
||||
if layout_root.get("showMasterSp", "1").lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
}:
|
||||
errors.append(f"{layout_part} must show its Master shape tree")
|
||||
c_sld = layout_root.find(f"{{{PML_NS}}}cSld")
|
||||
expected_name = layout_specs[0].layout_name
|
||||
actual_name = c_sld.get("name") if c_sld is not None else None
|
||||
if actual_name != expected_name:
|
||||
errors.append(
|
||||
f"{layout_part} has picker name {actual_name!r}, expected "
|
||||
f"{expected_name!r}"
|
||||
)
|
||||
|
||||
override_values = overrides.get(f"/{layout_part}", [])
|
||||
if override_values != [SLIDE_LAYOUT_CONTENT_TYPE]:
|
||||
errors.append(
|
||||
f"[Content_Types].xml must declare {layout_part} exactly once "
|
||||
f"as {SLIDE_LAYOUT_CONTENT_TYPE}"
|
||||
)
|
||||
|
||||
bindings = bindings_by_layout[layout_key]
|
||||
layout_placeholders = _validate_placeholder_roster(
|
||||
layout_root,
|
||||
bindings,
|
||||
f"Layout {layout_key!r}",
|
||||
errors,
|
||||
is_layout=True,
|
||||
)
|
||||
_validate_layout_header_footer(
|
||||
layout_root,
|
||||
bindings,
|
||||
f"Layout {layout_key!r}",
|
||||
errors,
|
||||
)
|
||||
slide_placeholders: dict[int, dict[int, _Placeholder]] = {}
|
||||
for spec in layout_specs:
|
||||
slide_root = slide_roots.get(spec.slide_num)
|
||||
if slide_root is None:
|
||||
continue
|
||||
slide_placeholders[spec.slide_num] = _validate_placeholder_roster(
|
||||
slide_root,
|
||||
bindings,
|
||||
f"Slide {spec.slide_num}",
|
||||
errors,
|
||||
is_layout=False,
|
||||
)
|
||||
|
||||
prototype = layout_specs[0]
|
||||
prototype_placeholders = slide_placeholders.get(
|
||||
prototype.slide_num,
|
||||
{},
|
||||
)
|
||||
for binding in bindings:
|
||||
idx = binding.effective_idx
|
||||
layout_placeholder = layout_placeholders.get(idx)
|
||||
prototype_placeholder = prototype_placeholders.get(idx)
|
||||
if layout_placeholder is None or prototype_placeholder is None:
|
||||
continue
|
||||
context = (
|
||||
f"Layout {layout_key!r} placeholder "
|
||||
f"{binding.element.element_id!r}"
|
||||
)
|
||||
actual_bounds = _shape_bounds(
|
||||
layout_placeholder.shape,
|
||||
context,
|
||||
errors,
|
||||
)
|
||||
if binding.element.placeholder_bounds is not None:
|
||||
expected_bounds = tuple(
|
||||
round(value * EMU_PER_PX)
|
||||
for value in binding.element.placeholder_bounds
|
||||
)
|
||||
else:
|
||||
expected_bounds = _shape_bounds(
|
||||
prototype_placeholder.shape,
|
||||
f"Slide {prototype.slide_num} placeholder "
|
||||
f"{binding.element.element_id!r}",
|
||||
errors,
|
||||
)
|
||||
if (
|
||||
actual_bounds is not None
|
||||
and expected_bounds is not None
|
||||
and actual_bounds != expected_bounds
|
||||
):
|
||||
errors.append(
|
||||
f"{context} bounds are {actual_bounds}, expected "
|
||||
f"{expected_bounds}"
|
||||
)
|
||||
|
||||
prototype_size = _first_run_size(prototype_placeholder.shape)
|
||||
if prototype_size is not None:
|
||||
prompt_size = _first_run_size(layout_placeholder.shape)
|
||||
if prompt_size != prototype_size:
|
||||
errors.append(
|
||||
f"{context} prompt size is {prompt_size!r}, expected "
|
||||
f"{prototype_size!r}"
|
||||
)
|
||||
default_size = _level_one_default_size(
|
||||
layout_placeholder.shape
|
||||
)
|
||||
if default_size != prototype_size:
|
||||
errors.append(
|
||||
f"{context} level-1 default size is "
|
||||
f"{default_size!r}, expected {prototype_size!r}"
|
||||
)
|
||||
|
||||
master_target = _single_relationship_target(
|
||||
reader,
|
||||
layout_part,
|
||||
SLIDE_MASTER_REL_TYPE,
|
||||
f"Layout {layout_key!r}",
|
||||
)
|
||||
if master_target is None:
|
||||
continue
|
||||
_relationship, master_part = master_target
|
||||
if not (
|
||||
master_part.startswith("ppt/slideMasters/")
|
||||
and master_part.endswith(".xml")
|
||||
):
|
||||
errors.append(
|
||||
f"Layout {layout_key!r} targets non-Master part {master_part}"
|
||||
)
|
||||
continue
|
||||
reader.xml(master_part)
|
||||
used_master_parts.add(master_part)
|
||||
layout_parts_by_master.setdefault(master_part, set()).add(
|
||||
layout_part
|
||||
)
|
||||
|
||||
layout_id_owners: dict[int, str] = {}
|
||||
for master_part, layout_parts in sorted(layout_parts_by_master.items()):
|
||||
master_root = reader.xml(master_part)
|
||||
if master_root is None:
|
||||
continue
|
||||
layout_id_entries = master_root.findall(
|
||||
f"{{{PML_NS}}}sldLayoutIdLst/"
|
||||
f"{{{PML_NS}}}sldLayoutId"
|
||||
)
|
||||
layout_ids = _validate_registered_part_roster(
|
||||
reader,
|
||||
master_part,
|
||||
SLIDE_LAYOUT_REL_TYPE,
|
||||
layout_id_entries,
|
||||
layout_parts,
|
||||
f"Master {master_part} p:sldLayoutIdLst",
|
||||
)
|
||||
for layout_id in sorted(layout_ids):
|
||||
previous_owner = layout_id_owners.setdefault(
|
||||
layout_id,
|
||||
master_part,
|
||||
)
|
||||
if previous_owner != master_part:
|
||||
errors.append(
|
||||
f"Slide Layout numeric id {layout_id} appears in both "
|
||||
f"{previous_owner} and {master_part}"
|
||||
)
|
||||
|
||||
_validate_presentation_master_registration(reader, used_master_parts)
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise ValueError(f"cannot read template PPTX package {path}: {exc}") from exc
|
||||
|
||||
if errors:
|
||||
details = "\n".join(f" - {error}" for error in errors)
|
||||
raise ValueError(f"template package read-back failed:\n{details}")
|
||||
+434
-1
@@ -1,4 +1,4 @@
|
||||
"""In-memory expansion of ``<use data-icon="lib/name">`` elements.
|
||||
"""In-memory expansion of project icons and static local ``<use>`` elements.
|
||||
|
||||
The icon placeholder ``<use data-icon="...">`` is a project-internal SVG
|
||||
extension; standard renderers (browsers, PowerPoint's SVG parser) and our
|
||||
@@ -12,6 +12,11 @@ Public API:
|
||||
Walk the SVG element tree, replace every ``<use data-icon="...">``
|
||||
with its expanded ``<g>`` group of primitive shapes, and return
|
||||
the number of replacements made.
|
||||
expand_local_use_references(root) -> int
|
||||
Materialize same-document ``href="#id"`` references, including
|
||||
``<symbol>`` viewBox mapping, and return the number of instances.
|
||||
expand_local_use_references_in_file(svg_path) -> int
|
||||
Apply the same expansion to an SVG file in place.
|
||||
|
||||
The heavy lifting (icon resolution, color application, scaling) is
|
||||
delegated to ``svg_finalize.embed_icons`` so the two pipelines stay
|
||||
@@ -20,12 +25,440 @@ behaviourally aligned.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
_LOCAL_HREF_RE = re.compile(r'^#([^#\s]+)$')
|
||||
_LENGTH_RE = re.compile(
|
||||
r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(?:px)?\s*$'
|
||||
)
|
||||
_VIEWBOX_SPLIT_RE = re.compile(r'[\s,]+')
|
||||
_URL_REF_RE = re.compile(r'''url\(#([^#\s()'"\\]+)\)''')
|
||||
_URL_FUNCTION_RE = re.compile(r'\burl\s*\([^)]*\)', re.IGNORECASE)
|
||||
_URL_FUNCTION_START_RE = re.compile(r'\burl\s*\(', re.IGNORECASE)
|
||||
_MAX_LOCAL_USE_DEPTH = 64
|
||||
_MAX_LOCAL_USE_INSTANCES = 10_000
|
||||
_NON_REUSABLE_METADATA_PREFIXES = (
|
||||
'data-pptx-layer',
|
||||
'data-pptx-native',
|
||||
'data-pptx-placeholder',
|
||||
)
|
||||
_REFERENCE_TAGS = frozenset({
|
||||
'symbol', 'g', 'use',
|
||||
'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',
|
||||
'text', 'image',
|
||||
})
|
||||
|
||||
|
||||
class UseExpansionError(ValueError):
|
||||
"""Raised when a static local ``<use>`` cannot be expanded safely."""
|
||||
|
||||
|
||||
def _local_tag(elem: ET.Element) -> str:
|
||||
"""Return an ElementTree node's local tag name."""
|
||||
return elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag)
|
||||
|
||||
|
||||
def _qualified_tag(elem: ET.Element, local: str) -> str:
|
||||
"""Return ``local`` in the same namespace as ``elem``."""
|
||||
tag = str(elem.tag)
|
||||
if tag.startswith('{') and '}' in tag:
|
||||
return f'{tag.split("}", 1)[0]}}}{local}'
|
||||
return local
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
"""Format a finite SVG transform number without negative zero."""
|
||||
if abs(value) < 1e-12:
|
||||
value = 0.0
|
||||
return f'{value:.12g}'
|
||||
|
||||
|
||||
def _numeric_length(
|
||||
elem: ET.Element,
|
||||
name: str,
|
||||
default: float | None = None,
|
||||
) -> float | None:
|
||||
"""Parse a unitless/px local-use geometry value."""
|
||||
raw = elem.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
match = _LENGTH_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
f'<use> {name} must be a finite unitless or px value, got {raw!r}'
|
||||
)
|
||||
value = float(match.group(1))
|
||||
if not math.isfinite(value):
|
||||
raise UseExpansionError(f'<use> {name} must be finite, got {raw!r}')
|
||||
return value
|
||||
|
||||
|
||||
def _parse_viewbox(symbol: ET.Element) -> tuple[float, float, float, float]:
|
||||
"""Parse a positive four-number symbol viewBox."""
|
||||
raw = symbol.get('viewBox', '')
|
||||
parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw.strip()) if part]
|
||||
if len(parts) != 4:
|
||||
raise UseExpansionError(
|
||||
'<symbol> referenced by <use> must define a four-number viewBox'
|
||||
)
|
||||
try:
|
||||
values = tuple(float(part) for part in parts)
|
||||
except ValueError as exc:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> viewBox contains a non-numeric value: {raw!r}'
|
||||
) from exc
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise UseExpansionError(f'<symbol> viewBox must be finite, got {raw!r}')
|
||||
min_x, min_y, width, height = values
|
||||
if width <= 0 or height <= 0:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> viewBox width/height must be positive, got {raw!r}'
|
||||
)
|
||||
return min_x, min_y, width, height
|
||||
|
||||
|
||||
def _symbol_viewport_transform(symbol: ET.Element, use_elem: ET.Element) -> str:
|
||||
"""Map one symbol viewBox into the use element's explicit viewport."""
|
||||
for attr in ('refX', 'refY'):
|
||||
if symbol.get(attr) is not None:
|
||||
raise UseExpansionError(
|
||||
f'<symbol> {attr} is not supported by local <use> expansion'
|
||||
)
|
||||
|
||||
min_x, min_y, view_width, view_height = _parse_viewbox(symbol)
|
||||
width = _numeric_length(use_elem, 'width')
|
||||
height = _numeric_length(use_elem, 'height')
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
raise UseExpansionError(
|
||||
'<use> referencing <symbol> requires positive numeric width and height'
|
||||
)
|
||||
|
||||
raw_aspect = symbol.get('preserveAspectRatio', 'xMidYMid meet').strip()
|
||||
parts = raw_aspect.split()
|
||||
if parts and parts[0] == 'defer':
|
||||
parts.pop(0)
|
||||
align = parts[0] if parts else 'xMidYMid'
|
||||
mode = parts[1] if len(parts) > 1 else 'meet'
|
||||
if len(parts) > 2 or mode not in {'meet', 'slice'}:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
|
||||
if align == 'none':
|
||||
if len(parts) > 1:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
scale_x = width / view_width
|
||||
scale_y = height / view_height
|
||||
translate_x = -min_x * scale_x
|
||||
translate_y = -min_y * scale_y
|
||||
else:
|
||||
alignments = {
|
||||
'xMinYMin': (0.0, 0.0), 'xMidYMin': (0.5, 0.0), 'xMaxYMin': (1.0, 0.0),
|
||||
'xMinYMid': (0.0, 0.5), 'xMidYMid': (0.5, 0.5), 'xMaxYMid': (1.0, 0.5),
|
||||
'xMinYMax': (0.0, 1.0), 'xMidYMax': (0.5, 1.0), 'xMaxYMax': (1.0, 1.0),
|
||||
}
|
||||
if align not in alignments:
|
||||
raise UseExpansionError(
|
||||
f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}'
|
||||
)
|
||||
if mode == 'slice':
|
||||
raise UseExpansionError(
|
||||
'symbol preserveAspectRatio="... slice" requires viewport clipping, '
|
||||
'which local <use> expansion does not approximate'
|
||||
)
|
||||
scale = min(width / view_width, height / view_height)
|
||||
scale_x = scale_y = scale
|
||||
align_x, align_y = alignments[align]
|
||||
translate_x = (width - view_width * scale) * align_x - min_x * scale
|
||||
translate_y = (height - view_height * scale) * align_y - min_y * scale
|
||||
|
||||
return (
|
||||
f'matrix({_fmt_number(scale_x)} 0 0 {_fmt_number(scale_y)} '
|
||||
f'{_fmt_number(translate_x)} {_fmt_number(translate_y)})'
|
||||
)
|
||||
|
||||
|
||||
class _LocalUseExpander:
|
||||
"""Materialize static same-document SVG use references."""
|
||||
|
||||
def __init__(self, root: ET.Element):
|
||||
self.root = root
|
||||
self.targets: dict[str, ET.Element] = {}
|
||||
self.duplicate_ids: set[str] = set()
|
||||
self.used_ids: set[str] = set()
|
||||
self.instance_index = 0
|
||||
self.instances_started = 0
|
||||
self.expanded = 0
|
||||
for elem in root.iter():
|
||||
elem_id = elem.get('id')
|
||||
if not elem_id:
|
||||
continue
|
||||
self.used_ids.add(elem_id)
|
||||
if elem_id in self.targets:
|
||||
self.duplicate_ids.add(elem_id)
|
||||
else:
|
||||
self.targets[elem_id] = elem
|
||||
|
||||
def expand(self) -> int:
|
||||
"""Expand every visible non-data-icon use node in the document."""
|
||||
self._expand_children(self.root, ())
|
||||
return self.expanded
|
||||
|
||||
def _expand_children(self, parent: ET.Element, stack: tuple[str, ...]) -> None:
|
||||
for index, child in enumerate(list(parent)):
|
||||
if _local_tag(child) == 'defs':
|
||||
continue
|
||||
if _local_tag(child) == 'use' and not child.get('data-icon'):
|
||||
replacement = self._materialize_use(child, stack)
|
||||
parent.remove(child)
|
||||
parent.insert(index, replacement)
|
||||
continue
|
||||
self._expand_children(child, stack)
|
||||
|
||||
def _materialize_use(
|
||||
self,
|
||||
use_elem: ET.Element,
|
||||
stack: tuple[str, ...],
|
||||
) -> ET.Element:
|
||||
self.instances_started += 1
|
||||
if self.instances_started > _MAX_LOCAL_USE_INSTANCES:
|
||||
raise UseExpansionError(
|
||||
'Local <use> expansion exceeds the 10000-instance safety limit'
|
||||
)
|
||||
|
||||
href = use_elem.get('href')
|
||||
xlink_href = use_elem.get(f'{{{XLINK_NS}}}href')
|
||||
if href is not None and xlink_href is not None:
|
||||
if href != xlink_href:
|
||||
raise UseExpansionError(
|
||||
'Conflicting href and xlink:href values on local <use>'
|
||||
)
|
||||
if href is None:
|
||||
href = xlink_href
|
||||
match = _LOCAL_HREF_RE.fullmatch(href or '')
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
'<use> must reference a same-document fragment with href="#id"; '
|
||||
f'got {href!r}'
|
||||
)
|
||||
ref_id = match.group(1)
|
||||
if len(stack) >= _MAX_LOCAL_USE_DEPTH:
|
||||
chain = ' -> '.join((*stack, ref_id))
|
||||
raise UseExpansionError(
|
||||
f'Local <use> expansion exceeds the 64-reference depth limit: {chain}'
|
||||
)
|
||||
if ref_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> is ambiguous because the id is duplicated'
|
||||
)
|
||||
use_id = use_elem.get('id')
|
||||
if use_id and use_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> instance id {use_id!r} is duplicated in this SVG'
|
||||
)
|
||||
target = self.targets.get(ref_id)
|
||||
if target is None:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> has no matching element in this SVG'
|
||||
)
|
||||
if ref_id in stack:
|
||||
chain = ' -> '.join((*stack, ref_id))
|
||||
raise UseExpansionError(f'Circular local <use> reference: {chain}')
|
||||
|
||||
target_tag = _local_tag(target)
|
||||
if target_tag not in _REFERENCE_TAGS:
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> references unsupported <{target_tag}>'
|
||||
)
|
||||
|
||||
self._reject_structural_metadata(use_elem, 'instance')
|
||||
self._reject_structural_metadata(target, f'target #{ref_id}')
|
||||
self._validate_fragment_reference_syntax(use_elem, 'instance')
|
||||
self._validate_fragment_reference_syntax(target, f'target #{ref_id}')
|
||||
|
||||
target_ids = {
|
||||
elem_id
|
||||
for elem in target.iter()
|
||||
if (elem_id := elem.get('id'))
|
||||
}
|
||||
ambiguous_ids = sorted(target_ids & self.duplicate_ids)
|
||||
if ambiguous_ids:
|
||||
joined = ', '.join(ambiguous_ids)
|
||||
raise UseExpansionError(
|
||||
f'<use href="#{ref_id}"> references a subtree with duplicate id(s): '
|
||||
f'{joined}'
|
||||
)
|
||||
|
||||
next_stack = (*stack, ref_id)
|
||||
target_clone = copy.deepcopy(target)
|
||||
if target_tag == 'use':
|
||||
clone = self._materialize_use(target_clone, next_stack)
|
||||
else:
|
||||
clone = target_clone
|
||||
if target_tag == 'symbol':
|
||||
clone.tag = _qualified_tag(clone, 'g')
|
||||
viewport_transform = _symbol_viewport_transform(target, use_elem)
|
||||
existing_transform = clone.get('transform', '').strip()
|
||||
clone.set(
|
||||
'transform',
|
||||
f'{existing_transform} {viewport_transform}'.strip(),
|
||||
)
|
||||
for attr in ('viewBox', 'preserveAspectRatio', 'x', 'y', 'width', 'height'):
|
||||
clone.attrib.pop(attr, None)
|
||||
self._expand_children(clone, next_stack)
|
||||
|
||||
self._rewrite_clone_ids(clone, self._next_instance_prefix(clone))
|
||||
wrapper = self._build_wrapper(use_elem)
|
||||
wrapper.append(clone)
|
||||
self.expanded += 1
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def _reject_structural_metadata(elem: ET.Element, label: str) -> None:
|
||||
"""Reject reusable template/native markers parsed before expansion."""
|
||||
for candidate in elem.iter():
|
||||
for attr in candidate.attrib:
|
||||
if attr.startswith(_NON_REUSABLE_METADATA_PREFIXES):
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} cannot carry structural {attr} metadata'
|
||||
)
|
||||
|
||||
def _validate_fragment_reference_syntax(
|
||||
self,
|
||||
elem: ET.Element,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Reject URL fragment forms the clone rewriter cannot preserve."""
|
||||
for candidate in elem.iter():
|
||||
for value in candidate.attrib.values():
|
||||
starts = list(_URL_FUNCTION_START_RE.finditer(value))
|
||||
if not starts:
|
||||
continue
|
||||
functions = list(_URL_FUNCTION_RE.finditer(value))
|
||||
if len(functions) != len(starts):
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has malformed url(...) reference {value!r}'
|
||||
)
|
||||
for function in functions:
|
||||
raw = function.group(0)
|
||||
match = _URL_REF_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} requires exact url(#id) fragments; '
|
||||
f'got {raw!r}'
|
||||
)
|
||||
ref_id = match.group(1)
|
||||
if ref_id in self.duplicate_ids:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has ambiguous url(#{ref_id}); '
|
||||
'the referenced id is duplicated'
|
||||
)
|
||||
if ref_id not in self.targets:
|
||||
raise UseExpansionError(
|
||||
f'Local <use> {label} has unresolved url(#{ref_id})'
|
||||
)
|
||||
|
||||
def _next_instance_prefix(self, clone: ET.Element) -> str:
|
||||
"""Reserve a deterministic clone prefix that cannot collide."""
|
||||
clone_ids = {
|
||||
elem_id
|
||||
for elem in clone.iter()
|
||||
if (elem_id := elem.get('id'))
|
||||
}
|
||||
while True:
|
||||
self.instance_index += 1
|
||||
prefix = f'use-instance-{self.instance_index}-'
|
||||
generated_ids = {f'{prefix}{elem_id}' for elem_id in clone_ids}
|
||||
if not generated_ids & self.used_ids:
|
||||
self.used_ids.update(generated_ids)
|
||||
return prefix
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_clone_ids(clone: ET.Element, prefix: str) -> None:
|
||||
"""Make materialized IDs instance-local and rewrite fragment refs."""
|
||||
id_map: dict[str, str] = {}
|
||||
for elem in clone.iter():
|
||||
elem_id = elem.get('id')
|
||||
if elem_id:
|
||||
id_map[elem_id] = f'{prefix}{elem_id}'
|
||||
if not id_map:
|
||||
return
|
||||
for elem in clone.iter():
|
||||
elem_id = elem.get('id')
|
||||
if elem_id in id_map:
|
||||
elem.set('id', id_map[elem_id])
|
||||
for attr, value in list(elem.attrib.items()):
|
||||
if attr in {'href', f'{{{XLINK_NS}}}href'} and value.startswith('#'):
|
||||
ref_id = value[1:]
|
||||
if ref_id in id_map:
|
||||
elem.set(attr, f'#{id_map[ref_id]}')
|
||||
continue
|
||||
rewritten = _URL_REF_RE.sub(
|
||||
lambda match: f'url(#{id_map.get(match.group(1), match.group(1))})',
|
||||
value,
|
||||
)
|
||||
if rewritten != value:
|
||||
elem.set(attr, rewritten)
|
||||
|
||||
@staticmethod
|
||||
def _build_wrapper(use_elem: ET.Element) -> ET.Element:
|
||||
"""Create an inheriting group for use styles and instance geometry."""
|
||||
wrapper = ET.Element(_qualified_tag(use_elem, 'g'))
|
||||
skipped = {
|
||||
'href', f'{{{XLINK_NS}}}href',
|
||||
'x', 'y', 'width', 'height', 'preserveAspectRatio', 'transform',
|
||||
}
|
||||
for attr, value in use_elem.attrib.items():
|
||||
if attr not in skipped:
|
||||
wrapper.set(attr, value)
|
||||
|
||||
x = _numeric_length(use_elem, 'x', 0.0) or 0.0
|
||||
y = _numeric_length(use_elem, 'y', 0.0) or 0.0
|
||||
transforms = []
|
||||
use_transform = use_elem.get('transform', '').strip()
|
||||
if use_transform:
|
||||
transforms.append(use_transform)
|
||||
if x or y:
|
||||
transforms.append(f'translate({_fmt_number(x)} {_fmt_number(y)})')
|
||||
if transforms:
|
||||
wrapper.set('transform', ' '.join(transforms))
|
||||
return wrapper
|
||||
|
||||
|
||||
def expand_local_use_references(root: ET.Element) -> int:
|
||||
"""Expand static same-document ``<use href="#id">`` references."""
|
||||
return _LocalUseExpander(root).expand()
|
||||
|
||||
|
||||
def validate_local_use_references(root: ET.Element) -> list[str]:
|
||||
"""Return expansion errors without mutating the caller's SVG tree."""
|
||||
try:
|
||||
expand_local_use_references(copy.deepcopy(root))
|
||||
except UseExpansionError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
|
||||
|
||||
def expand_local_use_references_in_file(svg_path: Path) -> int:
|
||||
"""Expand local use references in one SVG file in place."""
|
||||
tree = ET.parse(str(svg_path))
|
||||
count = expand_local_use_references(tree.getroot())
|
||||
if count:
|
||||
ET.register_namespace('', SVG_NS)
|
||||
ET.register_namespace('xlink', XLINK_NS)
|
||||
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
|
||||
return count
|
||||
|
||||
|
||||
def _import_embed_icons():
|
||||
|
||||
+31
-10
@@ -1,4 +1,4 @@
|
||||
"""analyze: read a PPTX as a reusable slide library of text / table / chart slots."""
|
||||
"""Analyze a PPTX as reusable text, table, chart, and SmartArt source facts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .chart_read import empty_chart_data, read_chart_data
|
||||
from .diagram_read import read_smartart_diagrams
|
||||
from .ooxml import (
|
||||
CHART_REL_TYPE,
|
||||
NS,
|
||||
@@ -143,26 +144,36 @@ def _canvas_px(pres_root: ET.Element) -> dict[str, int | None]:
|
||||
}
|
||||
|
||||
|
||||
def _fill_risk(tables: list[dict[str, Any]], charts: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
def _fill_risk(
|
||||
tables: list[dict[str, Any]],
|
||||
charts: list[dict[str, Any]],
|
||||
diagrams: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace.
|
||||
|
||||
Only tables and charts are checked; no new dependencies are introduced.
|
||||
Returns None when the slide has no such content.
|
||||
Tables and charts may be covered by explicit edits. SmartArt is inventory-only:
|
||||
template-fill preserves it unchanged, so its source text may show through.
|
||||
"""
|
||||
kinds: list[str] = []
|
||||
if tables:
|
||||
kinds.append("table")
|
||||
if charts:
|
||||
kinds.append("chart")
|
||||
if diagrams:
|
||||
kinds.append("smartart")
|
||||
if not kinds:
|
||||
return None
|
||||
kind_str = "/".join(kinds)
|
||||
guidance: list[str] = []
|
||||
if tables or charts:
|
||||
guidance.append("cover tables/charts with explicit edits")
|
||||
if diagrams:
|
||||
guidance.append("review preserved SmartArt source text")
|
||||
guidance_text = "; ".join(guidance)
|
||||
return {
|
||||
"has_non_text_content": True,
|
||||
"reason": (
|
||||
f"has non-text content ({kind_str}) that text-fill cannot replace; "
|
||||
"provide table_edits/chart_edits or pick another source slide"
|
||||
),
|
||||
"kinds": kinds,
|
||||
"reason": f"has non-text content ({kind_str}) that text-fill does not replace automatically; {guidance_text}",
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +213,16 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
|
||||
|
||||
tables = _analyze_tables(slide_root, slide_ref.index)
|
||||
charts = _analyze_charts(zf, slide_root, slide_ref)
|
||||
slide_text = "\n".join(slot["text"] for slot in slots if slot["text"])
|
||||
diagrams = read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index)
|
||||
slide_text = "\n".join(
|
||||
[slot["text"] for slot in slots if slot["text"]]
|
||||
+ [
|
||||
str(text)
|
||||
for diagram in diagrams
|
||||
for text in diagram.get("text_items", [])
|
||||
if text
|
||||
]
|
||||
)
|
||||
slide: dict[str, Any] = {
|
||||
"slide_index": slide_ref.index,
|
||||
"page_type": _classify_page_type(slide_ref.index, len(slide_refs), slide_text, slots),
|
||||
@@ -210,8 +230,9 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
|
||||
"slots": slots,
|
||||
"tables": tables,
|
||||
"charts": charts,
|
||||
"diagrams": diagrams,
|
||||
}
|
||||
risk = _fill_risk(tables, charts)
|
||||
risk = _fill_risk(tables, charts, diagrams)
|
||||
if risk is not None:
|
||||
slide["fill_risk"] = risk
|
||||
slides.append(slide)
|
||||
|
||||
+72
-6
@@ -6,11 +6,23 @@ and rebuilds the presentation slide list, relationships, and content types.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_animations import (
|
||||
object_animation_fingerprint,
|
||||
validate_pptx_animation_package,
|
||||
)
|
||||
from pptx_transitions import (
|
||||
parse_source_xml,
|
||||
serialize_source_xml,
|
||||
set_package_use_timings,
|
||||
validate_pptx_transition_package,
|
||||
)
|
||||
|
||||
from .chart_fill import (
|
||||
_apply_chart_edits_to_slide_package,
|
||||
_max_chart_part_number,
|
||||
@@ -76,6 +88,7 @@ def apply_plan(
|
||||
next_chart_number = _max_chart_part_number(entries)
|
||||
next_embedding_number = _max_embedding_part_number(entries)
|
||||
allocate_part = _make_part_allocator(entries)
|
||||
wrote_auto_advance = False
|
||||
|
||||
for offset, item in enumerate(plan_slides):
|
||||
source_slide = int(item.get("source_slide", 0))
|
||||
@@ -87,7 +100,11 @@ def apply_plan(
|
||||
new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels"
|
||||
new_rid = f"rId{next_rel_number + offset}"
|
||||
|
||||
slide_root = ET.fromstring(entries[source_ref.part_name])
|
||||
source_slide_xml = entries[source_ref.part_name]
|
||||
source_animation_fingerprint = object_animation_fingerprint(
|
||||
source_slide_xml
|
||||
)
|
||||
slide_root = parse_source_xml(source_slide_xml)
|
||||
replacements = item.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
raise RuntimeError(f"Slide {source_slide} replacements must be a list")
|
||||
@@ -109,12 +126,14 @@ def apply_plan(
|
||||
default_effect=transition,
|
||||
default_duration=transition_duration,
|
||||
)
|
||||
_set_slide_transition(
|
||||
slide_has_auto_advance = _set_slide_transition(
|
||||
slide_root,
|
||||
effect=slide_effect,
|
||||
duration=slide_duration,
|
||||
advance_after=slide_advance,
|
||||
)
|
||||
if slide_advance is not None and slide_has_auto_advance:
|
||||
wrote_auto_advance = True
|
||||
|
||||
source_rels = entries.get(source_ref.rels_name)
|
||||
slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root()
|
||||
@@ -139,7 +158,21 @@ def apply_plan(
|
||||
next_chart_number=next_chart_number,
|
||||
next_embedding_number=next_embedding_number,
|
||||
)
|
||||
entries[new_part] = _xml_bytes(slide_root)
|
||||
try:
|
||||
serialized_slide = serialize_source_xml(
|
||||
slide_root,
|
||||
source_slide_xml,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
if (
|
||||
object_animation_fingerprint(serialized_slide)
|
||||
!= source_animation_fingerprint
|
||||
):
|
||||
raise RuntimeError(
|
||||
f'Slide {source_slide} object animations changed during template fill'
|
||||
)
|
||||
entries[new_part] = serialized_slide
|
||||
notes_text = str(item.get("notes") or item.get("speaker_notes") or "")
|
||||
entries[new_rels], note_entries = _slide_rels_with_notes(
|
||||
_xml_bytes(slide_rels_root),
|
||||
@@ -171,8 +204,41 @@ def apply_plan(
|
||||
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root)
|
||||
_prune_unreferenced_parts(entries, content_root)
|
||||
entries["[Content_Types].xml"] = _xml_bytes(content_root)
|
||||
if wrote_auto_advance:
|
||||
try:
|
||||
set_package_use_timings(entries)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as out:
|
||||
for name, data in entries.items():
|
||||
out.writestr(name, data)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="template-fill-pptx-",
|
||||
dir=output_path.parent,
|
||||
) as temp_dir:
|
||||
candidate_path = Path(temp_dir) / output_path.name
|
||||
with zipfile.ZipFile(
|
||||
candidate_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as out:
|
||||
for name, data in entries.items():
|
||||
out.writestr(name, data)
|
||||
try:
|
||||
validate_pptx_transition_package(
|
||||
candidate_path,
|
||||
require_use_timings=wrote_auto_advance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX transition package validation failed: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_animation_package(
|
||||
candidate_path,
|
||||
require_supported_effects=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"PPTX animation/timing package validation failed: {exc}"
|
||||
) from exc
|
||||
candidate_path.replace(output_path)
|
||||
|
||||
+20
-8
@@ -1,4 +1,4 @@
|
||||
"""check-plan: compare planned text / table / chart edits against source capacity."""
|
||||
"""Check planned text/table/chart edits and preserved SmartArt source risks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -463,9 +463,8 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
# --- Guardrail 2: source slides with non-text content not covered by edits ---
|
||||
# For each plan slide, if the source slide has tables/charts in the library
|
||||
# but the plan slide provides no matching table_edits/chart_edits, warn that
|
||||
# text-fill will silently leave the original template content in place.
|
||||
# Tables/charts may be covered by explicit edits. SmartArt is preserve-only,
|
||||
# so selecting a source slide that contains it always needs a content review.
|
||||
lib_slides = _library_slide_index(library)
|
||||
for plan_slide_index, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
@@ -474,7 +473,8 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
continue
|
||||
lib_tables = lib_slide.get("tables", [])
|
||||
lib_charts = lib_slide.get("charts", [])
|
||||
if not lib_tables and not lib_charts:
|
||||
lib_diagrams = lib_slide.get("diagrams", [])
|
||||
if not lib_tables and not lib_charts and not lib_diagrams:
|
||||
continue
|
||||
# Check whether the plan slide provides edits covering the non-text content.
|
||||
has_table_edits = bool(slide.get("table_edits"))
|
||||
@@ -484,9 +484,16 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
uncovered_kinds.append("table")
|
||||
if lib_charts and not has_chart_edits:
|
||||
uncovered_kinds.append("chart")
|
||||
if lib_diagrams:
|
||||
uncovered_kinds.append("smartart")
|
||||
if not uncovered_kinds:
|
||||
continue
|
||||
kind_str = "/".join(uncovered_kinds)
|
||||
guidance = "add table_edits/chart_edits, or pick another source slide"
|
||||
if lib_diagrams:
|
||||
guidance = "SmartArt remains unchanged, so pick another source slide or accept this warning"
|
||||
if any(kind in uncovered_kinds for kind in ("table", "chart")):
|
||||
guidance = f"add table/chart edits where supported; {guidance}"
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
@@ -494,11 +501,16 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
"code": "non_text_content_unedited",
|
||||
"plan_slide": plan_slide_index,
|
||||
"source_slide": source_slide,
|
||||
"uncovered_kinds": uncovered_kinds,
|
||||
"diagram_ids": [
|
||||
diagram.get("diagram_id")
|
||||
for diagram in lib_diagrams
|
||||
if diagram.get("diagram_id")
|
||||
],
|
||||
"message": (
|
||||
f"source slide {source_slide} has non-text content ({kind_str}) "
|
||||
"with no matching edits in the plan; text-fill leaves it untouched "
|
||||
"and original template content may show through "
|
||||
"(add table_edits/chart_edits, or pick another source slide)"
|
||||
"outside the plan's supported edits; template-fill leaves it untouched "
|
||||
f"and original template content may show through ({guidance})"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
"""Read SmartArt content and structure from DrawingML diagram parts.
|
||||
|
||||
The reader exposes source facts only. It does not edit DiagramML or promise
|
||||
native SmartArt regeneration; generated decks continue to redraw the extracted
|
||||
content through the SVG-to-DrawingML shape pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import (
|
||||
MC_NS,
|
||||
NS,
|
||||
_container_geometry,
|
||||
_normalize_part,
|
||||
_paragraph_texts,
|
||||
_qn,
|
||||
_read_xml,
|
||||
_rels_name_for_part,
|
||||
_shape_identity,
|
||||
_slide_relationships,
|
||||
)
|
||||
|
||||
|
||||
DIAGRAM_NS = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||
DIAGRAM_DRAWING_NS = "http://schemas.microsoft.com/office/drawing/2008/diagram"
|
||||
DIAGRAM_URI = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||
|
||||
_DIAGRAM_REL_SUFFIXES = {
|
||||
"colors": "diagramColors",
|
||||
"data": "diagramData",
|
||||
"layout": "diagramLayout",
|
||||
"quick_style": "diagramQuickStyle",
|
||||
}
|
||||
_REL_ATTRS = {
|
||||
"colors": "cs",
|
||||
"data": "dm",
|
||||
"layout": "lo",
|
||||
"quick_style": "qs",
|
||||
}
|
||||
_CONTENT_POINT_TYPES = {"asst", "node"}
|
||||
|
||||
DIAGRAM_NS_MAP = {
|
||||
**NS,
|
||||
"dgm": DIAGRAM_NS,
|
||||
"dsp": DIAGRAM_DRAWING_NS,
|
||||
"mc": MC_NS,
|
||||
}
|
||||
|
||||
|
||||
def _relationship_matches(rel_type: str, suffix: str) -> bool:
|
||||
return rel_type.rsplit("/", 1)[-1] == suffix
|
||||
|
||||
|
||||
def _read_optional_xml(
|
||||
zf: zipfile.ZipFile,
|
||||
part_name: str | None,
|
||||
) -> tuple[ET.Element | None, str | None]:
|
||||
if not part_name:
|
||||
return None, "missing-part-reference"
|
||||
try:
|
||||
return _read_xml(zf, part_name), None
|
||||
except RuntimeError:
|
||||
return None, "missing-part"
|
||||
except ET.ParseError:
|
||||
return None, "invalid-xml"
|
||||
|
||||
|
||||
def _diagram_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
containers: list[ET.Element] = []
|
||||
for frame in slide_root.findall(".//p:graphicFrame", DIAGRAM_NS_MAP):
|
||||
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
|
||||
if graphic_data is not None and graphic_data.attrib.get("uri") == DIAGRAM_URI:
|
||||
containers.append(frame)
|
||||
return containers
|
||||
|
||||
|
||||
def _fallback_preview_shape_ids(slide_root: ET.Element) -> set[str]:
|
||||
shape_ids: set[str] = set()
|
||||
for alternate in slide_root.findall(".//mc:AlternateContent", DIAGRAM_NS_MAP):
|
||||
frame = alternate.find("mc:Choice//p:graphicFrame", DIAGRAM_NS_MAP)
|
||||
if frame is None:
|
||||
continue
|
||||
graphic_data = frame.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
|
||||
if graphic_data is None or graphic_data.attrib.get("uri") != DIAGRAM_URI:
|
||||
continue
|
||||
if alternate.find("mc:Fallback//p:pic", DIAGRAM_NS_MAP) is None:
|
||||
continue
|
||||
shape_id, _shape_name = _shape_identity(frame, len(shape_ids) + 1)
|
||||
shape_ids.add(shape_id)
|
||||
return shape_ids
|
||||
|
||||
|
||||
def _diagram_parts(
|
||||
rel_ids: ET.Element | None,
|
||||
relationships: dict[str, dict[str, str]],
|
||||
slide_part: str,
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
if rel_ids is None:
|
||||
return {}, "missing-rel-ids"
|
||||
|
||||
parts: dict[str, str] = {}
|
||||
data_error: str | None = None
|
||||
for key, attr_name in _REL_ATTRS.items():
|
||||
rel_id = rel_ids.attrib.get(_qn(NS["r"], attr_name), "")
|
||||
if not rel_id:
|
||||
if key == "data":
|
||||
data_error = "missing-data-relationship"
|
||||
continue
|
||||
relationship = relationships.get(rel_id)
|
||||
if relationship is None:
|
||||
if key == "data":
|
||||
data_error = "missing-data-relationship"
|
||||
continue
|
||||
if not _relationship_matches(
|
||||
relationship.get("type", ""),
|
||||
_DIAGRAM_REL_SUFFIXES[key],
|
||||
):
|
||||
if key == "data":
|
||||
data_error = "invalid-data-relationship"
|
||||
continue
|
||||
parts[key] = _normalize_part(relationship["target"], slide_part)
|
||||
return parts, data_error
|
||||
|
||||
|
||||
def _persisted_drawing_part(
|
||||
zf: zipfile.ZipFile,
|
||||
data_part: str | None,
|
||||
data_root: ET.Element | None,
|
||||
slide_part: str,
|
||||
slide_relationships: dict[str, dict[str, str]],
|
||||
) -> tuple[str | None, list[str]]:
|
||||
if not data_part or data_root is None:
|
||||
return None, []
|
||||
data_model_ext = data_root.find(".//dsp:dataModelExt", DIAGRAM_NS_MAP)
|
||||
rel_id = data_model_ext.attrib.get("relId", "") if data_model_ext is not None else ""
|
||||
if not rel_id:
|
||||
return None, []
|
||||
|
||||
warnings: list[str] = []
|
||||
relationship = slide_relationships.get(rel_id)
|
||||
relationship_owner = slide_part
|
||||
if relationship is not None and not _relationship_matches(
|
||||
relationship.get("type", ""),
|
||||
"diagramDrawing",
|
||||
):
|
||||
relationship = None
|
||||
if relationship is None:
|
||||
try:
|
||||
data_relationships = _slide_relationships(zf, _rels_name_for_part(data_part))
|
||||
except ET.ParseError:
|
||||
data_relationships = {}
|
||||
warnings.append("invalid-persisted-drawing-relationships")
|
||||
relationship = data_relationships.get(rel_id)
|
||||
relationship_owner = data_part
|
||||
if relationship is not None and not _relationship_matches(
|
||||
relationship.get("type", ""),
|
||||
"diagramDrawing",
|
||||
):
|
||||
relationship = None
|
||||
if relationship is None:
|
||||
warnings.append("unresolved-persisted-drawing-relationship")
|
||||
return None, warnings
|
||||
|
||||
part_name = _normalize_part(relationship["target"], relationship_owner)
|
||||
if part_name not in zf.namelist():
|
||||
warnings.append("missing-persisted-drawing-part")
|
||||
return None, warnings
|
||||
return part_name, warnings
|
||||
|
||||
|
||||
def _layout_info(
|
||||
layout_root: ET.Element | None,
|
||||
data_root: ET.Element | None,
|
||||
) -> dict[str, Any]:
|
||||
unique_id = layout_root.attrib.get("uniqueId") if layout_root is not None else None
|
||||
if not unique_id and data_root is not None:
|
||||
document_properties = data_root.find(
|
||||
".//dgm:pt[@type='doc']/dgm:prSet",
|
||||
DIAGRAM_NS_MAP,
|
||||
)
|
||||
if document_properties is not None:
|
||||
unique_id = document_properties.attrib.get("loTypeId")
|
||||
title = layout_root.find("dgm:title", DIAGRAM_NS_MAP) if layout_root is not None else None
|
||||
name = title.attrib.get("val") if title is not None else None
|
||||
if not name and unique_id:
|
||||
name = unique_id.rstrip("/").rsplit("/", 1)[-1]
|
||||
categories = (
|
||||
[
|
||||
category.attrib["type"]
|
||||
for category in layout_root.findall("dgm:catLst/dgm:cat", DIAGRAM_NS_MAP)
|
||||
if category.attrib.get("type")
|
||||
]
|
||||
if layout_root is not None
|
||||
else []
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"unique_id": unique_id,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
|
||||
def _integer_or_none(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _point_text(point: ET.Element) -> str:
|
||||
text_body = point.find("dgm:t", DIAGRAM_NS_MAP)
|
||||
if text_body is None:
|
||||
return ""
|
||||
return "\n".join(_paragraph_texts(text_body)).strip()
|
||||
|
||||
|
||||
def _connections(data_root: ET.Element) -> list[dict[str, Any]]:
|
||||
connections: list[dict[str, Any]] = []
|
||||
for connection in data_root.findall(
|
||||
".//dgm:cxnLst/dgm:cxn",
|
||||
DIAGRAM_NS_MAP,
|
||||
):
|
||||
source_id = connection.attrib.get("srcId", "")
|
||||
destination_id = connection.attrib.get("destId", "")
|
||||
if not source_id or not destination_id:
|
||||
continue
|
||||
connections.append(
|
||||
{
|
||||
"type": connection.attrib.get("type") or "parOf",
|
||||
"source_id": source_id,
|
||||
"destination_id": destination_id,
|
||||
"source_order": _integer_or_none(connection.attrib.get("srcOrd")),
|
||||
"destination_order": _integer_or_none(connection.attrib.get("destOrd")),
|
||||
}
|
||||
)
|
||||
return connections
|
||||
|
||||
|
||||
def _nearest_content_parent(
|
||||
node_id: str,
|
||||
parent_by_id: dict[str, str],
|
||||
content_ids: set[str],
|
||||
) -> str | None:
|
||||
current = parent_by_id.get(node_id)
|
||||
visited = {node_id}
|
||||
while current:
|
||||
if current in content_ids:
|
||||
return current
|
||||
if current in visited:
|
||||
return None
|
||||
visited.add(current)
|
||||
current = parent_by_id.get(current)
|
||||
return None
|
||||
|
||||
|
||||
def _break_parent_cycles(
|
||||
parent_by_node: dict[str, str | None],
|
||||
source_order: dict[str, int],
|
||||
) -> list[str]:
|
||||
"""Break cycles in the Markdown tree projection while retaining raw connections."""
|
||||
warnings: list[str] = []
|
||||
for start_id in sorted(parent_by_node, key=lambda node_id: source_order[node_id]):
|
||||
path: list[str] = []
|
||||
path_index: dict[str, int] = {}
|
||||
current_id: str | None = start_id
|
||||
while current_id is not None and current_id in parent_by_node:
|
||||
if current_id in path_index:
|
||||
cycle = path[path_index[current_id] :]
|
||||
root_id = min(cycle, key=lambda node_id: source_order[node_id])
|
||||
parent_by_node[root_id] = None
|
||||
warnings.append(f"parent-cycle-broken-at:{root_id}")
|
||||
break
|
||||
path_index[current_id] = len(path)
|
||||
path.append(current_id)
|
||||
current_id = parent_by_node.get(current_id)
|
||||
return warnings
|
||||
|
||||
|
||||
def _ordered_nodes(
|
||||
data_root: ET.Element,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
|
||||
raw_points: list[dict[str, Any]] = []
|
||||
for source_index, point in enumerate(
|
||||
data_root.findall(".//dgm:ptLst/dgm:pt", DIAGRAM_NS_MAP),
|
||||
):
|
||||
model_id = point.attrib.get("modelId", "")
|
||||
point_type = point.attrib.get("type") or "node"
|
||||
text = _point_text(point)
|
||||
if not model_id or point_type not in _CONTENT_POINT_TYPES:
|
||||
continue
|
||||
raw_points.append(
|
||||
{
|
||||
"id": model_id,
|
||||
"type": point_type,
|
||||
"text": text,
|
||||
"source_index": source_index,
|
||||
}
|
||||
)
|
||||
|
||||
all_connections = _connections(data_root)
|
||||
parent_by_id: dict[str, str] = {}
|
||||
order_by_id: dict[str, int | None] = {}
|
||||
for connection in all_connections:
|
||||
if connection["type"] != "parOf":
|
||||
continue
|
||||
destination_id = connection["destination_id"]
|
||||
parent_by_id.setdefault(destination_id, connection["source_id"])
|
||||
order_by_id.setdefault(destination_id, connection["source_order"])
|
||||
|
||||
content_ids = {point["id"] for point in raw_points}
|
||||
source_order = {point["id"]: int(point["source_index"]) for point in raw_points}
|
||||
parent_by_node = {
|
||||
point["id"]: _nearest_content_parent(
|
||||
point["id"],
|
||||
parent_by_id,
|
||||
content_ids,
|
||||
)
|
||||
for point in raw_points
|
||||
}
|
||||
structure_warnings = _break_parent_cycles(parent_by_node, source_order)
|
||||
nodes_by_id: dict[str, dict[str, Any]] = {}
|
||||
for point in raw_points:
|
||||
node = {
|
||||
"id": point["id"],
|
||||
"type": point["type"],
|
||||
"text": point["text"],
|
||||
"parent_id": parent_by_node[point["id"]],
|
||||
"order": order_by_id.get(point["id"]),
|
||||
"depth": 0,
|
||||
"_source_index": point["source_index"],
|
||||
}
|
||||
nodes_by_id[point["id"]] = node
|
||||
|
||||
children: dict[str | None, list[dict[str, Any]]] = {}
|
||||
for node in nodes_by_id.values():
|
||||
children.setdefault(node["parent_id"], []).append(node)
|
||||
|
||||
def sort_key(node: dict[str, Any]) -> tuple[int, int]:
|
||||
order = node["order"]
|
||||
return (
|
||||
order if isinstance(order, int) else 1_000_000,
|
||||
int(node["_source_index"]),
|
||||
)
|
||||
|
||||
for siblings in children.values():
|
||||
siblings.sort(key=sort_key)
|
||||
|
||||
ordered: list[dict[str, Any]] = []
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(node: dict[str, Any], depth: int) -> None:
|
||||
node_id = str(node["id"])
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
node["depth"] = depth
|
||||
ordered.append(node)
|
||||
for child in children.get(node_id, []):
|
||||
visit(child, depth + 1)
|
||||
|
||||
for root in children.get(None, []):
|
||||
visit(root, 0)
|
||||
for node in sorted(nodes_by_id.values(), key=sort_key):
|
||||
if node["id"] not in visited:
|
||||
visit(node, 0)
|
||||
|
||||
for node in ordered:
|
||||
node.pop("_source_index", None)
|
||||
|
||||
visible_connections = [
|
||||
connection
|
||||
for connection in all_connections
|
||||
if connection["type"] == "parOf"
|
||||
and connection["source_id"] in content_ids
|
||||
and connection["destination_id"] in content_ids
|
||||
]
|
||||
return ordered, visible_connections, structure_warnings
|
||||
|
||||
|
||||
def _read_diagram_container(
|
||||
zf: zipfile.ZipFile,
|
||||
container: ET.Element,
|
||||
*,
|
||||
slide_part: str,
|
||||
slide_index: int,
|
||||
order: int,
|
||||
relationships: dict[str, dict[str, str]],
|
||||
relationship_error: str | None,
|
||||
fallback_shape_ids: set[str],
|
||||
) -> dict[str, Any]:
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
graphic_data = container.find("a:graphic/a:graphicData", DIAGRAM_NS_MAP)
|
||||
rel_ids = (
|
||||
graphic_data.find("dgm:relIds", DIAGRAM_NS_MAP)
|
||||
if graphic_data is not None
|
||||
else None
|
||||
)
|
||||
parts, diagram_relation_error = _diagram_parts(
|
||||
rel_ids,
|
||||
relationships,
|
||||
slide_part,
|
||||
)
|
||||
data_root, data_error = _read_optional_xml(zf, parts.get("data"))
|
||||
layout_root, layout_error = _read_optional_xml(zf, parts.get("layout"))
|
||||
metadata_warnings = [f"layout:{layout_error}"] if layout_error else []
|
||||
nodes: list[dict[str, Any]] = []
|
||||
connections: list[dict[str, Any]] = []
|
||||
structure_warnings: list[str] = []
|
||||
if data_root is not None:
|
||||
nodes, connections, structure_warnings = _ordered_nodes(data_root)
|
||||
|
||||
status = relationship_error or diagram_relation_error or data_error
|
||||
if status is None and structure_warnings:
|
||||
status = "structure-cycle"
|
||||
persisted_drawing, drawing_warnings = _persisted_drawing_part(
|
||||
zf,
|
||||
parts.get("data"),
|
||||
data_root,
|
||||
slide_part,
|
||||
relationships,
|
||||
)
|
||||
text_items = [node["text"] for node in nodes if node["text"]]
|
||||
return {
|
||||
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
|
||||
"kind": "smartart",
|
||||
"shape_id": shape_id,
|
||||
"shape_name": shape_name,
|
||||
"geometry": _container_geometry(container),
|
||||
"layout": _layout_info(layout_root, data_root),
|
||||
"root_ids": [node["id"] for node in nodes if node["parent_id"] is None],
|
||||
"nodes": nodes,
|
||||
"connections": connections,
|
||||
"text_items": text_items,
|
||||
"node_count": len(nodes),
|
||||
"text_count": len(text_items),
|
||||
"connection_count": len(connections),
|
||||
"max_depth": max((int(node["depth"]) for node in nodes), default=0),
|
||||
"text_extracted": data_root is not None,
|
||||
"has_persisted_drawing": persisted_drawing is not None,
|
||||
"has_fallback_preview": shape_id in fallback_shape_ids,
|
||||
"status": status or "ok",
|
||||
"warnings": metadata_warnings + structure_warnings + drawing_warnings,
|
||||
}
|
||||
|
||||
|
||||
def _failed_diagram(
|
||||
container: ET.Element,
|
||||
*,
|
||||
slide_index: int,
|
||||
order: int,
|
||||
error: Exception,
|
||||
fallback_shape_ids: set[str],
|
||||
) -> dict[str, Any]:
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
return {
|
||||
"diagram_id": f"s{slide_index:02d}_dgm{shape_id}",
|
||||
"kind": "smartart",
|
||||
"shape_id": shape_id,
|
||||
"shape_name": shape_name,
|
||||
"geometry": _container_geometry(container),
|
||||
"layout": {"name": None, "unique_id": None, "categories": []},
|
||||
"root_ids": [],
|
||||
"nodes": [],
|
||||
"connections": [],
|
||||
"text_items": [],
|
||||
"node_count": 0,
|
||||
"text_count": 0,
|
||||
"connection_count": 0,
|
||||
"max_depth": 0,
|
||||
"text_extracted": False,
|
||||
"has_persisted_drawing": False,
|
||||
"has_fallback_preview": shape_id in fallback_shape_ids,
|
||||
"status": "diagram-read-error",
|
||||
"warnings": [f"{type(error).__name__}:{error}"],
|
||||
}
|
||||
|
||||
|
||||
def read_smartart_diagrams(
|
||||
zf: zipfile.ZipFile,
|
||||
slide_part: str,
|
||||
slide_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return SmartArt source facts for one slide part."""
|
||||
slide_root = _read_xml(zf, slide_part)
|
||||
relationship_error: str | None = None
|
||||
try:
|
||||
relationships = _slide_relationships(zf, _rels_name_for_part(slide_part))
|
||||
except ET.ParseError:
|
||||
relationships = {}
|
||||
relationship_error = "invalid-slide-relationships"
|
||||
fallback_shape_ids = _fallback_preview_shape_ids(slide_root)
|
||||
diagrams: list[dict[str, Any]] = []
|
||||
|
||||
for order, container in enumerate(_diagram_containers(slide_root), start=1):
|
||||
try:
|
||||
diagram = _read_diagram_container(
|
||||
zf,
|
||||
container,
|
||||
slide_part=slide_part,
|
||||
slide_index=slide_index,
|
||||
order=order,
|
||||
relationships=relationships,
|
||||
relationship_error=relationship_error,
|
||||
fallback_shape_ids=fallback_shape_ids,
|
||||
)
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError, KeyError, ValueError) as exc:
|
||||
diagram = _failed_diagram(
|
||||
container,
|
||||
slide_index=slide_index,
|
||||
order=order,
|
||||
error=exc,
|
||||
fallback_shape_ids=fallback_shape_ids,
|
||||
)
|
||||
diagrams.append(diagram)
|
||||
return diagrams
|
||||
|
||||
|
||||
def smartart_to_markdown(diagram: dict[str, Any]) -> str:
|
||||
"""Render one extracted SmartArt diagram as hierarchical Markdown."""
|
||||
name = str(diagram.get("shape_name") or diagram.get("diagram_id") or "SmartArt")
|
||||
layout = diagram.get("layout") or {}
|
||||
layout_name = str(layout.get("name") or "") if isinstance(layout, dict) else ""
|
||||
heading = f"### SmartArt: {name}"
|
||||
if layout_name and layout_name.lower() not in name.lower():
|
||||
heading += f" — {layout_name}"
|
||||
lines = [heading, ""]
|
||||
nodes = diagram.get("nodes") or []
|
||||
text_nodes = [node for node in nodes if str(node.get("text") or "").strip()]
|
||||
if text_nodes:
|
||||
nodes_by_id = {
|
||||
str(node.get("id")): node
|
||||
for node in nodes
|
||||
if node.get("id") is not None
|
||||
}
|
||||
rendered_depths: dict[str, int] = {}
|
||||
|
||||
def rendered_depth(node: dict[str, Any]) -> int:
|
||||
node_id = str(node.get("id") or "")
|
||||
if node_id in rendered_depths:
|
||||
return rendered_depths[node_id]
|
||||
parent = nodes_by_id.get(str(node.get("parent_id") or ""))
|
||||
if parent is None:
|
||||
depth = 0
|
||||
else:
|
||||
depth = rendered_depth(parent)
|
||||
if str(parent.get("text") or "").strip():
|
||||
depth += 1
|
||||
rendered_depths[node_id] = depth
|
||||
return depth
|
||||
|
||||
for node in text_nodes:
|
||||
text = " / ".join(str(node.get("text") or "").splitlines()).strip()
|
||||
indent = " " * rendered_depth(node)
|
||||
lines.append(f"{indent}- {text}")
|
||||
elif nodes:
|
||||
lines.append(f"> [SmartArt structure has {len(nodes)} node(s), but no text]")
|
||||
else:
|
||||
status = str(diagram.get("status") or "content-unavailable")
|
||||
if diagram.get("text_extracted") and status == "ok":
|
||||
lines.append("> [SmartArt data is readable but has no semantic nodes]")
|
||||
else:
|
||||
lines.append(f"> [SmartArt content unavailable: {status}]")
|
||||
return "\n".join(lines).rstrip()
|
||||
+55
-53
@@ -2,8 +2,8 @@
|
||||
|
||||
Native templates usually ship an empty ``<p:transition/>`` that renders as no
|
||||
motion, so ``apply`` injects a default transition unless told to ``keep`` the
|
||||
source or set ``none``. Effects come from the shared ``pptx_animations``
|
||||
vocabulary so they match the SVG export path.
|
||||
source or set ``none``. Effects and OOXML mutation come from the shared
|
||||
``pptx_transitions`` core so every PPTX path uses the same writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,12 +11,13 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import NS, P14_NS, _qn
|
||||
|
||||
try:
|
||||
from pptx_animations import TRANSITIONS
|
||||
except ImportError:
|
||||
TRANSITIONS = {}
|
||||
from pptx_transitions import (
|
||||
TRANSITIONS,
|
||||
AdvanceUpdate,
|
||||
EnterUpdate,
|
||||
apply_slide_motion,
|
||||
validate_seconds,
|
||||
)
|
||||
|
||||
# Default page transition injected by `apply` when neither the CLI flag nor a
|
||||
# per-slide plan field asks for something else. Use `keep` to preserve the
|
||||
@@ -28,59 +29,52 @@ KEEP_TRANSITION = "keep"
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _build_transition_element(
|
||||
effect: str,
|
||||
duration: float,
|
||||
advance_after: float | None = None,
|
||||
) -> ET.Element:
|
||||
"""Build a populated <p:transition> element from the shared TRANSITIONS vocabulary."""
|
||||
info = TRANSITIONS[effect]
|
||||
transition = ET.Element(_qn(NS["p"], "transition"))
|
||||
transition.set(_qn(P14_NS, "dur"), str(int(float(duration) * 1000)))
|
||||
if advance_after is not None:
|
||||
transition.set("advTm", str(int(float(advance_after) * 1000)))
|
||||
child = ET.SubElement(transition, _qn(NS["p"], info["element"]))
|
||||
for key, value in info.get("attrs", {}).items():
|
||||
child.set(key, str(value))
|
||||
return transition
|
||||
|
||||
|
||||
def _set_slide_transition(
|
||||
slide_root: ET.Element,
|
||||
*,
|
||||
effect: str | None,
|
||||
duration: float,
|
||||
advance_after: float | None = None,
|
||||
) -> None:
|
||||
"""Replace the cloned slide's transition element (empty in most templates).
|
||||
) -> bool:
|
||||
"""Apply a legacy template-fill transition through the shared core.
|
||||
|
||||
``effect`` of ``None`` or ``"keep"`` leaves the source transition untouched;
|
||||
``"none"`` strips it so the slide advances with no animation. OOXML requires
|
||||
the transition to sit after ``p:clrMapOvr`` and before ``p:timing``.
|
||||
``None`` and ``keep`` preserve the source transition. ``none`` removes the
|
||||
visual transition while retaining an explicitly requested auto-advance.
|
||||
Legacy ``advance_after`` allowed both click and timed advance, so it maps to
|
||||
``both`` rather than the stricter ``after`` mode. The return value reports
|
||||
whether the resulting slide contains an automatic advance.
|
||||
"""
|
||||
if effect is None or effect == KEEP_TRANSITION:
|
||||
return
|
||||
enter = EnterUpdate(policy="preserve")
|
||||
advance = AdvanceUpdate(
|
||||
mode="preserve" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
elif effect == "none":
|
||||
enter = EnterUpdate(policy="none", effect=None, duration=duration)
|
||||
advance = AdvanceUpdate(
|
||||
mode="click" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
else:
|
||||
enter = EnterUpdate(
|
||||
policy="replace",
|
||||
effect=effect,
|
||||
duration=duration,
|
||||
)
|
||||
advance = AdvanceUpdate(
|
||||
mode="click" if advance_after is None else "both",
|
||||
after=advance_after,
|
||||
)
|
||||
|
||||
new_element = None if effect == "none" else _build_transition_element(effect, duration, advance_after)
|
||||
existing = slide_root.find("p:transition", NS)
|
||||
if existing is not None:
|
||||
index = list(slide_root).index(existing)
|
||||
slide_root.remove(existing)
|
||||
if new_element is not None:
|
||||
slide_root.insert(index, new_element)
|
||||
return
|
||||
if new_element is None:
|
||||
return
|
||||
|
||||
timing = slide_root.find("p:timing", NS)
|
||||
if timing is not None:
|
||||
slide_root.insert(list(slide_root).index(timing), new_element)
|
||||
return
|
||||
clr_map_ovr = slide_root.find("p:clrMapOvr", NS)
|
||||
if clr_map_ovr is not None:
|
||||
slide_root.insert(list(slide_root).index(clr_map_ovr) + 1, new_element)
|
||||
return
|
||||
slide_root.append(new_element)
|
||||
try:
|
||||
return apply_slide_motion(
|
||||
slide_root,
|
||||
enter=enter,
|
||||
advance=advance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _resolve_slide_transition(
|
||||
@@ -98,11 +92,19 @@ def _resolve_slide_transition(
|
||||
duration = raw.get("duration", default_duration)
|
||||
advance_after = raw.get("advance_after")
|
||||
else:
|
||||
effect = str(raw)
|
||||
effect = None if raw is None else str(raw)
|
||||
duration = default_duration
|
||||
advance_after = None
|
||||
if effect is not None and effect not in ("none", KEEP_TRANSITION) and effect not in TRANSITIONS:
|
||||
raise RuntimeError(
|
||||
f"Unknown transition effect '{effect}'. Valid: {', '.join(sorted(TRANSITIONS))}, none, {KEEP_TRANSITION}"
|
||||
)
|
||||
return effect, float(duration), advance_after
|
||||
try:
|
||||
resolved_duration = validate_seconds(
|
||||
duration,
|
||||
"transition duration",
|
||||
allow_zero=False,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return effect, resolved_duration, advance_after
|
||||
|
||||
+27
-88
@@ -5,11 +5,10 @@
|
||||
|
||||
## 0. 上游规范引用
|
||||
|
||||
本文档是 **图表模板专用** 的美学与实现规范。所有图表同时必须遵守项目级通用技术约束:
|
||||
|
||||
> **[`references/shared-standards.md`](../../references/shared-standards.md)** — SVG 禁用特性黑名单、PPT 兼容性替代、Canvas 格式、tspan 内联规则、分组规范、阴影/叠加技术、后处理管线
|
||||
|
||||
以下章节摘录了 shared-standards 中与图表模板最密切相关的条目。完整细节(如 marker 条件约束、clipPath 条件约束、弧线路径计算公式等)请查阅上游文档。
|
||||
本文档只定义 **图表模板专用** 的美学与实现配方。项目级 SVG
|
||||
创作、兼容性例外与条件映射统一以
|
||||
[`references/shared-standards.md`](../../references/shared-standards.md)
|
||||
为权威;本指南不摘录、不放宽该合同。
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +74,6 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
```
|
||||
|
||||
- 纯英文场景可省略 `'PingFang SC', 'Microsoft YaHei'`
|
||||
- **禁止** 使用 `@font-face`、外部字体、`<style>` 标签
|
||||
|
||||
### 2.2 字号层级
|
||||
|
||||
@@ -90,23 +88,9 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
> **最小字号下限:12px**。所有文本不得小于 12px。
|
||||
|
||||
### 2.3 tspan 规范
|
||||
### 2.3 同一图表标签的内联格式
|
||||
|
||||
所有 `<text>` 元素的文本内容 **必须** 包裹在 `<tspan>` 中:
|
||||
|
||||
```xml
|
||||
<!-- 正确 -->
|
||||
<text x="60" y="80" font-size="34" fill="#0F172A">
|
||||
<tspan>图表标题</tspan>
|
||||
</text>
|
||||
|
||||
<!-- 错误 -->
|
||||
<text x="60" y="80" font-size="34" fill="#0F172A">图表标题</text>
|
||||
```
|
||||
|
||||
### 2.4 内联格式化规则(shared-standards SS4)
|
||||
|
||||
**单逻辑行 = 单 `<text>`**。同一行内需要多色/多粗细时,用内联 `<tspan>` 实现,**不要**用多个并排 `<text>`:
|
||||
当一个图表标签需要在 PPT 中保持为单一可编辑文本框时,把同一逻辑行写进一个 `<text>`,并用内联 `<tspan>` 表达多色或多粗细。若本来就需要多个独立文本框,则可使用多个 `<text>`:
|
||||
|
||||
```xml
|
||||
<!-- 正确:一个 text frame,三个 run -->
|
||||
@@ -114,15 +98,15 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
实现<tspan fill="#3B82F6" font-weight="bold">10倍</tspan>效率提升
|
||||
</text>
|
||||
|
||||
<!-- 错误:三个独立 text frame,PPT 中无法作为一行编辑 -->
|
||||
<!-- 若目标是单一文本框,则不适用:下面会生成三个独立 text frame -->
|
||||
<text x="100" y="200">实现</text>
|
||||
<text x="160" y="200" fill="#3B82F6">10倍</text>
|
||||
<text x="240" y="200">效率提升</text>
|
||||
```
|
||||
|
||||
> 内联 tspan **不得** 携带 `x` / `y` / `dy`,否则后处理会将其拆分为独立 text frame。`dx` 可用于微调字距。
|
||||
> 只有需要留在同一文本框内的 inline tspan 才不带 `x` / `y` / `dy`;带这些定位属性的 tspan 会按独立文本框处理。`dx` 可用于微调字距。
|
||||
|
||||
### 2.5 数据高亮默认行为
|
||||
### 2.4 数据高亮默认行为
|
||||
|
||||
图表中的关键数据文本应默认高亮:
|
||||
- **数值结果** — 百分比、倍数、金额 → `<tspan fill="主题色" font-weight="bold">`
|
||||
@@ -133,7 +117,9 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
## 3. 阴影滤镜
|
||||
|
||||
`<filter>` 本身是允许的、且是 PPT 阴影/发光的官方推荐路径(详见本节末尾的"禁用列表"说明)。本节统一阴影 primitive 写法——使用 `feFlood` 方案,**禁止** `<filter>` 内部使用 `<feComponentTransfer>`:
|
||||
本节只规定图表模板库采用的阴影配方,不定义项目级 filter
|
||||
支持边界。图表阴影统一使用 `feFlood` 方案,本指南不采用
|
||||
`<feComponentTransfer>`:
|
||||
|
||||
```xml
|
||||
<filter id="chartShadow" x="-15%" y="-15%" width="130%" height="130%">
|
||||
@@ -156,19 +142,20 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
| 中型元素(柱子、箱体) | 2-3 | 1-2 | 0.10-0.15 |
|
||||
| 轻型元素(底部卡片) | 4-6 | 2-4 | 0.06-0.08 |
|
||||
|
||||
### 禁用列表
|
||||
### 本指南实现约定
|
||||
|
||||
- `flood-color="#000000"` → 必须用 `#0F172A`
|
||||
- `<feComponentTransfer>` + `<feFuncA slope=...>` → 用 `<feFlood flood-color flood-opacity>` 替代
|
||||
- `flood-opacity > 0.20` → 阴影过重,最大 0.15-0.20
|
||||
|
||||
> **被禁的是 sub-element,不是 `<filter>` 本身。** `<filter>` 是 PPT Master 允许的、官方推荐的阴影/发光路径(见 [`shared-standards.md`](../../references/shared-standards.md) §1 黑名单不含 filter、§6 把 filter shadow 列为 drop-shadow 的官方实现),转换器 [`svg_to_pptx/drawingml/styles.py`](../../scripts/svg_to_pptx/drawingml/styles.py) 也主动把 `feGaussianBlur` + `feOffset` + `feFlood` + `feComposite` + `feMerge`(以及 `feDropShadow` 简写)映射成 DrawingML `<a:outerShdw>`。
|
||||
> 这是图表模板的色彩一致性约定,不是项目级 SVG 黑名单。单独排除
|
||||
> `feComponentTransfer/feFuncA(slope)`,是因为它只能调透明度、无法携带
|
||||
> 颜色,容易让阴影退回纯黑,与同页使用 `feFlood`
|
||||
> `flood-color="#0F172A"` 的卡片产生肉眼可见的冷暖色差。
|
||||
>
|
||||
> 单独禁 `feComponentTransfer/feFuncA(slope)` 的原因:**它物理上只能调透明度、无法携带颜色**。转换器读到 `feFuncA slope` 时只把它当作 alpha,颜色字段保持默认 `'000000'`——SVG 端看起来阴影颜色正常(因为 SourceAlpha 本身是黑),但导出到 PPTX 后阴影颜色会被定死成纯黑 `#000000`,与同页其他用 `feFlood flood-color="#0F172A"` 的卡片产生肉眼可见的冷暖色差。
|
||||
>
|
||||
> 简言之:**用 filter 没问题,但 primitive 必须能把"颜色"显式表达出来;只能表达"透明度"的 primitive 是被禁的。**
|
||||
> 简言之:本库的图表阴影必须显式表达颜色,而不能只表达透明度。
|
||||
|
||||
### 阴影使用原则(shared-standards SS6)
|
||||
### 图表阴影使用原则
|
||||
|
||||
> **阴影是美学成分,不是默认处理。** 克制而非丰富才能产生"经过设计"的感觉。 "阴影被感知而非被看见" 是高端美学标准。
|
||||
|
||||
@@ -222,7 +209,7 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
## 5. 结构规范
|
||||
|
||||
### 5.1 层级分组(shared-standards SS4 Grouping)
|
||||
### 5.1 图表层级分组
|
||||
|
||||
使用 `<g id="...">` 进行语义分组,便于 PPT 中逐个操作/动画:
|
||||
|
||||
@@ -241,7 +228,7 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
</g>
|
||||
```
|
||||
|
||||
**分组单元参考**(来自 shared-standards):
|
||||
**图表模板分组单元**:
|
||||
|
||||
| 分组单元 | 包含内容 |
|
||||
|---------|---------|
|
||||
@@ -254,8 +241,6 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
**命名约定**:使用描述性 `id`(如 `card-1`、`step-discover`、`header`、`footer`)。
|
||||
|
||||
> 只有 `<g opacity="...">` 被禁止(见 SS2)。纯结构 `<g>` 是必需的。
|
||||
|
||||
### 5.2 viewBox
|
||||
|
||||
固定为 `0 0 1280 720`(PPT 16:9),不可修改。
|
||||
@@ -278,52 +263,11 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
---
|
||||
|
||||
## 6. SVG 禁用特性与兼容性(shared-standards SS1-2)
|
||||
## 6. 通用 SVG 技术约束
|
||||
|
||||
### 6.1 绝对禁止
|
||||
|
||||
| 禁用特性 | 替代方案 |
|
||||
|---------|---------|
|
||||
| HTML 命名实体(` ` `—` `©` `–` `®` `…` `•` …) | 直接写原生 Unicode 字符(`—` `–` `©` `®` `→` NBSP …) |
|
||||
| 文本/属性值中裸写 `& < > " '` | 必须写成 XML 实体 `&` `<` `>` `"` `'` |
|
||||
| `<style>` / `class` | 内联属性(`id` 在 `<defs>` 内合法) |
|
||||
| `<foreignObject>` | `<text>` + `<tspan>` |
|
||||
| `mask` | 叠加遮罩矩形 / gradient overlay |
|
||||
| `<symbol>` + `<use>` | 直接写出完整元素 |
|
||||
| `textPath` | 手动排列 `<text>` |
|
||||
| `@font-face` | 系统字体栈 |
|
||||
| `<animate*>` / `<set>` | 无(PPT 侧处理动画) |
|
||||
| `<script>` / event 属性 | 无 |
|
||||
| `<iframe>` | 无 |
|
||||
|
||||
### 6.2 PPT 兼容性替代
|
||||
|
||||
| 禁止语法 | 正确替代 |
|
||||
|---------|----------|
|
||||
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
|
||||
| `<g opacity="0.2">...</g>` | 在每个子元素上单独设置 `fill-opacity` / `stroke-opacity` |
|
||||
| `<image opacity="0.3"/>` | 在 image 后叠加 `<rect fill="背景色" opacity="0.7"/>` |
|
||||
|
||||
### 6.3 条件允许
|
||||
|
||||
| 特性 | 条件 | 转换结果 |
|
||||
|------|------|----------|
|
||||
| `marker-start` / `marker-end` | `<marker>` 在 `<defs>` 中,`orient="auto"`,形状为三角/菱形/圆 | DrawingML `<a:headEnd>` / `<a:tailEnd>` |
|
||||
| `clipPath` on `<image>` | `<clipPath>` 在 `<defs>` 中,单子元素,**仅用于 image** | DrawingML `<a:prstGeom>` / `<a:custGeom>` |
|
||||
| `stroke-dasharray` | 使用预设值 `4,4` / `2,2` / `8,4` / `8,4,2,4` | PPTX `<a:prstDash>` |
|
||||
| `text-decoration` | `underline` / `line-through` | PPTX 原生文本格式 |
|
||||
| `transform="rotate(...)"` | 所有元素类型均支持 | PPTX `<a:xfrm rot="...">` |
|
||||
|
||||
> 完整条件约束见 [`shared-standards.md`](../../references/shared-standards.md) SS1.1(marker 约束)和 SS1.2(clipPath 约束)。
|
||||
|
||||
### 6.4 虚线预设对照
|
||||
|
||||
| SVG 值 | PPTX 预设 | 适用场景 |
|
||||
|--------|-----------|---------|
|
||||
| `4,4` | Dash | 通用虚线、分隔线 |
|
||||
| `2,2` | Dot (sysDot) | 占位轮廓、细边框 |
|
||||
| `8,4` | Long dash | 时间线连接、流程箭头 |
|
||||
| `8,4,2,4` | Long dash-dot | 技术图纸、尺寸线 |
|
||||
本指南不定义或摘录项目级 SVG 允许项、禁用项与条件映射。当前合同统一见
|
||||
[`shared-standards.md`](../../references/shared-standards.md);新增或修改模板时,
|
||||
必须对目标文件运行 `svg_quality_checker.py` 并通过校验。
|
||||
|
||||
---
|
||||
|
||||
@@ -413,16 +357,12 @@ font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Micr
|
||||
|
||||
### 排版
|
||||
- [ ] 无 `font-size < 12` 的文本
|
||||
- [ ] 所有 `<text>` 内容包裹 `<tspan>`
|
||||
- [ ] 同一行多格式用内联 `<tspan>`,**非**多个并排 `<text>`
|
||||
- [ ] 内联 `<tspan>` 不携带 `x` / `y` / `dy`
|
||||
- [ ] 需要保持为单一 PPT 文本框的多格式标签使用 inline `<tspan>`;只有这类 inline run 不携带 `x` / `y` / `dy`
|
||||
- [ ] 标题 34px、副标题 18px、来源 14px
|
||||
|
||||
### 结构
|
||||
- [ ] 主要元素有语义化 `<g id="...">`
|
||||
- [ ] 无 `<style>`、`class`、`<foreignObject>`、`mask`、`rgba()`
|
||||
- [ ] `<g>` 标签无 `opacity` 属性
|
||||
- [ ] 文本字符为原生 Unicode(`—` `©` `→` NBSP 等),无 HTML 命名实体(` ` `—` `©` 等);裸 `& < >` 已转义为 `& < >`
|
||||
- [ ] `svg_quality_checker.py` 对目标模板通过;通用 SVG 合同不在本清单复述
|
||||
|
||||
### 阴影
|
||||
- [ ] 使用 `feFlood` 方案(非 `feComponentTransfer`)
|
||||
@@ -640,4 +580,3 @@ echo "Small fonts:" && grep -c 'font-size="[0-9]"' "skills/ppt-master/templates/
|
||||
| §11.5 倾斜虚线连接箭头 | `matrix_2x2.svg` |
|
||||
| §11.6 接地椭圆 | `team_roster.svg` |
|
||||
| §11.7 双向交互箭头 | `client_server_flow.svg` |
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
Purpose: Qualitative assessment, scoring, and vendor comparison
|
||||
Use cases: Vendor evaluations, project status, skill matrices
|
||||
Design Principles: Left-aligned features, centered Harvey balls, clean minimalist consulting grid
|
||||
Note: Harvey ball shapes are inlined (no <use>/<defs>) so the SVG-to-PPTX converter renders them.
|
||||
Note: Harvey ball shapes stay inlined for direct per-cell editing.
|
||||
Static same-document <use> is supported, but PPTX import does not reconstruct reuse structure.
|
||||
-->
|
||||
<rect width="1280" height="720" fill="#FFFFFF"/>
|
||||
<!-- ==================== Header ==================== -->
|
||||
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -42,7 +42,7 @@ primary_color: "#XXXXXX"
|
||||
## VIII. SVG Page Roster # Structure — file list + per-file purpose
|
||||
```
|
||||
|
||||
Decks may include additional supporting sections (Voice & Tone, Icon Style, Layout Modes, Spacing Specification, SVG Technical Constraints, Placeholder Specification, Asset Specification, Usage Notes). Use them when meaningful for the replica.
|
||||
Decks may include additional supporting sections (Voice & Tone, Icon Style, Layout Modes, Spacing Specification, Placeholder Specification, Asset Specification, Usage Notes). Put a template-specific required / forbidden / conditional boundary inside the personality section it qualifies. General SVG/PPT authoring rules belong only in [`shared-standards.md`](../../references/shared-standards.md); do not add a generic technical-rules section or pointer to a deck spec.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-13
@@ -99,6 +99,7 @@ primary_color: "#C00000"
|
||||
- Chapter pages are cleaner section-divider pages and should not inherit the content-page header ribbon.
|
||||
- Each page should contain at most one formal logo mark; sidebars should rely on slogan and skyline imagery instead of repeated logo lockups.
|
||||
- The content page remains open-canvas by default and should not reserve a large fixed sidebar.
|
||||
- Repeated telecom motifs use one reusable structure family rather than unrelated decorative variants.
|
||||
|
||||
---
|
||||
|
||||
@@ -180,18 +181,7 @@ primary_color: "#C00000"
|
||||
|
||||
---
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
1. `viewBox` must remain `0 0 1280 720`
|
||||
2. Do not use `mask`, `<style>`, `class`, `foreignObject`, or `rgba()`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
3. Use plain vector geometry and `<image>` references to packaged assets only
|
||||
4. Transparency must use `fill-opacity` / `stroke-opacity`
|
||||
5. Text wrapping should be handled with `<tspan>` if needed
|
||||
6. Avoid PPT-fragile decorative complexity; simplify repeated motifs into reusable structures
|
||||
|
||||
---
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
| Placeholder | Purpose | Applicable Page |
|
||||
| --- | --- | --- |
|
||||
@@ -214,8 +204,9 @@ primary_color: "#C00000"
|
||||
|
||||
---
|
||||
|
||||
## XII. Usage Guide
|
||||
## XI. Usage Guide
|
||||
|
||||
1. Reuse `logo.png` and `footer_ribbon.png` as fixed brand assets; `slogan_red.png` and `skyline_bg.png` should be used selectively and only on the cover / ending pages.
|
||||
2. Keep generated text inside the documented safe areas; only chapter pages use a strong left/right split.
|
||||
3. Prefer red emphasis only for structure and key figures; do not over-saturate the content area.
|
||||
4. Reference only assets packaged with this template.
|
||||
|
||||
+4
-25
@@ -98,6 +98,8 @@ primary_color: "#00418D"
|
||||
| **Content** | y=100, h=560px | Main content area |
|
||||
| **Footer** | y=680, h=40px | Page number, company name, bottom decorative line |
|
||||
|
||||
**Required background layer**: Every page retains the template's full-canvas background layer.
|
||||
|
||||
---
|
||||
|
||||
## VI. Page Types
|
||||
@@ -178,30 +180,7 @@ primary_color: "#00418D"
|
||||
|
||||
---
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox fixed at `0 0 1280 720`
|
||||
2. Background must include a full-screen `<rect>`
|
||||
3. Text wrapping via `<tspan>`
|
||||
4. Opacity must use `fill-opacity` / `stroke-opacity`
|
||||
5. `marker-start` / `marker-end` conditionally allowed — see shared-standards.md §1.1 (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval)
|
||||
|
||||
### Forbidden Elements (Blacklist)
|
||||
|
||||
- `mask` (masking); `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
- `<style>`, `class` (stylesheets; `id` within `<defs>` is allowed)
|
||||
- `foreignObject` (foreign objects)
|
||||
- `textPath` (text on path)
|
||||
- `animate`, `animateTransform`, `set` (animations)
|
||||
|
||||
- `rgba()` color format (must use hex + opacity)
|
||||
- `<g opacity="...">` (group opacity — set individually on each element)
|
||||
|
||||
---
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
| Placeholder | Description |
|
||||
| -------------------- | ------------------ |
|
||||
@@ -222,7 +201,7 @@ primary_color: "#00418D"
|
||||
|
||||
---
|
||||
|
||||
## XII. Usage Notes (Recommended)
|
||||
## XI. Usage Notes (Recommended)
|
||||
|
||||
1. **Logo Adaptation**: Cover and ending pages use inverted (white) Logo; content page upper-right uses color or inverted Logo.
|
||||
2. **Image Assets**: Ensure the `images/` folder under the template directory contains necessary Logo files.
|
||||
|
||||
+2
-22
@@ -183,27 +183,7 @@ primary_color: "#004098"
|
||||
|
||||
---
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Text wrapping via `<tspan>` (no `<foreignObject>`)
|
||||
4. Opacity via `fill-opacity` / `stroke-opacity`, no `rgba()`
|
||||
5. Forbidden: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Forbidden: `textPath`, `animate*`, `script`
|
||||
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- No `<g opacity="...">` (group opacity) — set opacity on each child element individually
|
||||
- Use overlay layers for image transparency
|
||||
- Inline styles only — no external CSS or `@font-face`
|
||||
|
||||
---
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
Templates use `{{PLACEHOLDER}}` format. Common placeholders:
|
||||
|
||||
@@ -226,7 +206,7 @@ Templates use `{{PLACEHOLDER}}` format. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XII. Usage Notes (Recommended)
|
||||
## XI. Usage Notes (Recommended)
|
||||
|
||||
1. **Template Deployment**: Copy the template to your project directory.
|
||||
2. **Asset Replacement**: Replace `大型 logo.png` (592×238) and `右上角 logo.png` (113×50) in the `images` directory.
|
||||
|
||||
+7
-12
@@ -140,15 +140,7 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
|
||||
| **Title to Body** | 44px |
|
||||
| **Footer Offset** | 32px from bottom |
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
1. `viewBox` must stay `0 0 1280 720`
|
||||
2. No `mask`, `<style>`, `class`, `foreignObject`, `textPath`, or animation tags. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
3. Use plain hex colors with `fill-opacity` / `stroke-opacity`
|
||||
4. Keep image assets semantic and minimal
|
||||
5. Prefer vector reconstruction over embedding PPT-export fragments
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
| Placeholder | Description |
|
||||
| --- | --- |
|
||||
@@ -184,7 +176,7 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
|
||||
| `{{CONTACT_EMAIL}}` | Ending email address |
|
||||
| `{{CONTACT_PHONE}}` | Ending phone number |
|
||||
|
||||
## XII. Asset Specification
|
||||
## XI. Asset Specification
|
||||
|
||||
### Core Assets
|
||||
|
||||
@@ -206,7 +198,10 @@ Reference slides read before generation: `1, 2, 3, 4, 6, 9, 11, 13, 16, 18`.
|
||||
|
||||
Core assets are wired into SVG templates. `logo_dark.png` is used on light pages (TOC, content); `logo_white.png` and `cover_bg.png` on dark pages (cover, chapter, ending). Optional assets are available for project-specific customization.
|
||||
|
||||
## XIII. Chart Specifications
|
||||
- Keep bundled image assets semantic and minimal.
|
||||
- Reconstruct editable template geometry instead of embedding fragments exported from PowerPoint.
|
||||
|
||||
## XII. Chart Specifications
|
||||
|
||||
### Recommended Chart Dimensions
|
||||
|
||||
@@ -228,7 +223,7 @@ Core assets are wired into SVG templates. `logo_dark.png` is used on light pages
|
||||
| Negative indicator | `#E74C3C` |
|
||||
| Neutral | `#666666` |
|
||||
|
||||
## XIV. Usage Instructions
|
||||
## XIII. Usage Instructions
|
||||
|
||||
1. Copy the template directory to the project `templates/` folder
|
||||
2. Read this design specification to understand the visual system
|
||||
|
||||
+3
-20
@@ -196,6 +196,8 @@ Small circle elements representing the nighttime lights of the Mountain City:
|
||||
- Content page header: Width 160-200px
|
||||
- Ending page: Width 320-400px
|
||||
|
||||
Use the packaged university logo assets through the standard project image pipeline.
|
||||
|
||||
---
|
||||
|
||||
## IX. Spacing Specification
|
||||
@@ -210,26 +212,7 @@ Small circle elements representing the nighttime lights of the Mountain City:
|
||||
|
||||
---
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Define gradients using `<linearGradient>` inside `<defs>`
|
||||
3. Use `fill-opacity` / `stroke-opacity` for transparency
|
||||
4. Use `<tspan>` for text wrapping
|
||||
5. Use Base64 inline or `<image>` reference for logos
|
||||
|
||||
### Prohibited Elements
|
||||
|
||||
- `mask`, `<style>`, `class`; `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
- `foreignObject`, `textPath`, `animate*`
|
||||
- `rgba()` color format
|
||||
- `<g opacity="...">` (group opacity)
|
||||
|
||||
---
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
| Placeholder | Description |
|
||||
| -------------------- | ---------------------- |
|
||||
|
||||
+1
-23
@@ -81,7 +81,7 @@
|
||||
<stop offset="100%" stop-color="#[secondary accent]"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Background decorative gradient (note: rgba forbidden, use stop-opacity) -->
|
||||
<!-- Background decorative gradient (explicit stop-opacity keeps the palette reusable) -->
|
||||
<radialGradient id="bgDecor" cx="80%" cy="20%" r="50%">
|
||||
<stop offset="0%" stop-color="#[primary]" stop-opacity="0.15"/>
|
||||
<stop offset="100%" stop-color="#[primary]" stop-opacity="0"/>
|
||||
@@ -349,25 +349,3 @@ One speaker note file per page, saved to `notes/`:
|
||||
|
||||
- **Filename**: match SVG name (e.g., `01_cover.md`)
|
||||
- **Content**: script key points, timing cues, transition phrases
|
||||
|
||||
---
|
||||
|
||||
## XI. Technical Constraints Reminder
|
||||
|
||||
### SVG Generation Must Follow:
|
||||
|
||||
1. viewBox: `{canvas_info['viewbox']}`
|
||||
2. Background uses `<rect>` elements
|
||||
3. Text wrapping uses `<tspan>` (`<foreignObject>` FORBIDDEN)
|
||||
4. Transparency uses `fill-opacity` / `stroke-opacity`; `rgba()` FORBIDDEN
|
||||
5. FORBIDDEN: `mask`, `<style>`, `class`, `foreignObject`
|
||||
6. FORBIDDEN: `textPath`, `animate*`, `script`
|
||||
7. Text characters: write typography & symbols as raw Unicode (em dash `—`, en dash `–`, `©`, `®`, `→`, NBSP, etc.); HTML named entities (` `, `—`, `©`, `®` …) are FORBIDDEN. XML reserved chars in text MUST be escaped as `&` `<` `>` `"` `'` (e.g. `R&D`, `error < 5%`). See shared-standards.md §1.0
|
||||
7. `marker-start` / `marker-end` conditionally allowed: `<marker>` must be in `<defs>`, `orient="auto"`, shape must be triangle / diamond / circle (see shared-standards.md §1.1)
|
||||
8. `clipPath` conditionally allowed **only on `<image>` elements**: `<clipPath>` in `<defs>`, single shape child (circle / ellipse / rect with rx,ry / path / polygon). Do NOT apply to shapes / groups / text — draw the target geometry directly with the matching native element (`<circle>` / `<ellipse>` / `<rect rx>` / `<polygon>` / `<path>`). See shared-standards.md §1.2
|
||||
|
||||
### PPT Compatibility Rules:
|
||||
|
||||
- `<g opacity="...">` FORBIDDEN (group opacity); set on each child element individually
|
||||
- Image transparency uses overlay mask layer (`<rect fill="bg-color" opacity="0.x"/>`)
|
||||
- Inline styles only; external CSS and `@font-face` FORBIDDEN
|
||||
|
||||
@@ -39,7 +39,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
## V. SVG Page Roster # File list + per-file purpose
|
||||
```
|
||||
|
||||
Layouts may include additional supporting sections (Layout Patterns, Spacing Guidelines, SVG Technical Constraints, Placeholder Specification, Usage Notes). Do **not** include Color Scheme or Typography sections — those are identity-segment fields owned by `templates/brands/` and `templates/decks/`.
|
||||
Layouts may include additional supporting sections (Layout Patterns, Spacing Guidelines, Placeholder Specification, Usage Notes). Put a layout-specific required / forbidden / conditional boundary inside the personality section it qualifies. Do **not** include Color Scheme or Typography sections — those are identity-segment fields owned by `templates/brands/` and `templates/decks/`. General SVG/PPT authoring rules belong only in [`shared-standards.md`](../../references/shared-standards.md); do not add a generic technical-rules section or pointer to a layout spec.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +75,6 @@ The register step updates [`layouts_index.json`](./layouts_index.json) — the s
|
||||
|
||||
---
|
||||
|
||||
## SVG technical constraints
|
||||
## SVG technical authority
|
||||
|
||||
See [`shared-standards.md`](../../references/shared-standards.md) for the authoritative ban list (PPT incompatibilities, raw-character rules, clipPath conditional allowance, etc.). Layouts must comply.
|
||||
[`shared-standards.md`](../../references/shared-standards.md) is authoritative for general SVG/PPT required, forbidden, and conditional rules. Layout specs add only constraints unique to that layout.
|
||||
|
||||
+3
-23
@@ -142,27 +142,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
|
||||
---
|
||||
|
||||
## VIII. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
|
||||
4. Use `fill-opacity` / `stroke-opacity` for transparency; no `rgba()`
|
||||
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Prohibited: `textPath`, `animate*`, `script`
|
||||
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
|
||||
- Use overlay layers for image transparency
|
||||
- Inline styles only; no external CSS or `@font-face`
|
||||
|
||||
---
|
||||
|
||||
## IX. Placeholder Specification
|
||||
## VIII. Placeholder Specification
|
||||
|
||||
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
@@ -194,7 +174,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## X. Component Specifications
|
||||
## IX. Component Specifications
|
||||
|
||||
### 1. Tag
|
||||
|
||||
@@ -227,7 +207,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XI. Usage Instructions
|
||||
## X. Usage Instructions
|
||||
|
||||
1. Copy the template to the project directory
|
||||
2. Select the appropriate page template based on defense content needs
|
||||
|
||||
+3
-26
@@ -256,30 +256,7 @@ This template emulates the visual language of telecom technical reports. The cor
|
||||
|
||||
---
|
||||
|
||||
## X. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Use `<tspan>` for text wrapping (**`<foreignObject>` is strictly prohibited**)
|
||||
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
|
||||
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject` (`id` inside `<defs>` is allowed). `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Prohibited: `textPath`, `animate*`, `script`
|
||||
7. Prohibited: `<symbol>+<use>`, `<iframe>`, `@font-face`
|
||||
8. Prohibited: `<g opacity="...">` (group opacity) — set opacity on each child element individually
|
||||
9. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
10. Use only system fonts and inline styles
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- Use overlay layers instead of image opacity
|
||||
- Define gradients using `<linearGradient>` inside `<defs>`
|
||||
- Use `rx`/`ry` attributes for rounded rectangles (post-processing converts to Path)
|
||||
|
||||
---
|
||||
|
||||
## XI. Placeholder Specification
|
||||
## X. Placeholder Specification
|
||||
|
||||
The template uses `{{PLACEHOLDER}}` format placeholders:
|
||||
|
||||
@@ -304,7 +281,7 @@ The template uses `{{PLACEHOLDER}}` format placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XII. Usage Notes
|
||||
## XI. Usage Notes
|
||||
|
||||
1. Copy this template directory to the project `templates/` directory
|
||||
2. Review `reference_style.svg` to understand the core visual style
|
||||
@@ -315,7 +292,7 @@ The template uses `{{PLACEHOLDER}}` format placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XIII. Design Highlights
|
||||
## XII. Design Highlights
|
||||
|
||||
- **Telecom DNA**: Derived from real telecom AI operations architecture reports, naturally suited for telecom/enterprise presentation styles
|
||||
- **High Information Density**: A single page can accommodate a complete architecture view (objectives → results → scenarios → orchestration → foundational capabilities)
|
||||
|
||||
+3
-23
@@ -140,27 +140,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
|
||||
---
|
||||
|
||||
## VIII. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
|
||||
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
|
||||
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Prohibited: `textPath`, `animate*`, `script`
|
||||
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
|
||||
- Use overlay layers instead of image opacity
|
||||
- Use inline styles only; external CSS and `@font-face` are prohibited
|
||||
|
||||
---
|
||||
|
||||
## IX. Placeholder Specification
|
||||
## VIII. Placeholder Specification
|
||||
|
||||
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
@@ -184,7 +164,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## X. Usage Instructions
|
||||
## IX. Usage Instructions
|
||||
|
||||
1. Copy the template to the project directory
|
||||
2. Replace logo files in the images directory (if applicable)
|
||||
@@ -194,7 +174,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XI. Design Highlights
|
||||
## X. Design Highlights
|
||||
|
||||
- **Tech Gradient**: Bright-to-deep blue gradient reflects a modern tech aesthetic
|
||||
- **Geometric Decorative Elements**: Circles and grids add a tech atmosphere
|
||||
|
||||
+3
-23
@@ -135,27 +135,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
|
||||
---
|
||||
|
||||
## VIII. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
|
||||
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
|
||||
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Prohibited: `textPath`, `animate*`, `script`
|
||||
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
|
||||
- Use overlay layers instead of image opacity
|
||||
- Use inline styles only; external CSS and `@font-face` are prohibited
|
||||
|
||||
---
|
||||
|
||||
## IX. Placeholder Specification
|
||||
## VIII. Placeholder Specification
|
||||
|
||||
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
@@ -178,7 +158,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## X. Usage Instructions
|
||||
## IX. Usage Instructions
|
||||
|
||||
1. Copy the template to the project directory
|
||||
2. Replace logo files in the images directory (if applicable)
|
||||
@@ -188,7 +168,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XI. Design Highlights
|
||||
## X. Design Highlights
|
||||
|
||||
- **Dual-Color Gradient Top Decoration**: Red-blue gradient reflects a government style
|
||||
- **Gold Accent Elements**: Adds a sense of dignity
|
||||
|
||||
+3
-30
@@ -142,34 +142,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
|
||||
---
|
||||
|
||||
## VIII. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
- viewBox fixed at `0 0 1280 720`
|
||||
- Use `<rect>` elements for backgrounds
|
||||
- Use `<tspan>` for text wrapping
|
||||
- All colors in HEX format (no rgba)
|
||||
- Use `fill-opacity` / `stroke-opacity` for transparency
|
||||
|
||||
### Prohibited Elements (PPT Incompatible)
|
||||
|
||||
| Prohibited Item | Alternative |
|
||||
| -------------------- | ------------------------------ |
|
||||
| `clipPath` | Allowed only on `<image>` under `shared-standards.md` §1.2 |
|
||||
| `mask` | Do not use masking |
|
||||
| `<style>` | Use inline styles |
|
||||
| `class` | Use inline attributes (`id` inside `<defs>` is allowed) |
|
||||
| `foreignObject` | Use `<tspan>` for wrapping |
|
||||
| `textPath` | Use standard `<text>` |
|
||||
| `animate*` / `set` | Do not use animations |
|
||||
| `<g opacity>` | Set opacity on each element individually |
|
||||
|
||||
> `marker-start` / `marker-end` are conditionally allowed — see `shared-standards.md` §1.1 (marker must be in `<defs>`, `orient="auto"`, shape = triangle / diamond / oval). The converter maps them to native DrawingML arrow heads.
|
||||
|
||||
---
|
||||
|
||||
## IX. Placeholder Specification
|
||||
## VIII. Placeholder Specification
|
||||
|
||||
| Placeholder | Usage |
|
||||
| ------------------- | ---------------------------- |
|
||||
@@ -197,7 +170,7 @@ page_types: [cover, toc, chapter, content, ending]
|
||||
|
||||
---
|
||||
|
||||
## X. Usage Notes
|
||||
## IX. Usage Notes
|
||||
|
||||
### 1. Copy Template to Project
|
||||
|
||||
@@ -213,7 +186,7 @@ cp templates/layouts/medical_university/* projects/<project>/templates/
|
||||
|
||||
---
|
||||
|
||||
## XI. Medical Content-Specific Components
|
||||
## X. Medical Content-Specific Components
|
||||
|
||||
### Data Card (Vital Signs)
|
||||
|
||||
|
||||
+4
-25
@@ -173,7 +173,7 @@ Apply glow filters to key text/elements:
|
||||
<text filter="url(#glowGreen)" fill="#39FF14">Glowing Text</text>
|
||||
```
|
||||
|
||||
> **Note**: `filter` effects are typically ignored in PPT, but render well in SVG-compatible viewers.
|
||||
> **Conditional enhancement**: Glow filters may enrich the retro treatment, but the core pixel composition must remain legible without them.
|
||||
|
||||
### Emoji Usage
|
||||
|
||||
@@ -192,28 +192,7 @@ Apply glow filters to key text/elements:
|
||||
|
||||
---
|
||||
|
||||
## IX. SVG Technical Constraints
|
||||
|
||||
### Mandatory Rules
|
||||
|
||||
1. viewBox: `0 0 1280 720`
|
||||
2. Use `<rect>` elements for backgrounds
|
||||
3. Use `<tspan>` for text wrapping (no `<foreignObject>`)
|
||||
4. Use `fill-opacity` / `stroke-opacity` for transparency; `rgba()` is prohibited
|
||||
5. Prohibited: `mask`, `<style>`, `class`, `foreignObject`. `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2
|
||||
6. Prohibited: `textPath`, `animate*`, `script`
|
||||
7. `marker-start` / `marker-end` conditionally allowed (marker in `<defs>`, `orient="auto"`, shape = triangle/diamond/oval) — see shared-standards.md §1.1
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
- No `<g opacity="...">` (group opacity); set opacity on each child element individually
|
||||
- Use overlay layers instead of image opacity
|
||||
- Use inline styles only; external CSS and `@font-face` are prohibited
|
||||
- `filter` effects serve as enhancements (allowed) and do not affect baseline display
|
||||
|
||||
---
|
||||
|
||||
## X. Placeholder Specification
|
||||
## IX. Placeholder Specification
|
||||
|
||||
Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
@@ -235,7 +214,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XI. Usage Instructions
|
||||
## X. Usage Instructions
|
||||
|
||||
1. Copy the template to the project `templates/` directory
|
||||
2. Select the appropriate page template based on content requirements
|
||||
@@ -246,7 +225,7 @@ Templates use `{{PLACEHOLDER}}` format placeholders. Common placeholders:
|
||||
|
||||
---
|
||||
|
||||
## XII. Color Quick Reference
|
||||
## XI. Color Quick Reference
|
||||
|
||||
```
|
||||
Background Layer:
|
||||
|
||||
+2
-32
@@ -269,37 +269,7 @@ Use `tabler-outline` as the stylistic icon library for this template. It matches
|
||||
|
||||
---
|
||||
|
||||
## IX. SVG Technical Constraints
|
||||
|
||||
### viewBox Specification
|
||||
|
||||
```xml
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||
```
|
||||
|
||||
### Prohibited Features (Blocklist)
|
||||
|
||||
| Category | Prohibited Items |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| **Clipping/Masking** | `mask` is forbidden; `clipPath` is allowed only on `<image>` under `shared-standards.md` §1.2 |
|
||||
| **Style System** | `<style>`, `class` (`id` inside `<defs>` is allowed) |
|
||||
| **Structure/Nesting** | `<foreignObject>` |
|
||||
| **Text/Font** | `textPath`, `@font-face` |
|
||||
| **Animation/Interaction** | `<animate*>`, `<set>`, `on*` |
|
||||
|
||||
> `marker-start` / `marker-end` are conditionally allowed — see `shared-standards.md` §1.1 (marker must be in `<defs>`, `orient="auto"`, shape = triangle / diamond / oval).
|
||||
|
||||
### PPT Compatibility Rules
|
||||
|
||||
| Prohibited | Correct Alternative |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
|
||||
| `stroke="rgba(0,0,0,0.5)"` | `stroke="#000000" stroke-opacity="0.5"` |
|
||||
| `<g opacity="0.2">...</g>` | Set `opacity` / `fill-opacity` on each child element individually |
|
||||
|
||||
---
|
||||
|
||||
## X. Placeholder Specification
|
||||
## IX. Placeholder Specification
|
||||
|
||||
| Placeholder | Usage |
|
||||
| -------------------- | -------------------- |
|
||||
@@ -322,7 +292,7 @@ Use `tabler-outline` as the stylistic icon library for this template. It matches
|
||||
|
||||
---
|
||||
|
||||
## XI. Usage Notes
|
||||
## X. Usage Notes
|
||||
|
||||
### Template Usage Steps
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
>
|
||||
> **Size slots are anchors, not a closed menu.** Common slots (`title` / `subtitle` / `annotation`) cover frequent cases. Add role-specific slots (e.g. `cover_title: 88`, `hero_number: 56`, `subheading: 32`, `lead: 30`, `footnote: 16`, `chart_annotation: 16`) for the roles the deck actually uses — common for cover-heavy decks, consulting-style hero numbers, dense pages. **Mandatory — scan `§IX` and declare a slot for every role that recurs across pages, not just the four defaults.** A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** and **page numbers / source credits / footnotes** → declare `lead` and `footnote` for them. `subheading` and `lead` sit between `subtitle` and `body` (their bands overlap `subtitle`) — pick by role, not size — and the core-message `lead` is a **primary** line, **always ≥ `body`**, never smaller. Leaving a recurring lead / footnote undeclared forces the Executor to improvise an unlocked size (and a core line improvised below `body` inverts the hierarchy). **Structural roles (title / body / subtitle / annotation / footnote) render at their locked size on every page — one role, one size, deck-wide.** Intermediate in-band sizes are for special / feature elements only (hero number, display title, one-off emphasis); declare a recurring one as its own slot so it stays consistent too.
|
||||
>
|
||||
> **Template Master defaults**: `pptx_structure.mode: template` requires both unitless `title` and `body`. Native export writes `title` into every Master `titleStyle` default and `body` into every `bodyStyle` / `otherStyle` default, changing only `a:defRPr@sz`. Direct page-run sizes and role-specific Layout placeholder prototype sizes remain unchanged. Other structure modes do not rewrite Master text-style sizes.
|
||||
>
|
||||
> **⚠️ PPT-safe stack discipline (HARD rule).** Native `baseline` / `template` export maps `title_family` to the PowerPoint theme major font and `body_family` (or `font_family`) to the theme minor font. Runs whose resolved face matches either role use `+mj-*` / `+mn-*` theme tokens; other role families remain concrete per-run typefaces. Every exported Latin / EA face MUST therefore resolve to cross-platform pre-installed fonts: `"Microsoft YaHei"` / `SimSun` / `Arial` / `"Times New Roman"` / `Consolas`. Stacks that resolve to non-preinstalled typefaces (Inter / Google Fonts / brand typefaces) may be used only when the Design Spec notes the font-install or embedding requirement. `preserve` keeps the source template theme; `flat` keeps concrete fonts for diagnostics.
|
||||
>
|
||||
> **Stack length discipline.** 3-4 fonts per stack is the sweet spot. Converter only writes the **first** Latin and **first** CJK font into PPTX — everything after is silently dropped. macOS-only families (`Songti SC`, `Menlo`, `Monaco`, `Helvetica`) are auto-mapped to Windows equivalents via `FONT_FALLBACK_WIN` (see `scripts/svg_to_pptx/drawingml/utils.py`); stacking both is redundant. Lead with Windows-preinstalled fonts (`Microsoft YaHei` / `SimSun` / `Arial` / `Georgia` / `Consolas`); keep at most **one** macOS-exclusive family (typically `"PingFang SC"`) as a browser-preview nicety.
|
||||
@@ -191,7 +193,7 @@
|
||||
|
||||
## forbidden
|
||||
- Mixing icon libraries
|
||||
- rgba()
|
||||
- `<style>`, `class`, `<foreignObject>`, `textPath`, `@font-face`, `<animate*>`, `<script>`, `<iframe>`, `<symbol>`+`<use>`
|
||||
- `<g opacity>` (set opacity on each child element individually)
|
||||
- HTML named entities in text (` `, `—`, `©`, `–`, `®`, `…`, `•` …) — write as raw Unicode (`—`, `©`, `→`, NBSP, etc.); XML reserved chars `& < > " '` must be escaped as `& < > " '`. See shared-standards.md §1.0
|
||||
- `mask`, `<style>`, `class`, external CSS, `<foreignObject>`, `textPath`, `@font-face`, `<animate*>`, `<set>`, `<script>` / event attributes, `<iframe>`
|
||||
- HTML named entities in text; write typography as raw Unicode and escape XML reserved characters
|
||||
|
||||
> **Execution reminder — not authoring authority**: the baseline blacklist above is intentionally terse. Add only deck-specific execution locks. General SVG required / forbidden / conditional rules are owned by [`shared-standards.md`](../references/shared-standards.md); do not copy its feature matrix or parameter contracts into `spec_lock.md`.
|
||||
|
||||
@@ -114,19 +114,21 @@ python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/<stem
|
||||
--images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json
|
||||
```
|
||||
|
||||
If `images/image_manifest.json` does not exist because the source deck has no extracted pictures, omit `--images`. The script joins per slide: `text_blocks` (slot text + geometry), `tables` (cell grid) / `charts` (categories + series values) — the **frozen data values inlined**, so the inventory is a self-contained contract, not a pointer back to `slide_library.json` — and `images` (bound via `image_manifest` `occurrences[].slide_index`, with geometry / `usage_count`). It emits `ignored` and `needs_confirmation` as **empty arrays** — fill them with judgment before Step 5:
|
||||
If `images/image_manifest.json` does not exist because the source deck has no extracted pictures, omit `--images`. The script joins per slide: `text_blocks` (slot text + geometry), `tables` (cell grid), `charts` (categories + series values), `diagrams` (SmartArt nodes + hierarchy/connections + source layout), and `images` (bound via `image_manifest` `occurrences[].slide_index`, with geometry / `usage_count`). The **frozen source values are inlined**, so the inventory is a self-contained contract, not a pointer back to `slide_library.json`. It emits `ignored` and `needs_confirmation` as **empty arrays** — fill them with judgment before Step 5:
|
||||
|
||||
| Field | Fill with |
|
||||
|---|---|
|
||||
| `ignored` | hidden slides / shapes, master-only text, image crop / opacity / rotation / mask (not captured upstream) |
|
||||
| `needs_confirmation` | combo / dual-axis / waterfall charts (only the first plot type is captured), merged-cell or multi-header tables, density-outlier pages — **either** overcrowded **or** near-empty / title-only (e.g. a divider page with a heading and no body) |
|
||||
| `needs_confirmation` | unreadable SmartArt data; combo / dual-axis / waterfall charts; merged-cell or multi-header tables; density-outlier pages — **either** overcrowded **or** near-empty / title-only |
|
||||
|
||||
**SmartArt output boundary**: Preserve its extracted wording and semantic relationships, then redraw it through SVG as ordinary editable PowerPoint shapes. Do not attempt to regenerate a native SmartArt object or reuse persisted-drawing text as a second content source.
|
||||
|
||||
```markdown
|
||||
## ✅ Extraction Complete
|
||||
|
||||
- [x] `sources/<stem>.md` (from Step 3) holds every source slide's text, in order; extracted pictures, if any, are in `images/` + `images/image_manifest.json`
|
||||
- [x] `analysis/<stem>.identity.json` has theme + observed identity + canvas aspect
|
||||
- [x] `analysis/<stem>.slide_library.json` holds chart + table data for regeneration
|
||||
- [x] `analysis/<stem>.slide_library.json` holds chart + table data and SmartArt semantic structure for regeneration
|
||||
- [x] `analysis/source_profile.json` (multi-deck index) summarizes the source facts in its `decks[]` entry
|
||||
- [x] `analysis/beautify_inventory.json` ledgers per-slide text / images / data + ignored + needs-confirmation
|
||||
- [ ] **Next**: Step 5 — Beautify Plan (recommend & confirm)
|
||||
@@ -145,7 +147,7 @@ This step has two halves:
|
||||
| Plan item | Recommend from | Default lean |
|
||||
|---|---|---|
|
||||
| Identity source | `<stem>.identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
|
||||
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` | all text verbatim; data values frozen; pictures reused |
|
||||
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` / `diagrams` | all text verbatim; data values and SmartArt relationships frozen; pictures reused |
|
||||
| Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) |
|
||||
| Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle |
|
||||
| Verification level | deck size / risk | recommend the Step 7 per-page checks; user sets strictness |
|
||||
|
||||
@@ -6,11 +6,13 @@ description: Generate a new layout or deck template based on existing project fi
|
||||
|
||||
> **Role invoked**: [Template_Designer](../references/template-designer.md)
|
||||
|
||||
Generate a complete set of reusable PPT templates for the **global template library**.
|
||||
Generate a complete layout/deck template package for either the **global template library** or one initialized project.
|
||||
|
||||
> This workflow is for **library asset creation**, not project-level one-off customization. The output must be reusable by future PPT projects and discoverable from the appropriate index file.
|
||||
**Default — library scope**: Preserve the existing reusable-library behavior. Write `skills/ppt-master/templates/<kind>/<id>/` and register it in the matching discovery index.
|
||||
|
||||
> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable `templates/<kind>/<id>/` package. To generate a deck from that package, the user must provide the resulting template directory path to the main SKILL.md pipeline.
|
||||
**Project scope**: Write a thin template bundle directly into `<project>/templates/` for the named project. Do not add a nested template-ID directory and do not register any global index.
|
||||
|
||||
> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable template contract at the selected scope. To generate a deck from a library package, return its directory path to the main SKILL.md pipeline. A project-scoped package is already installed at that project's Step 3 path and is consumed in place.
|
||||
|
||||
> **Companion workflow**: identity-only locking (colors / typography / logo / voice without SVG pages) is handled by [`create-brand.md`](./create-brand.md). Use that when the user wants brand identity but free page layout; use this when fixed page structures are required.
|
||||
|
||||
@@ -18,17 +20,34 @@ Generate a complete set of reusable PPT templates for the **global template libr
|
||||
|
||||
This workflow produces one of two kinds of templates depending on whether the source PPT carries a specific brand identity:
|
||||
|
||||
| Kind | When | Output dir | What `design_spec.md` writes |
|
||||
| Kind | When | Library-scope output dir | What `design_spec.md` writes |
|
||||
|---|---|---|---|
|
||||
| **deck** (default) | Source is a specific organization's branded PPT (e.g. company report, university defense template); the visual identity is part of the replica | `templates/decks/<id>/` | Full segments: identity + structure + middle |
|
||||
| **layout** | Source is a generic stylistic template (no specific brand); only the structural skeleton should be reusable; color / typography decided per-deck downstream | `templates/layouts/<id>/` | Structure segments only (canvas / page structure / page types / SVG roster); identity segment omitted |
|
||||
|
||||
Default to **deck** unless the user explicitly says "structure only" / "layout only" / "no brand identity". When in doubt, lean deck — losing identity later is easy; reconstructing it from a layout-mode strip is not. See [`docs/zh/templates-architecture.md`](../../../docs/zh/templates-architecture.md) for the full kind / schema / fusion model.
|
||||
|
||||
## Output scope — library (default) vs project
|
||||
|
||||
Output scope is a workflow execution choice, not a new template kind or PPTX structure mode. Surface it in the Step 2 brief; do not invent a CLI flag or persist `output_scope` / `target_project` into portable `design_spec.md` frontmatter.
|
||||
|
||||
| Scope | Final output | Asset routing | Registration |
|
||||
|---|---|---|---|
|
||||
| `library` (default) | `skills/ppt-master/templates/<kind_dir>/<template_id>/` | Keep the existing self-contained package: SVG/spec/assets under the package, including `icons/` when present | Run `register_template.py` against the matching global index |
|
||||
| `project` | `<target_project>/templates/` **root** | `design_spec.md`, template SVGs, and non-bitmap package assets → `templates/`; bitmaps → `images/` and SVG references → `../images/<name>`; extracted icon assets → both `templates/icons/` (package/validation copy) and `icons/` (runtime copy) | Do not update any global index; bundle belongs only to this target project |
|
||||
|
||||
For `project`, `target_project` is required and must be an existing project initialized by `project_manager.py init`. Before the first final-output write, run one complete preflight:
|
||||
|
||||
1. Confirm the target project and its `templates/`, `images/`, and `icons/` directories exist.
|
||||
2. Confirm `<target_project>/templates/` is empty.
|
||||
3. Resolve every final bitmap and extracted-icon filename, then confirm none would overwrite an existing file in `<target_project>/images/` or `<target_project>/icons/`.
|
||||
|
||||
Any failed check aborts before writing `design_spec.md`, SVGs, images, or icons. Do not merge into a non-empty template root and do not overwrite a name conflict. Temporary Step 1 analysis workspaces remain allowed because they are not final project outputs.
|
||||
|
||||
## Process Overview
|
||||
|
||||
```
|
||||
Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Create Directory + Invoke Template_Designer -> Validate Assets -> Register Index -> Output
|
||||
Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Preflight + Invoke Template_Designer -> Validate Assets -> [Register Library Index] -> Output
|
||||
```
|
||||
|
||||
The first three steps derive the brief from facts, not guesses. **No final template directory may be created and no template SVG / `design_spec.md` may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates produced by `pptx_template_import.py` (typically under `/tmp/pptx_template_import/`) are explicitly **not** subject to this gate — they are temporary workspaces feeding Step 2.
|
||||
@@ -202,6 +221,8 @@ Compose a single message that surfaces every Required brief item to the user, **
|
||||
|
||||
| Field | Must show |
|
||||
|---|---|
|
||||
| Output scope | Recommended `library` (existing default) plus `project`; explain that `project` writes directly into one initialized project's template root and skips global registration |
|
||||
| Target project | Required only for `project`; show the exact initialized project directory path, not a project nickname |
|
||||
| Template kind | Recommended localized label with English ID, plus both options and the rule for choosing |
|
||||
| Category | Recommended localized category with English ID, plus `brand` / `general` / `scenario` / `government` / `special` with localized explanations |
|
||||
| Theme mode | Recommended localized mode with English ID, plus available modes such as `light` / `dark` / `mixed` with localized explanations |
|
||||
@@ -215,7 +236,9 @@ Items to surface:
|
||||
|
||||
| Item | Required | Provenance by input type |
|
||||
|------|----------|--------------------------|
|
||||
| New template ID | Yes | `[decision]` — user chooses ASCII slug; if Chinese brand name, must be filesystem-safe and match the target kind's index exactly |
|
||||
| Output scope | Yes | `[decision]` — `library` (default, globally reusable and indexed) or `project` (direct thin bundle for one initialized project) |
|
||||
| Target project | Yes for `project`; N/A for `library` | `[decision]` — explicit path to the initialized target project; validate it during the Step 4 preflight |
|
||||
| New template ID | Yes | `[decision]` — user chooses ASCII slug; if Chinese brand name, it must be filesystem-safe. In library scope it also becomes the matching index key |
|
||||
| Template display name | Yes | `[decision]` (often the source deck title — `[suggested]` from `summary.md` for type A) |
|
||||
| Category | Yes | `[decision]` — one of `brand` / `general` / `scenario` / `government` / `special` |
|
||||
| Applicable scenarios | Yes | `[suggested]` from analysis; user confirms |
|
||||
@@ -241,7 +264,7 @@ For type A, also include in this message:
|
||||
|
||||
The user replies with corrections, additions, or "all good".
|
||||
|
||||
> **Persist the brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed brief (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, `source_viewbox`, `replication_mode`, `native_structure_mode`, etc.). `register_template.py` reads this in Step 6, so the brief flows directly into the index without the AI re-deriving it from prose. See Step 6 for the recommended frontmatter shape.
|
||||
> **Persist the portable brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed portable fields (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, `source_viewbox`, `replication_mode`, `native_structure_mode`, etc.). Do not persist the execution-only `output_scope` or `target_project` fields. In library scope, `register_template.py` reads this frontmatter in Step 6 so the brief flows directly into the index without the AI re-deriving it from prose.
|
||||
|
||||
---
|
||||
|
||||
@@ -260,28 +283,38 @@ Skipping this gate — including silently inferring values from the reference so
|
||||
- [ ] Every finite-option field has shown a recommended value, other available options, and unavailable options with reasons when applicable
|
||||
- [ ] User-facing labels and option explanations match the user's language; English enum IDs appear only as precision aids
|
||||
- [ ] User has replied with values or explicit acceptance of suggested defaults
|
||||
- [ ] The template is clearly positioned as a **global library template**
|
||||
- [ ] Output scope is confirmed; `library` remains the default, while `project` includes an explicit initialized target-project path
|
||||
- [ ] The canvas format is fixed before SVG generation
|
||||
- [ ] Replication mode is consistent with the input type (`fidelity` / `mirror` allowed for A and B with B's caveats noted; forbidden for C / D)
|
||||
- [ ] Basic template norms from prior content have been surfaced and accepted, or explicitly marked N/A when no reference exists
|
||||
- [ ] Metadata is complete enough to register into the relevant index
|
||||
- [ ] For `library`, metadata is complete enough to register into the relevant index; for `project`, the portable template metadata is complete and no global registration is planned
|
||||
- [ ] Marker `[TEMPLATE_BRIEF_CONFIRMED]` emitted on its own line after the echoed brief
|
||||
|
||||
Step 4 MUST NOT run until `[TEMPLATE_BRIEF_CONFIRMED]` has been emitted in the current conversation.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Template Directory + Invoke Template_Designer
|
||||
## Step 4: Preflight Output + Invoke Template_Designer
|
||||
|
||||
> **Precondition**: `[TEMPLATE_BRIEF_CONFIRMED]` was emitted in Step 3. If not, return to Step 3.
|
||||
|
||||
Create the final template directory:
|
||||
Select the final target from the confirmed output scope:
|
||||
|
||||
```bash
|
||||
# library scope (default)
|
||||
mkdir -p "skills/ppt-master/templates/<kind_dir>/<template_id>"
|
||||
|
||||
# project scope
|
||||
# Do not create a nested template-ID directory. The initialized project already
|
||||
# owns <target_project>/templates/, images/, and icons/.
|
||||
```
|
||||
|
||||
> **Output location**: Templates go to `skills/ppt-master/templates/<kind>/<template_id>/`. The generated directory name must match the final template ID used in the relevant index.
|
||||
| Scope | Template target | Required action before generation |
|
||||
|---|---|---|
|
||||
| `library` | `skills/ppt-master/templates/<kind_dir>/<template_id>/` | Preserve the existing directory creation behavior; the generated directory name matches the final template ID used in the relevant index |
|
||||
| `project` | `<target_project>/templates/` | Run the full project preflight from "Output scope" above before any final-output write; stop on a non-empty template root or image/icon filename conflict |
|
||||
|
||||
The project preflight is atomic at workflow level: discover and settle every output filename first, check all destinations together, then begin generation. Do not partially write a project bundle and discover a later collision.
|
||||
|
||||
**Switch to the Template_Designer role** and generate per role definition. The role input is the finalized brief from Step 3 plus the analysis bundle from Step 1, including the accepted basic template norms.
|
||||
|
||||
@@ -318,42 +351,50 @@ Downstream, both template-adherence choices stay on `pptx_structure.mode: templa
|
||||
2. **Renames each file** using the source-order-first convention `<NNN>_<page_type>.svg`, where `<NNN>` is the source-order index zero-padded to 3 digits and `<page_type>` is typically `cover` / `toc` / `chapter` / `content` / `ending` (fall back to `content` when the type cannot be confidently classified). Examples: `001_cover.svg`, `002_toc.svg`, `003_content.svg`, ..., `050_ending.svg`.
|
||||
- Type A: derive `<page_type>` from `manifest.json.pageTypeCandidates`
|
||||
- Type B: derive `<page_type>` from the source filename when it follows the PPT Master convention (`01_cover.svg` → `cover`, `03a_content_two_col.svg` → `content`); otherwise infer from page content or fall back to `content`
|
||||
3. **Copies bundled assets** into the template directory and rewrites the `<image href="...">` paths inside each copied SVG to point at the local copies. Asset filenames may be renamed to semantic names (`brand_emblem.png` instead of `image3.png`) when it improves readability — but the rewrite must be consistent across every page.
|
||||
3. **Routes bundled assets** according to the selected scope and rewrites every `<image href="...">` consistently. Asset filenames may be renamed to semantic names (`brand_emblem.png` instead of `image3.png`) when it improves readability.
|
||||
- Type A: assets come from `<import_workspace>/assets/`
|
||||
- Type B: resolve relative paths in source `<image href="...">` against the source SVG location and copy each unique asset; if the source already follows PPT Master conventions (assets co-located with SVGs in the same directory), copy the whole asset set and then rewrite paths
|
||||
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
|
||||
- Library scope: keep bundled assets inside the template package and point SVG references at those package-local files.
|
||||
- Project scope: write bitmap assets to `<target_project>/images/`, point SVG references at `../images/<name>`, and keep other package assets under `<target_project>/templates/`.
|
||||
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Library scope keeps one package copy under the template directory. Project scope writes the package/validation copy to `<target_project>/templates/icons/` and an identical runtime copy to `<target_project>/icons/`. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
|
||||
5. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1. The §V Page Roster remains the content-fit index; explicit SVG metadata is the native Master/Layout contract. Mirror is only the template-creation replication mode; downstream generation still treats the finished package as a selectable / reusable roster, not as a forced 1:1 slide sequence.
|
||||
|
||||
Mirror mode does not simplify the visual page, but it still reconstructs layer ownership. The sprite-sheet preservation rule applies because flat SVGs already contain the original crop wrappers; do not flatten them when annotating the page.
|
||||
|
||||
**Expected outputs from this step** (full spec → [template-designer.md](../references/template-designer.md)):
|
||||
|
||||
1. `design_spec.md` — **personality only**. Required sections: Template Overview, Color Scheme, Signature Design Elements, Page Roster (matching the actual SVG files on disk). Skip Typography / Assets / Placeholder Overrides when they would just restate defaults. Declare brief frontmatter for `register_template.py`. **Do not** restate generic SVG constraints, layout pattern libraries, font-size ratio bands, the canonical placeholder table, or content methodology — those are sourced from `shared-standards.md` / `design_spec_reference.md` / `strategist.md` and are already in the downstream reader's context. Full scope rule and skeleton: [template-designer.md §1](../references/template-designer.md#1-must-generate-design_specmd).
|
||||
1. `design_spec.md` — **personality only**. Required sections: Template Overview, Color Scheme, Signature Design Elements, Page Roster (matching the actual SVG files on disk). Skip Typography / Assets / Placeholder Overrides when they would just restate defaults. Declare portable brief frontmatter; `register_template.py` consumes it only in library scope. **Do not** restate generic SVG constraints, layout pattern libraries, font-size ratio bands, the canonical placeholder table, or content methodology — those are sourced from `shared-standards.md` / `design_spec_reference.md` / `strategist.md` and are already in the downstream reader's context. Full scope rule and skeleton: [template-designer.md §1](../references/template-designer.md#1-must-generate-design_specmd).
|
||||
2. Page roster — see [Page Roster](../references/template-designer.md#page-roster) for `standard` / `fidelity` / `mirror` mode rosters, variant naming, and TOC handling
|
||||
3. Placeholder vocabulary — pages should adopt the conventional names (`{{TITLE}}`, `{{CONTENT_AREA}}`, ...) when they fit. Full reference: [Placeholder Reference](../references/template-designer.md#4-placeholder-reference-canonical-convention-overridable-per-template). When a template style legitimately needs different vocabulary (consulting → `{{KEY_MESSAGE}}`, branded cover → `{{BRAND_LOGO}}`), declare a `placeholders:` block in `design_spec.md` frontmatter so the registrar and quality checker treat it as the template's authoritative contract. **Avoid** one-off indexed families such as `{{CHAPTER_01_TITLE}}` — use the indexed TOC pattern instead.
|
||||
- `{{...}}` placeholders are the authoring vocabulary used to generate final slide content. Each emitted SVG also carries the native reconstruction contract: root `data-pptx-layout` / `data-pptx-layout-name`, direct Master/Layout layers, and direct semantic `data-pptx-placeholder` markers. Minimal structural `data-pptx-role` hints are added only when those specialized markers cannot express required behavior. Both strict and adaptive downstream use set `mode: template` and require `page_layouts` plus `pptx_layouts` for every page.
|
||||
4. Template assets (optional) — Logos / PNG / JPG / reference SVG bundled with the template package
|
||||
4. Template assets (optional) — library scope bundles Logos / PNG / JPG / reference SVG inside the package; project scope applies the `templates/` / `images/` / dual-icon routing defined above
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Validate Template Assets
|
||||
|
||||
Set `<template_target>` to the selected output directory:
|
||||
|
||||
| Scope | `<template_target>` |
|
||||
|---|---|
|
||||
| `library` | `skills/ppt-master/templates/<kind_dir>/<template_id>` |
|
||||
| `project` | `<target_project>/templates` |
|
||||
|
||||
```bash
|
||||
# Replace <kind_dir> with "decks" or "layouts" depending on the kind decided above
|
||||
ls -la "skills/ppt-master/templates/<kind_dir>/<template_id>"
|
||||
ls -la "<template_target>"
|
||||
```
|
||||
|
||||
Run SVG validation on the template directory:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/templates/<kind_dir>/<template_id>" --template-mode --format <canvas_format>
|
||||
python3 skills/ppt-master/scripts/svg_quality_checker.py "<template_target>" --template-mode --format <canvas_format>
|
||||
```
|
||||
|
||||
`--template-mode` makes the checker:
|
||||
|
||||
- glob `*.svg` in the template directory directly (templates do not live under `svg_output/`)
|
||||
- skip `spec_lock.md` drift checks (templates do not ship a spec_lock)
|
||||
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break the target kind's index)
|
||||
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break the template contract and, in library scope, the target kind's index)
|
||||
- emit advisory **warnings** when a page lacks a conventional placeholder — these are hints, not failures. Declare a `placeholders:` block in `design_spec.md` frontmatter to silence them when your template intentionally uses a different vocabulary
|
||||
- require every SVG root to declare an output Layout and every SVG to contain at least one direct Master/Layout/placeholder declaration
|
||||
- validate cross-page Master equality plus same-key Layout/placeholder equality
|
||||
@@ -367,18 +408,25 @@ python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/temp
|
||||
- [ ] `design_spec.md` frontmatter declares `canvas_format`, `canvas_width`, `canvas_height`, and `canvas_viewbox`; PPTX/SVG-backed templates also declare `source_canvas_width`, `source_canvas_height`, and `source_viewbox`
|
||||
- [ ] SVG `viewBox` matches the declared canvas dimensions, not just the aspect ratio (for `ppt169`: `0 0 1280 720`; for `banner`: `0 0 1920 1080`); `width` / `height`, if written, equal it
|
||||
- [ ] Placeholder names follow the canonical convention where applicable; templates with intentionally different vocabularies (e.g. `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`) should declare a `placeholders:` frontmatter block to silence advisory warnings
|
||||
- [ ] Asset files referenced by SVGs actually exist in the template package
|
||||
- [ ] Asset files referenced by SVGs exist at their resolved paths. In project scope, bitmap references resolve through `../images/`; no bitmap remains accidentally stranded in `templates/`
|
||||
- [ ] `design_spec.md` frontmatter declares `native_structure_mode: template`; no `native_structure.json` or `source_template.pptx` is packaged
|
||||
- [ ] Every SVG root declares `data-pptx-layout` and `data-pptx-layout-name`; direct Master/Layout elements and semantic placeholders obey the explicit paint-order contract. Structural `data-pptx-role` is used only when specialized metadata cannot express required package/page-number/animation behavior
|
||||
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists directly under the package's `icons/` directory; do not add a separate illustration embedding script
|
||||
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists under the package's `icons/` directory. Project scope also has the identical runtime copy under `<target_project>/icons/`; do not add a separate illustration embedding script
|
||||
- [ ] For `fidelity` mode: every sprite-sheet asset retains its nested `<svg viewBox=...>` crop wrapper; no image whose file aspect differs from its on-page aspect was flattened to a bare `<image>`
|
||||
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/<kind_dir>/<id>/*_*.svg | wc -l` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/<kind_dir>/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
||||
- [ ] For `mirror` mode: file count equals source page count (type A: `<template_target>/*_*.svg` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg` count; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" "<template_target>"/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
||||
|
||||
This step is a **hard gate**. Do not register a template into the library index until validation passes.
|
||||
This step is a **hard gate**. In library scope, do not register until validation passes. In project scope, do not hand the in-place bundle to the main pipeline until validation passes.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Register Template in Library Index
|
||||
## Step 6: Register Template in Library Index (Library Scope Only)
|
||||
|
||||
Branch on the confirmed output scope:
|
||||
|
||||
| Scope | Action |
|
||||
|---|---|
|
||||
| `library` | Run the registrar below after Step 5 passes |
|
||||
| `project` | Skip the registrar entirely. Do not edit `decks_index.json`, `layouts_index.json`, or any library README; continue to Step 7 with index status `Not registered (project-local)` |
|
||||
|
||||
Run the unified registrar with the kind flag; it derives the corresponding index entry from `design_spec.md` (frontmatter when present, prose fallback otherwise) plus the actual SVG file list:
|
||||
|
||||
@@ -454,7 +502,7 @@ README files describe each kind in prose only — they do not list templates. Di
|
||||
|
||||
## Step 7: Output Confirmation
|
||||
|
||||
`register_template.py` already printed a "Template Creation Complete" card during Step 6 — copy it verbatim into the conversation. The card includes the template name, path, category, primary color, index status, and the full SVG file roster (auto-collected from disk, so `fidelity`-mode variant pages and TOC pages are listed correctly without manual editing).
|
||||
For `library`, `register_template.py` already printed a "Template Creation Complete" card during Step 6 — copy it verbatim into the conversation. The card includes the template name, path, category, primary color, index status, and the full SVG file roster (auto-collected from disk, so `fidelity`-mode variant pages and TOC pages are listed correctly without manual editing).
|
||||
|
||||
For a standard-mode template the card looks like:
|
||||
|
||||
@@ -478,6 +526,29 @@ For a standard-mode template the card looks like:
|
||||
| `04_ending.svg` | Done |
|
||||
```
|
||||
|
||||
For `project`, produce the same evidence-driven file roster from `<target_project>/templates/*.svg`, but use this scope-aware card:
|
||||
|
||||
```markdown
|
||||
## Template Creation Complete
|
||||
|
||||
**Template Name**: <template_id> (<display_name>)
|
||||
**Kind**: deck | layout
|
||||
**Output Scope**: project
|
||||
**Template Path**: `<target_project>/templates/`
|
||||
**Bitmap Path**: `<target_project>/images/`
|
||||
**Runtime Icon Path**: `<target_project>/icons/`
|
||||
**Index Registration**: Not registered (project-local)
|
||||
|
||||
### Files Included
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `design_spec.md` | Done |
|
||||
| `<actual-template>.svg` | Done |
|
||||
```
|
||||
|
||||
The next main-pipeline Step 3 input is the exact `<target_project>/templates/` path. It is an in-place bundle: do not copy it onto itself and do not move its `../images/` assets again. Do not reuse this root from another project; choose library scope when cross-project portability is required.
|
||||
|
||||
---
|
||||
|
||||
## Color Scheme Quick Reference
|
||||
@@ -496,6 +567,6 @@ For a standard-mode template the card looks like:
|
||||
1. **SVG technical constraints**: See [shared-standards.md](../references/shared-standards.md) — do not restate them in the template's `design_spec.md`
|
||||
2. **Color consistency**: All SVG files must use the same color scheme as `design_spec.md §II Color Scheme`
|
||||
3. **Placeholder convention**: `{{}}` format only; default names listed in [Placeholder Reference](../references/template-designer.md#4-placeholder-reference-canonical-convention-overridable-per-template). Override per template via `placeholders:` frontmatter when needed.
|
||||
4. **Discovery requirement**: A template directory is only discoverable after `register_template.py` has been run against it (Step 6)
|
||||
4. **Discovery requirement**: A library template is discoverable only after `register_template.py` has been run against it (Step 6). A project-scoped template intentionally stays out of global discovery and is consumed by its explicit `<project>/templates/` path.
|
||||
|
||||
> **Full role specification**: [template-designer.md](../references/template-designer.md)
|
||||
|
||||
+34
-18
@@ -28,13 +28,17 @@ description: Customize default PPTX animations with per-slide and per-object tim
|
||||
python3 skills/ppt-master/scripts/animation_config.py list-groups <project_path>
|
||||
```
|
||||
|
||||
Output is one line per slide: `<slide_basename>: id1, id2, id3` — chrome
|
||||
groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` / `watermark` /
|
||||
`logo` / `pagenumber`) are excluded because the exporter already pins them
|
||||
to `none`. Use this as the source of truth when planning §3 and editing §4
|
||||
Output is one line per slide: `<slide_basename>: id1, id2, id3` — default
|
||||
chrome groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` /
|
||||
`watermark` / `logo` / `pagenumber`) are excluded from the ordinary target
|
||||
list. Use this as the source of truth when planning §3 and editing §4
|
||||
— **do not read the full scaffold file unless you need it as an editing
|
||||
starting point**.
|
||||
|
||||
An explicit sidecar entry may override only the marker-free legacy id-name
|
||||
heuristic. A group carrying `data-pptx-layer` or an explicit static
|
||||
role/placeholder marker can never animate, even when it is named explicitly.
|
||||
|
||||
If `animations.json` does not exist and you want a starting file to edit:
|
||||
|
||||
```bash
|
||||
@@ -99,7 +103,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
|
||||
| Effect | Behavior |
|
||||
|---|---|
|
||||
| `none` | Disable page transition |
|
||||
| `none` | Remove visual page transition; timed advance may remain |
|
||||
| `fade` | Neutral default for technical decks |
|
||||
| `push` | Directional slide entry |
|
||||
| `wipe` | Directional reveal |
|
||||
@@ -112,9 +116,9 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
|
||||
| Field | Behavior |
|
||||
|---|---|
|
||||
| `effect` | One supported page transition effect |
|
||||
| `duration` | Transition duration in seconds |
|
||||
| `auto_advance` | Optional seconds before automatic slide advance |
|
||||
| `effect` | One supported page transition effect; `none` removes only the visual effect |
|
||||
| `duration` | Finite transition duration in seconds; must be greater than zero |
|
||||
| `auto_advance` | Optional finite non-negative seconds before automatic slide advance; click remains enabled, and this field is valid with `effect: none` |
|
||||
|
||||
### 3.2 Supported In-Slide Animations
|
||||
|
||||
@@ -124,7 +128,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
| `appear` | Visibility flip without motion |
|
||||
| `fade` | Neutral entrance |
|
||||
| `fly` | Fly in from bottom |
|
||||
| `cut` | Cut in from left |
|
||||
| `cut` | Legacy compatibility key; preserve its registered tuple exactly |
|
||||
| `zoom` | Scale/zoom entrance |
|
||||
| `wipe` | Wipe entrance |
|
||||
| `split` | Split/barn entrance |
|
||||
@@ -145,7 +149,7 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
| `swivel` | Swivel entrance |
|
||||
| `auto` | Map effect from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (hero/figure-/image/img-/kpi) cycle zoom/dissolve/circle/box/diamond/wheel for visual variation; unmatched ids cycle fade/wipe/fly/zoom |
|
||||
| `mixed` | Legacy 16-effect cycle by group order (first group fades, rest cycle the larger pool) |
|
||||
| `random` | Random effect per animated group, sampled from the legacy pool |
|
||||
| `random` | Stable seeded effect per animated group; `--conversion-trace` records resolved effects when diagnostics are enabled |
|
||||
|
||||
**Start modes**:
|
||||
|
||||
@@ -166,18 +170,25 @@ even when the values match `defaults`. This makes per-page rhythm visible
|
||||
at a glance without mentally merging the inheritance chain. Group-level
|
||||
overrides remain opt-in — list only the groups that genuinely diverge from
|
||||
the slide's `animation` block. Chrome groups stay out (the exporter pins
|
||||
them to `none`).
|
||||
them to `none` by default). Name a legacy chrome-like id only when the user
|
||||
explicitly wants that content animated and the SVG has no explicit structural
|
||||
layer, role, or placeholder marker.
|
||||
|
||||
`defaults` is still required: it supplies values for any slide not yet
|
||||
present in `slides` (rare, e.g. mid-edit drafts) and acts as the single
|
||||
source for the deck-wide baseline you copy into each slide block.
|
||||
> Note: version-1 legacy sidecars may omit fields inside a listed slide and
|
||||
> inherit them from `defaults`; the loader preserves that compatibility. This
|
||||
> workflow writes complete new slide blocks, and validation still requires
|
||||
> every current SVG stem to be present under `slides`.
|
||||
|
||||
`defaults` is still required: it supplies the legacy inheritance baseline and
|
||||
the deck-wide values copied into every complete new slide block.
|
||||
|
||||
**Forbidden**:
|
||||
|
||||
- Omitting a slide that exists in `svg_output/` — every produced slide must appear under `slides`
|
||||
- Writing a slide block with only `groups` and no `transition`/`animation`
|
||||
- Enumerating every content group in a slide just to restate the slide-level default effect
|
||||
- Listing chrome groups (`bg`, `*-header`, `*-footer`, `*-decor`, `nav`, `watermark`, `logo`, `pagenumber`)
|
||||
- Listing a group with `data-pptx-layer` or an explicit static role/placeholder marker
|
||||
- Listing a legacy chrome-like id without an explicit, reviewed intent to override the name heuristic
|
||||
|
||||
| Field | Behavior |
|
||||
|---|---|
|
||||
@@ -190,7 +201,7 @@ source for the deck-wide baseline you copy into each slide block.
|
||||
| `groups.<id>.effect` | Object-specific entrance effect, `auto`, `mixed`, `random`, or `none` |
|
||||
| `order` | Animation order only; does not change SVG layer order |
|
||||
| `delay` | Extra seconds before this group starts in `after-previous` mode |
|
||||
| `duration` | Per-group entrance duration in seconds; vary when semantic weight or pacing calls for it |
|
||||
| `duration` | Per-group schedule duration in seconds; `appear` stays a 1ms visibility flip and uses this value only for subsequent `after-previous` spacing |
|
||||
|
||||
**Canonical example — every slide carries explicit transition + animation;
|
||||
groups appear only when they diverge**:
|
||||
@@ -233,7 +244,7 @@ groups appear only when they diverge**:
|
||||
Notes:
|
||||
- `02_agenda` repeats `defaults` verbatim — this is intentional under the new rule so per-page rhythm is auditable in one read.
|
||||
- `03_market` and `07_hero_quote` only list the groups that diverge; `title`, `footer`, `bg`, `header` etc. are not enumerated.
|
||||
- Chrome groups are never listed; the exporter pins them to `none`.
|
||||
- Structural chrome groups are never listed. Legacy id-only chrome groups remain omitted unless an explicit reviewed override is required.
|
||||
|
||||
**Forbidden — SVG pollution**: do not add `data-*` animation attributes to SVG files. Animation customization belongs in `animations.json`.
|
||||
|
||||
@@ -251,7 +262,11 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
**Validation**: the exported native PPTX should reflect the object-level overrides. `--animation none` still disables all per-element animation and overrides `animations.json`.
|
||||
**Validation**: the exported native PPTX must reflect the object-level overrides. `--animation none` still disables all per-element animation and overrides `animations.json`. Unknown animation effects/modes/triggers; boolean, NaN, or Infinity numeric values; non-positive durations; negative delay/stagger; invalid order; missing slides/groups; and structural-layer targets fail validation. Transition validation remains strict as well. None of these failures substitutes a fallback effect or silently drops a requested target.
|
||||
|
||||
Generated export performs semantic read-back per slide, comparing row order, trigger, target, resolved effect tuple, duration, and offset. It then validates timing-tree placement, `p:cTn` ids, and `p:spTgt` references across the packaged PPTX. Stable `random` choices appear in the conversion trace when export enables `--conversion-trace`. Narration merges audio into the existing timing tree and must preserve these rows.
|
||||
|
||||
Direct-PPTX routes are preserve-only for object animation: they compare the source object-animation fingerprint before and after allowed edits, run structural package validation, and do not write, normalize, or claim ownership of effects. See [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact compatibility and OOXML contract.
|
||||
|
||||
---
|
||||
|
||||
@@ -265,3 +280,4 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
|
||||
- [x] Transition and object durations were chosen intentionally for the deck's pacing
|
||||
- [x] `animation_config.py validate` passed
|
||||
- [x] PPTX re-export completed with custom animation overrides
|
||||
- [x] Generated animation semantic read-back and package validation passed
|
||||
|
||||
@@ -149,7 +149,7 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
||||
|
||||
If `notes_to_audio.py` errors with a missing dependency or missing provider API key, fix the prerequisite and re-run — do NOT swallow the error.
|
||||
|
||||
`--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
|
||||
`--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. Narration changes the slide-advance layer only: the resolved page-transition effect remains unchanged, `-t none` remains visually transition-free, and narration advance disables click while using audio duration plus padding. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Registry for standalone workflows referenced by `SKILL.md` and [`routing.md`](./
|
||||
| `topic-research` | [`topic-research.md`](./topic-research.md) | Topic-only request with no substantive source material | Topic or requirements exist | Do not invent facts in the main pipeline | Before `SKILL.md` Step 1 | Source material suitable for Step 1 | Stops if usable source facts cannot be gathered |
|
||||
| `template-fill-pptx` | [`template-fill-pptx.md`](./template-fill-pptx.md) | Raw PPTX template plus new material/topic; clone/fill native slides | Source PPTX plus content material or topic brief | No SVG generation; no `create-template` unless reusable package is requested | Workflow Step 1 | Native PPTX in project `exports/` | Stops if no PPTX or no fill material |
|
||||
| `beautify-pptx` | [`beautify-pptx.md`](./beautify-pptx.md) | Existing PPTX should be re-laid out while preserving slide count/order and wording 1:1 | Single source PPTX | Not for merge/split/drop/reorder or re-outline | Workflow Step 1 | Regenerated 1:1 deck through SVG pipeline | Blocks on source-slide mapping and final confirmation gates defined in the workflow |
|
||||
| `create-template` | [`create-template.md`](./create-template.md) | Build a reusable layout/deck template from a PPTX or design reference | Design source or explicit template-creation request | Not a one-off generated deck | Workflow Step 1 | Template directory with `design_spec.md` and assets | Stops after template creation until user supplies the directory path to main Step 3 |
|
||||
| `create-template` | [`create-template.md`](./create-template.md) | Build a reusable layout/deck template from a PPTX or design reference, globally or for one initialized project | Design source or explicit template-creation request; project scope also requires an initialized target project | Does not fill final content or emit the generated deck | Workflow Step 1 | Library scope (default): indexed `templates/<kind>/<id>/`; project scope: in-place `<project>/templates/` bundle with bitmaps in `images/` and no global registration | Library scope stops until the user supplies the directory path to main Step 3; project scope may continue with its in-place path after validation |
|
||||
| `create-brand` | [`create-brand.md`](./create-brand.md) | Build/extract a reusable brand identity preset | Logo/site/branded deck/PDF or explicit brand setup request | Not a layout/deck template roster | Workflow Step 1 | Brand directory under `templates/brands/<id>/` | Blocks on brand evidence and validation defined in the workflow |
|
||||
| `resume-execute` | [`resume-execute.md`](./resume-execute.md) | Continue an existing split-mode project | Planning-session artifacts exist | Do not re-run planning | Execution-session entry | SVG generation, post-processing, export | Stops if `design_spec.md` or `spec_lock.md` is missing |
|
||||
| `refine-spec` | [`refine-spec.md`](./refine-spec.md) | User explicitly opts into spec refinement before generation | Strategist confirmation stage completed | Do not enter by default | After Step 4, before Step 5/6 | Revised `design_spec.md` and `spec_lock.md` | Blocks for user review/approval before generation |
|
||||
|
||||
@@ -129,6 +129,18 @@ Present the plan to the user before generating notes or audio:
|
||||
|
||||
**⛔ BLOCKING**: Stop here and wait for explicit user confirmation. Do not generate notes, generate audio, or patch the PPTX until the user confirms the module plan.
|
||||
|
||||
**Transition/timing ownership**:
|
||||
|
||||
| Confirmed state | Enter transition | Slide advance |
|
||||
|---|---|---|
|
||||
| Transitions enabled with an effect | Replace with that exact effect and duration | Preserve unless timings is enabled |
|
||||
| Transitions disabled with a non-`none` configured effect | Preserve the source effect, including unknown `AlternateContent` | Preserve unless timings is enabled |
|
||||
| Explicit `none` | Remove the visual effect | Preserve, or write timing-only advance when timings is enabled |
|
||||
| Timings enabled with audio | Keep the resolved enter policy | Use audio duration plus narration padding; click disabled |
|
||||
| Timings disabled | Apply the confirmed enter policy only | Do not run `ffprobe`; do not add/change `advTm` or `useTimings` |
|
||||
|
||||
**Hard rule — no silent downgrade**: a requested supported effect must be written with its established direction/variant attributes. Unknown requested effects fail; unknown source effects are preserved when the transition module is disabled.
|
||||
|
||||
After confirmation, update `<project>/analysis/enhancement_plan.json`:
|
||||
|
||||
```json
|
||||
@@ -254,9 +266,14 @@ python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "<project>" \
|
||||
--transition fade \
|
||||
--transition-duration 0.5 \
|
||||
--narration-padding 0.4 \
|
||||
--apply-transition-without-audio \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
Without `--apply-transition-without-audio` (or the matching confirmed-plan
|
||||
field), the resolved enter policy is applied while processing slides with
|
||||
audio; slides without audio keep their source transition.
|
||||
|
||||
Patch scope:
|
||||
|
||||
| Package area | Append/update |
|
||||
@@ -266,6 +283,7 @@ Patch scope:
|
||||
| `ppt/slides/_rels/slideN.xml.rels` | Relationships for notes/audio/media/poster |
|
||||
| `ppt/media/` | Narration audio and transparent poster |
|
||||
| `ppt/slides/slideN.xml` | Hidden autoplay audio shape and page timing |
|
||||
| `ppt/presProps.xml` | `showPr useTimings=1` only when this run writes automatic slide advance |
|
||||
| `[Content_Types].xml` | Required content types |
|
||||
|
||||
**Hard rule**: Do not modify existing slide shapes, text bodies, images, chart data, master/layout parts, or existing non-target relationships.
|
||||
@@ -291,6 +309,8 @@ Check:
|
||||
| Notes | Present on intended slides |
|
||||
| Audio media | Present under `ppt/media/` when generated |
|
||||
| Auto-play | Narrated slides advance by audio duration |
|
||||
| Transition | Requested effect remains exact; preserved `AlternateContent` keeps its primary and fallback branches |
|
||||
| Timings disabled | Source `advTm` and package `useTimings` are not changed |
|
||||
|
||||
```markdown
|
||||
## ✅ Native PPTX Enhancement V1 Complete
|
||||
|
||||
@@ -26,7 +26,7 @@ The user **explicitly asks** to refine / review / revise the spec before generat
|
||||
|
||||
## Step 1: Produce the full spec
|
||||
|
||||
Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§I–XI) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it.
|
||||
Run the default Strategist output exactly as SKILL.md Step 4 specifies: write `design_spec.md` (§I–X) and `spec_lock.md`. Read the relevant `sources/` files so the content outline (`§IX`) carries real facts, not skeleton points. Nothing special here — this is the normal spec, just produced under the knowledge that the user is about to review it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Route selection authority for PPT Master. Use this file before entering the main
|
||||
| Topic only, no source facts | User supplies only a topic name or requirement and no substantive source material | [`topic-research`](./topic-research.md), then main `SKILL.md` pipeline | Direct main pipeline with invented facts | Web/source gathering is allowed or user supplies facts | Research material becomes source input for Step 1 | Stop if facts cannot be gathered and the user supplies no source |
|
||||
| Source material can be reworked into a new story | PDF/DOCX/URL/Markdown/text/conversation content, or PPTX treated as content | Main `SKILL.md` pipeline | Direct PPTX edit workflows | Source content exists or is available in conversation | `design_spec.md`, `spec_lock.md`, `svg_output/`, exported PPTX | Stop at Step gates when required artifacts are missing |
|
||||
| PPTX as re-architectable source | User allows page count/order/outline to change, or asks to split/merge/drop/reorder slides | Main `SKILL.md` pipeline with `ppt_to_md.py` plus PPTX intake | [`beautify-pptx`](./beautify-pptx.md) | PPTX source exists | Markdown content plus `analysis/source_profile.json`; Strategist may re-outline | Stop if user requires exact 1:1 page preservation |
|
||||
| Explicit template directory path | User provides a directory containing `design_spec.md` with `kind: brand`, `kind: layout`, or `kind: deck` | Main `SKILL.md` Step 3 | Fuzzy template lookup by bare name | Path resolves and frontmatter kind is valid | Template assets copied/fused into `<project>/templates/` | Stop if the path does not exist or lacks valid `design_spec.md` |
|
||||
| Explicit template directory path | User provides a directory containing `design_spec.md` with `kind: brand`, `kind: layout`, or `kind: deck` | Main `SKILL.md` Step 3 | Fuzzy template lookup by bare name | Path resolves and frontmatter kind is valid | External assets are copied/fused into `<project>/templates/`; the same project's own `templates/` path is consumed in place | Stop if the path does not exist or lacks valid `design_spec.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +53,7 @@ Route selection authority for PPT Master. Use this file before entering the main
|
||||
| Raw PPTX template plus new material/topic | "Use this PPT template to generate a PPTX", "fill this deck", "replace copy", native slide shell reuse | [`template-fill-pptx`](./template-fill-pptx.md) | Main SVG pipeline directly from raw PPTX template | Source PPTX plus content material or topic brief | New native PPTX in `exports/`, cloned/patched by OOXML | Stop if user instead wants a reusable template package |
|
||||
| Existing PPTX, preserve page split and wording | "Beautify", "re-layout", "make more professional" with same slide count/order and verbatim text | [`beautify-pptx`](./beautify-pptx.md) | Main pipeline if page count/order changes | Single source PPTX | Regenerated deck through SVG pipeline, one source slide to one output slide | Stop if user asks to split/merge/drop/reorder |
|
||||
| Finished PPTX, native enhancement only | Add notes, recorded narration, auto-advance, transitions, or stable-layout metadata | [`native-enhance-pptx`](./native-enhance-pptx.md) | SVG regeneration | Finished PPTX exists; content/layout should stay stable | Patched PPTX through direct OOXML | Stop if user asks for visual redesign |
|
||||
| PPTX/reference design should become a reusable template | "Create a template", "make reusable", "build template from this deck/design" | [`create-template`](./create-template.md) | `template-fill-pptx` one-off fill | PPTX or design reference exists | Template directory under `templates/<kind>/<id>/` | Stop after creation; main pipeline resumes only when user supplies the generated directory path |
|
||||
| PPTX/reference design should become a reusable template | "Create a template", "make reusable", "build template from this deck/design", including a template for one named initialized project | [`create-template`](./create-template.md) | `template-fill-pptx` one-off fill | PPTX/design reference exists, or the user gives an explicit template-creation brief; project output additionally requires an initialized target project | Library scope (default): `templates/<kind>/<id>/` plus index registration; project scope: direct `<project>/templates/` thin bundle with no global registration | Stop after library creation until the user supplies its directory path; project scope may resume main Step 3 with the in-place `<project>/templates/` path |
|
||||
|
||||
**Hard rule**: Raw PPTX template plus "generate PPTX" routes to `template-fill-pptx` by default. A raw PPTX is not a Step 3 template until `create-template` has produced a reusable template directory.
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ Read `<project_dir>/analysis/<stem>.slide_library.json` (intake prefixes per-dec
|
||||
| `slides[].slots[].role` | Title / body / label candidate hint |
|
||||
| `slides[].tables[]` | Native PowerPoint tables with `table_id`, row / column counts, and per-cell coordinates + text |
|
||||
| `slides[].charts[]` | Native PowerPoint charts with `chart_id` |
|
||||
| `slides[].diagrams[]` | SmartArt layout, semantic nodes, hierarchy/connections, geometry, and extraction status; inventory-only |
|
||||
|
||||
**Selection rule**: Pick pages by content fitness, not by source order alone. A source page is useful only if its visible structure can carry the target message without heavy redesign.
|
||||
|
||||
@@ -107,6 +108,9 @@ A page's layout already encodes a rhetorical shape — a single hero statement,
|
||||
| `slots[].geometry` | Estimate whether each text slot is a short label, medium title, body block, caption, or decorative number |
|
||||
| `slots[].text_metrics.font_size_px` | Estimate text capacity together with geometry; larger type means fewer safe characters |
|
||||
| `slots[].text_summary` | Read the source page's original rhetorical pattern, not its literal placeholder wording |
|
||||
| `diagrams[].layout` + `nodes` | Understand the SmartArt's source meaning; template-fill preserves it unchanged and cannot map new text into it |
|
||||
|
||||
**SmartArt boundary**: A selected source slide keeps its original native SmartArt parts. `check-plan` warns because the fill plan cannot replace SmartArt node text; choose another layout unless the original diagram content is intentionally retained, or explicitly accept the warning.
|
||||
|
||||
**Hard rule**: The target story controls output order. Source slides may move forward, move backward, be omitted, or be reused several times when their layout matches multiple target messages. Never treat source slide order as a default outline unless the user explicitly asks to preserve it.
|
||||
|
||||
@@ -192,7 +196,7 @@ The plan structure:
|
||||
| `layout_rationale` | Human review aid for page selection. Include `layout_pattern`, `why_fit`, and `risk`; it is not a mechanical checker gate. |
|
||||
| `accepted_warnings` | Optional audit trail for warnings the user or agent explicitly accepts. `check-plan` warnings remain non-blocking; errors must be fixed. |
|
||||
| `notes` | Optional spoken speaker notes for the filled slide — see **Speaker notes** below; write prose, not a copy of the on-slide text |
|
||||
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to strip it, or `{ "effect": "push", "duration": 0.6 }` |
|
||||
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to remove the visual effect, `keep` to preserve the source, or an object such as `{ "effect": "push", "duration": 0.6, "advance_after": 5 }` |
|
||||
| `replacements` | Target by `slot_id` whenever possible; `shape_id` and `shape_name` are fallback selectors |
|
||||
| `table_edits` | Optional native table cell edits; target by `table_id` whenever possible and use zero-based `row` / `col` |
|
||||
| `chart_edits` | Optional native chart data edits; target by `chart_id`, set `categories`, and provide one or more `series` |
|
||||
@@ -249,6 +253,7 @@ Interpret the report:
|
||||
| Short label exceeds visual width | Rewrite shorter or choose a layout with a larger label slot; do not shrink font by default |
|
||||
| Title too long | Rewrite first; only use font-size changes as a last resort |
|
||||
| Body much longer than source slot | Compress, split across another selected page, or choose a larger source page |
|
||||
| SmartArt source content remains unchanged | Pick another source slide unless the original SmartArt wording is intended; otherwise record the accepted warning |
|
||||
| Missing target | Fix `slot_id` / `shape_id`; do not apply the plan |
|
||||
|
||||
`check-plan` emits stable `code` fields in its JSON results so warnings can be tracked without parsing message text. Warnings are advisory and do not fail the command; record any intentionally accepted warning in `accepted_warnings` when it matters for review. Errors are blocking and must be fixed before apply.
|
||||
@@ -267,7 +272,7 @@ Run:
|
||||
python3 skills/ppt-master/scripts/template_fill_pptx.py apply "<project_dir>/sources/<source.pptx>" "<project_dir>/analysis/fill_plan.json" -o "<project_dir>/exports/<output.pptx>"
|
||||
```
|
||||
|
||||
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), because most native templates ship an empty `<p:transition/>` that renders as *no* motion. Override the default with `--transition <effect>` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration <seconds>`; pass `--transition none` for no motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field in the plan overrides whatever the CLI selects for that slide.
|
||||
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), preserving the v1 route contract. Override it with `--transition <effect>` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration <seconds>`; pass `--transition none` for no visual motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field overrides the CLI. `advance_after` keeps click advance enabled and adds timed advance; it also works with `none` (timing-only transition) and `keep` (source effect preserved, Choice/Fallback timing updated together).
|
||||
|
||||
`apply` appends a timestamp automatically. For example, `-o "<project_dir>/exports/demo.pptx"` writes `demo_YYYYMMDD_HHMMSS.pptx`. If the filename already ends with `_YYYYMMDD_HHMMSS`, it is left unchanged.
|
||||
|
||||
@@ -278,12 +283,12 @@ The script:
|
||||
| Clones selected source slides | Original slide design, relationships, images, layouts, and animations are preserved where PowerPoint supports them |
|
||||
| Replaces text nodes | Text frames remain editable in PowerPoint |
|
||||
| Writes `notes` fields | Speaker notes are embedded as native PowerPoint notes slides |
|
||||
| Applies `--transition` / per-slide `transition` | Populates each slide's `<p:transition>` with a native PowerPoint page transition |
|
||||
| Applies `--transition` / per-slide `transition` | Applies the requested visual-transition and slide-advance policy; `keep` may preserve no carrier and `none` may remove it |
|
||||
| Rebuilds presentation slide list | Output deck contains only the planned slide sequence |
|
||||
| Adds timestamp to PPTX filename | Matches the main SVG-to-PPTX export convention |
|
||||
| Drops orphaned source parts | Output carries only the selected pages and the layouts / media / charts they still reference (reachability prune) |
|
||||
|
||||
**Animation policy**: Template-fill preserves each cloned slide's existing object animation XML (the SVG pipeline's generated object animation defaults are not applied here). Page transitions are the one motion layer this workflow writes directly, and `apply` adds a `fade` transition by default so a filled deck is never left with the template's empty no-motion transitions; change it with `apply --transition` / a per-slide `transition` field, or opt out with `--transition keep` (preserve source) or `--transition none`. If the user asks to change object-level animation order / timing / effects, treat that as a separate direct-PPTX animation customization task.
|
||||
**Animation policy**: Template-fill preserves each cloned slide's existing object animation XML (the SVG pipeline's generated object animation defaults are not applied here). Page transitions are the one motion layer this workflow writes directly, and `apply` adds a `fade` transition by default; change it with `apply --transition` / a per-slide `transition` field, or opt out with `--transition keep` (preserve source) or `--transition none`. `keep` preserves direct and `mc:AlternateContent` transition effects without converting unknown effects to `fade`; explicit replacement removes the old logical carrier before writing one new carrier. If the user asks to change object-level animation order / timing / effects, treat that as a separate direct-PPTX animation customization task.
|
||||
|
||||
---
|
||||
|
||||
@@ -334,11 +339,13 @@ If the extracted text is correct but visual overflow is likely, reduce the text
|
||||
| Replace text in existing text frames | Supported |
|
||||
| Edit native PowerPoint table cell text | Supported |
|
||||
| Edit native PowerPoint chart categories / series data | Supported |
|
||||
| Read SmartArt node text / hierarchy / layout | Supported in intake and planning |
|
||||
| Preserve existing native SmartArt unchanged | Supported by recursive private-part cloning |
|
||||
| Preserve original visual design | Supported by cloning slide parts directly |
|
||||
| Page-to-page transitions | Supported via `apply --transition` or per-slide `transition` |
|
||||
| Replace images | Not in v1 |
|
||||
| Object-level entrance animations | Not in v1; preserved from source only, set as a separate task |
|
||||
| Edit chart formatting / axes / legend layout | Not in v1 |
|
||||
| Edit SmartArt deeply | Not in v1 |
|
||||
| Edit or generate native SmartArt | Not supported; regenerated visual routes use ordinary editable shapes |
|
||||
| Automatic visual overflow detection | Not in v1; use text-capacity judgment from the library slots |
|
||||
| Material-divergence reshaping (§c content strategy) | Not applicable — this workflow fills text into existing slots, it does not author an outline from a source, so the main pipeline's `content_divergence` free-text field has no role here |
|
||||
|
||||
Reference in New Issue
Block a user