Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-07-11 00:01:27 +08:00
parent 53d1b02685
commit a61708780e
149 changed files with 9608 additions and 4805 deletions
+12 -12
View File
@@ -268,18 +268,6 @@
},
"category": "文档处理"
},
{
"name": "ui-ux-pro-max",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/ui-ux-pro-max"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "设计"
},
{
"name": "taste-skill",
"source": {
@@ -304,6 +292,18 @@
},
"category": "开发工具"
},
{
"name": "ui-ux-pro-max",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/ui-ux-pro-max"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "设计"
},
{
"name": "shadcn",
"source": {
+10 -10
View File
@@ -15,8 +15,8 @@
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "f4110b0ad672c87f2ca31677ba7faee70332875a",
"syncedAt": "2026-07-08T16:00:01Z"
"commit": "5d43a5bf6f008de17f9425bee4495c457c60b96a",
"syncedAt": "2026-07-10T16:00:00Z"
},
{
"id": "ui-ux-pro-max",
@@ -24,8 +24,8 @@
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "12b486b22e67f5d887962ef8351c1ac863bfaeb9",
"syncedAt": "2026-07-06T16:00:00Z"
"commit": "3da52ff1cab1be91848072ec1be5f493d730fd5f",
"syncedAt": "2026-07-10T16:00:00Z"
},
{
"id": "caveman",
@@ -51,8 +51,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "21e4ceb94418096e21a7f1990027741a8f9b085d",
"syncedAt": "2026-07-09T15:59:59Z"
"commit": "3cdaa6eb2f0da27aca8598cb752c32d840e06940",
"syncedAt": "2026-07-10T16:00:00Z"
},
{
"id": "frontend-slides",
@@ -87,8 +87,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "b0beba5b659c664bdbf0c07227fbdee313698dd7",
"syncedAt": "2026-07-09T15:59:59Z"
"commit": "3f3f2418163ccb438d010f3acbce4a3651b758a5",
"syncedAt": "2026-07-10T16:00:00Z"
},
{
"id": "next-skills",
@@ -96,8 +96,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "34d92a50266d2b70be5ac8ac147bd270f52d4a12",
"syncedAt": "2026-07-09T15:59:59Z"
"commit": "46681d90f0fd92bb22fbf38b43b507faad47a812",
"syncedAt": "2026-07-10T16:00:00Z"
}
]
}
+12 -12
View File
@@ -268,18 +268,6 @@
},
"category": "文档处理"
},
{
"name": "ui-ux-pro-max",
"source": {
"source": "local",
"path": "./plugins/ui-ux-pro-max"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "设计"
},
{
"name": "taste-skill",
"source": {
@@ -304,6 +292,18 @@
},
"category": "开发工具"
},
{
"name": "ui-ux-pro-max",
"source": {
"source": "local",
"path": "./plugins/ui-ux-pro-max"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "设计"
},
{
"name": "shadcn",
"source": {
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-07-09T16:01:35Z"
"syncedAt": "2026-07-10T16:01:26Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "34d92a50266d2b70be5ac8ac147bd270f52d4a12",
"commit": "46681d90f0fd92bb22fbf38b43b507faad47a812",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-07-09T15:59:59Z"
"syncedAt": "2026-07-10T16:00:00Z"
}
@@ -1,6 +1,6 @@
{
"name": "oh-my-codex",
"version": "0.19.1",
"version": "0.20.0",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": {
"name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"commit": "f4110b0ad672c87f2ca31677ba7faee70332875a",
"commit": "5d43a5bf6f008de17f9425bee4495c457c60b96a",
"adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-07-08T16:00:01Z"
"syncedAt": "2026-07-10T16:00:00Z"
}
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { extname } from 'node:path';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -131,20 +131,82 @@ function detectCompactHookInput(input) {
}
}
async function readBoundedStdin() {
function parseHookPayload(input) {
try {
const parsed = JSON.parse(input.toString('utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function sanitizeLaunchId(value) {
return String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
}
function resolveLaunchClaimPath(payload, launchId) {
const cwd = typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd : process.cwd();
const stateRoot = typeof process.env.OMX_ROOT === 'string' && process.env.OMX_ROOT.trim()
? process.env.OMX_ROOT.trim()
: join(cwd, '.omx');
return join(stateRoot, 'state', 'plugin-hook-launches', `${sanitizeLaunchId(launchId)}.json`);
}
function hookPayloadSessionId(input, payload) {
const parsedSessionId = typeof payload.session_id === 'string' ? payload.session_id.trim() : '';
if (parsedSessionId) return parsedSessionId;
return extractTopLevelStringField(input.toString('utf8'), ['session_id', 'sessionId'])?.trim() ?? '';
}
function isOmxLauncherSession(input, payload) {
const launchId = process.env.OMX_CODEX_LAUNCH_ID?.trim();
const entryPath = process.env.OMX_ENTRY_PATH?.trim();
const sessionId = hookPayloadSessionId(input, payload);
if (!launchId || !entryPath || !sessionId) return false;
const claimPath = resolveLaunchClaimPath(payload, launchId);
try {
if (existsSync(claimPath)) {
const claimed = JSON.parse(readFileSync(claimPath, 'utf8'));
return claimed?.sessionId === sessionId;
}
mkdirSync(dirname(claimPath), { recursive: true });
writeFileSync(claimPath, `${JSON.stringify({ sessionId })}\n`, { encoding: 'utf8', mode: 0o600 });
return true;
} catch {
return false;
}
}
function writePlainCodexNoop(isStop) {
if (isStop) process.stdout.write('{}\n');
process.exitCode = 0;
}
async function readBoundedStdin({ drainOversized = false } = {}) {
const chunks = [];
let totalBytes = 0;
let storedBytes = 0;
let oversized = false;
for await (const rawChunk of process.stdin) {
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
totalBytes += chunk.length;
if (totalBytes > MAX_WRAPPER_STDIN_BYTES) {
const remaining = MAX_WRAPPER_STDIN_BYTES - Buffer.concat(chunks).length;
if (oversized) continue;
const remaining = MAX_WRAPPER_STDIN_BYTES - storedBytes;
if (chunk.length > remaining) {
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
return { input: Buffer.concat(chunks), oversized: true, totalBytes };
storedBytes += Math.max(remaining, 0);
oversized = true;
if (!drainOversized) return { input: Buffer.concat(chunks), oversized: true, totalBytes };
continue;
}
chunks.push(chunk);
storedBytes += chunk.length;
}
return { input: Buffer.concat(chunks), oversized: false, totalBytes };
return { input: Buffer.concat(chunks), oversized, totalBytes };
}
function stopFallbackOutput(stopReason, detail) {
@@ -326,9 +388,7 @@ function hasActiveAutopilotStateForOversizedStop(input) {
}
function parseSingleJsonObjectOutput(raw) {
const text = String(raw ?? '').trim();
if (!text) return null;
function parseJsonObjectCandidate(text) {
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
@@ -338,11 +398,37 @@ function parseSingleJsonObjectOutput(raw) {
}
}
function isJsonObjectFragmentLine(text) {
return text.startsWith('{') || text.endsWith('}') || /^"[^"]+"\s*:/.test(text);
}
function parseSingleJsonObjectOutput(raw) {
const text = String(raw ?? '').trim();
if (!text) return null;
const direct = parseJsonObjectCandidate(text);
if (direct) return direct;
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const lastLine = lines.at(-1);
if (!lastLine || !lastLine.startsWith('{') || !lastLine.endsWith('}')) return null;
for (const line of lines.slice(0, -1)) {
if (parseJsonObjectCandidate(line) || isJsonObjectFragmentLine(line)) return null;
}
return parseJsonObjectCandidate(lastLine);
}
async function main() {
const { input, oversized, totalBytes } = await readBoundedStdin();
const { input, oversized, totalBytes } = await readBoundedStdin({ drainOversized: true });
const payload = parseHookPayload(input);
const launchedByOmx = isOmxLauncherSession(input, payload);
const isStop = detectStopHookInput(input);
const isCompact = detectCompactHookInput(input);
if (!launchedByOmx) {
writePlainCodexNoop(isStop);
return;
}
if (oversized) {
const message = `plugin hook stdin exceeded ${MAX_WRAPPER_STDIN_BYTES} bytes before launcher delegation; totalBytes>${totalBytes}`;
if (isStop) {
@@ -15,7 +15,7 @@ Use this skill when:
- Follow-up implementation left duplicate code, dead code, weak boundaries, missing tests, fallback-like code, or unnecessary wrapper layers
- You need a disciplined cleanup workflow without broad rewrites
## GPT-5.5 Guidance Alignment
## GPT-5.6 Guidance Alignment
- Keep outputs concise and evidence-dense unless risk or the user requests more detail.
- Treat newer user instructions as local workflow updates without discarding earlier non-conflicting constraints.
@@ -15,7 +15,7 @@ This skill activates when:
- After implementing a major feature
- User wants quality assessment
## GPT-5.5 Guidance Alignment
## GPT-5.6 Guidance Alignment
- Default to outcome-first progress and completion reporting: state the target result, evidence, validation status, and stop condition before adding process detail.
- Treat newer user task updates as local overrides for the active workflow branch while preserving earlier non-conflicting constraints.
@@ -35,11 +35,11 @@ Supported setup flags (current implementation):
- if a TTY user has persisted setup preferences, `omx setup` first summarizes the recorded choices and asks whether to **keep**, **review/change**, or **reset** them
- else interactive prompt on TTY (default `user`)
- else default `user` (safe for CI/tests)
2. If scope is `user`, resolve user skill delivery mode:
2. Resolve setup install mode:
- explicit `--plugin`, `--legacy`, or `--install-mode legacy|plugin`, if present
- persisted install mode in `./.omx/setup-scope.json`, if present and the TTY review decision is `keep`
- else discovered installed plugin cache under `${CODEX_HOME:-~/.codex}/plugins/cache/**/.codex-plugin/plugin.json` with `name: oh-my-codex` makes `plugin` the default
- else interactive prompt on TTY (`legacy` by default, or `plugin` when a plugin cache is discovered)
- else discovered installed plugin cache under `${CODEX_HOME:-~/.codex}/plugins/cache/**/.codex-plugin/plugin.json` with `name: oh-my-codex` makes `plugin` the default for both `user` and `project` scope, so project setup does not duplicate plugin-provided skills/hooks with legacy `.codex/skills` and `.codex/hooks.json`
- else in `user` scope, interactive prompt on TTY (`legacy` by default, or `plugin` when a plugin cache is discovered)
- else default `legacy` unless a plugin cache is discovered
3. Create directories and persist effective scope/install mode
4. In legacy mode, install prompts/native agents/skills and merge full config.toml. In plugin mode, archive/remove legacy OMX-managed prompts/skills, refresh installable native agent TOMLs for `agent_type` routing, clean up stale generated non-installable native agents, and keep native Codex hooks installed.
@@ -51,7 +51,7 @@ Supported setup flags (current implementation):
- `omx setup` prompts for scope when no scope is provided and stdin/stdout are TTY. If `./.omx/setup-scope.json` already exists, setup now summarizes the saved choices first and asks whether to keep them, review/change them, or reset and behave like a fresh setup run.
- Non-interactive setup never blocks for this review prompt: it keeps deterministic CLI/persisted/default behavior for CI and scripted installs.
- In `user` scope, `omx setup` also prompts for skill delivery mode when no prior install mode is kept; installed plugin cache discovery makes plugin mode the default prompt/non-interactive choice.
- In `user` scope, `omx setup` also prompts for skill delivery mode when no prior install mode is kept; installed plugin cache discovery makes plugin mode the default prompt/non-interactive choice. In `project` scope, installed plugin cache discovery selects plugin mode non-interactively to avoid overlapping project legacy skills/hooks with plugin-provided surfaces.
- Local project orchestration file is `./AGENTS.md` (project root).
- If `AGENTS.md` exists and neither `--force` nor `--merge-agents` is used, interactive TTY runs ask whether to overwrite. Non-interactive runs preserve the file.
- Use `--merge-agents` to keep existing project guidance while allowing setup to refresh OMX-managed AGENTS sections and the generated model capability table idempotently.
@@ -73,7 +73,7 @@ Use this map when reconciling setup behavior or debugging a confusing install:
| Surface | Owner | Notes |
| --- | --- | --- |
| `./.omx/setup-scope.json` | `omx setup` | Persists setup scope and user-scope skill delivery mode. TTY reruns summarize it and offer keep/review/reset. |
| `./.omx/setup-scope.json` | `omx setup` | Persists setup scope and install mode when needed. TTY reruns summarize it and offer keep/review/reset. |
| `~/.codex/config.toml` / `./.codex/config.toml` | `omx setup` generated blocks + user edits | Setup refreshes OMX-managed blocks while preserving supported manual content; setup-owned runtime feature flags include `multi_agent`, `child_agents_md`, the Codex hook feature flag (`hooks` or legacy `codex_hooks`), and `goals`. |
| `~/.codex/hooks.json` / `./.codex/hooks.json` | `omx setup` shared ownership | Setup owns OMX native hook wrappers and preserves user-owned hooks. |
| prompts, skills, native agents | `omx setup` or Codex plugin delivery | Legacy mode installs local files; plugin mode relies on plugin discovery for bundled skills, archives/removes legacy OMX-managed prompt/skill copies, and refreshes setup-owned native agent TOMLs for `agent_type` routing while cleaning up stale generated/non-installable native agents. |
@@ -42,7 +42,7 @@ OMX already has `$plan`, `$ralplan`, and `$deep-interview`. Prometheus Strict ex
- Ask high-leverage questions as a batched round when the answers materially change scope, safety, or validation. Reserve one-at-a-time questioning only for dependent question chains where the next question depends on the previous answer.
- If a safe assumption is available, state it and continue.
- Use repository reads when needed to make paths, tests, and handoff commands concrete.
- During Metis planning, run pre-question research fan-out for every non-trivial intent unless the task is trivial, the cited spec is self-contained, or cached evidence already covers the same surface; use `explore` for repo facts and the exact cheap `gpt-5.4-mini` `researcher` lane for external docs / OSS references before asking the user. Prometheus Strict may fan out up to `2 explore + 4 researcher` agents per round so breadth comes from more citation-focused mini researchers while Metis/Momus/Oracle keep stronger judgment roles.
- During Metis planning, run pre-question research fan-out for every non-trivial intent unless the task is trivial, the cited spec is self-contained, or cached evidence already covers the same surface; use `explore` for repo facts and the exact cheap `gpt-5.6-terra` `researcher` lane for external docs / OSS references before asking the user. Prometheus Strict may fan out up to `2 explore + 4 researcher` agents per round so breadth comes from more citation-focused mini researchers while Metis/Momus/Oracle keep stronger judgment roles.
- Recommend `$team` only when Oracle identifies independent, bounded, verifiable lanes.
### Structured Question Surface
@@ -93,7 +93,7 @@ If the prompt contains destructive, credential-gated, external-production, or ma
Use `prometheus-strict-metis` as the interview voice. When native subagents are available, invoke the dedicated agent; otherwise run the same role in-context without editing files.
Metis discovers success criteria, non-goals, evidence versus assumptions, required artifacts, likely execution lanes, and missing decisions. Before the first user-facing question batch, Metis must actively fan out repo/external research per intent: `explore` maps local surfaces and exact `gpt-5.4-mini` `researcher` lanes gather official/upstream or OSS-reference evidence. Research-heavy intents use more cheap researchers rather than downgrading Metis/Momus/Oracle judgment.
Metis discovers success criteria, non-goals, evidence versus assumptions, required artifacts, likely execution lanes, and missing decisions. Before the first user-facing question batch, Metis must actively fan out repo/external research per intent: `explore` maps local surfaces and exact `gpt-5.6-terra` `researcher` lanes gather official/upstream or OSS-reference evidence. Research-heavy intents use more cheap researchers rather than downgrading Metis/Momus/Oracle judgment.
Run the interview as a bounded loop:
@@ -148,7 +148,7 @@ $team <N>:executor "execute the approved Ultragoal story in parallel lanes" # o
<Tool_Usage>
- Use read-only repository inspection to verify referenced files, commands, and existing conventions.
- Treat Metis research fan-out as part of planning, not execution: dispatch `explore` / exact `gpt-5.4-mini` `researcher` evidence-gathering before question generation for non-trivial intents, then re-prefill and ask only surviving CRITICAL gaps.
- Treat Metis research fan-out as part of planning, not execution: dispatch `explore` / exact `gpt-5.6-terra` `researcher` evidence-gathering before question generation for non-trivial intents, then re-prefill and ask only surviving CRITICAL gaps.
- Use `prometheus-strict-metis`, `prometheus-strict-momus`, and `prometheus-strict-oracle` sequentially; do not fan out implementation work from this skill.
- Use `$ultragoal` only as the recommended execution handoff after the plan is ready.
- Use `$team` only when parallel lanes are independent and verifiable.
@@ -30,7 +30,7 @@ $ralplan --interactive "task description"
## Behavior
## GPT-5.5 Guidance Alignment
## GPT-5.6 Guidance Alignment
Use the shared workflow guidance pattern: outcome-first framing, concise visible updates for multi-step planning, local overrides for the active workflow branch, evidence-backed planning and validation expectations, explicit stop rules, right-sized implementation/PRD shape, and automatic continuation for safe reversible steps. Ask only for material, destructive, credentialed, external-production, or preference-dependent branches.
@@ -17,7 +17,7 @@ This skill is operationally sensitive. Treat it as an operator workflow, not a g
## What This Skill Must Do
## GPT-5.5 Guidance Alignment
## GPT-5.6 Guidance Alignment
Use the shared workflow guidance pattern: outcome-first framing, concise visible updates for multi-step work, local overrides for the active workflow branch, validation proportional to risk, explicit stop rules, and automatic continuation for safe reversible steps. Ask only for material, destructive, credentialed, external-production, or preference-dependent branches.
@@ -47,8 +47,8 @@ Use role to choose responsibility, tier to choose depth, and posture to choose o
- Prioritizes intent classification, delegation, verification, and architectural judgment.
- Typical roles: `planner`, `analyst`, `architect`, `critic`, `code-reviewer`.
- Ralplan keeps `planner` and `architect` in this posture; `planner`
uses exact `gpt-5.5` with medium reasoning, `architect` uses exact
`gpt-5.5` with xhigh reasoning, and the `critic` consensus gate stays
uses exact `gpt-5.6-sol` with medium reasoning, `architect` uses exact
`gpt-5.6-sol` with xhigh reasoning, and the `critic` consensus gate stays
on the frontier lane.
- `deep-worker`:
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "b0beba5b659c664bdbf0c07227fbdee313698dd7",
"commit": "3f3f2418163ccb438d010f3acbce4a3651b758a5",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-09T15:59:59Z"
"syncedAt": "2026-07-10T16:00:00Z"
}
@@ -9,6 +9,18 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
**Core Pipeline**: `Source Document → Create Project → [Template] → Strategist → [Image_Generator] → Executor Live Preview → Quality Check → Post-processing → Export`
### SVG Page-Design Boundary
| Scope | Contract |
|---|---|
| Any route that authors or regenerates slide visuals through SVG | `svg_output/` is the complete page-design source: every visible text, image, shape, chart/table fallback, and layout element that should appear on the exported slide is present in that page SVG or referenced by it. |
| 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. |
| 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.”
> [!CAUTION]
> ## 🚨 Global Execution Discipline (MANDATORY)
>
@@ -21,7 +33,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
> 5. **NO SPECULATIVE EXECUTION** — "Pre-preparing" content for subsequent Steps is FORBIDDEN (e.g., writing SVG code during the Strategist phase)
> 6. **NO SUB-AGENT SVG GENERATION** — Executor Step 6 SVG generation is context-dependent and MUST be completed by the current main agent end-to-end. Delegating page SVG generation to sub-agents is FORBIDDEN
> 7. **SEQUENTIAL PAGE GENERATION ONLY** — In Executor Step 6, after the global design context is confirmed, SVG pages MUST be generated sequentially page by page in one continuous pass. Grouped page batches (for example, 5 pages at a time) are FORBIDDEN
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit, if any), and `page_charts` (which chart template to adapt, if any). Empty / absent entries are intentional Strategist signals — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the confirmed `pptx_structure.template_adherence` when present, the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit), `page_charts` (which chart template to adapt, if any), and — when `pptx_structure.mode` is `template` or `preserve` — `pptx_layouts` (which native PowerPoint layout the page references). Deck/layout template routes require both mappings for every page: `strict` reuses the selected layout contract; `adaptive` may create a new explicit layout contract from the selected reference. Empty / absent `page_layouts` rows are reserved for free design and brand-only routes — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce
> 10. **FOLLOW DETERMINISTIC ROUTING RULES** — Do not add blocking routing questions when this skill defines a route. If the user request violates a route precondition, state the required prerequisite and stop that route instead of asking the user to choose around the rule. Ordinary finite options, stylistic preferences, and recoverable details are surfaced with a recommended value plus alternatives at the next existing confirmation gate.
@@ -259,6 +271,10 @@ find <project_path>/templates -type f \( -iname '*.png' -o -iname '*.jpg' -o -in
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/`.)
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.
#### Multi-path fusion
When the user gives two or more paths of **different kinds**, Step 3 fuses them into a single `<project>/templates/design_spec.md`. **Default granularity is segment-level integer replacement** — entire identity / structure / middle segments are taken from the highest-priority source for that segment, no implicit field-level mixing.
@@ -312,6 +328,8 @@ When fusion happens (any multi-path case), the resulting `<project>/templates/de
Single-path Step 3 does **not** add provenance (the source is self-evident from the copied files).
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.**
---
@@ -341,16 +359,17 @@ Read references/strategist.md
2. Page count range
3. Target audience
4. Style objective
5. Color scheme
6. Icon usage approach
7. Typography plan, including formula rendering policy
8. Image usage approach
5. Template adherence — `strict` / `adaptive` (only when Step 3 loaded a deck/layout template)
6. Color scheme
7. Icon usage approach
8. Typography plan, including formula rendering policy
9. Image usage approach
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Strategist confirmation stage is presented through an interactive local page in **three stages within one browser session** — Stage 1 confirms the direction anchors; the AI then re-derives the design-system layer from the **user's actual** anchors; Stage 2 confirms that layer; the AI then re-derives image and execution choices from the confirmed direction + design system; Stage 3 confirms the final operational layer. Color swatches, live font previews, icon samples, image-style reference previews, and candidate picks appear where they help judgment; the chat path is the always-valid fallback. [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md) owns the schema, server lifecycle, port strategy, and fallback details; this section keeps the orchestration contract. The split:
| Stage | Confirms | Driven by |
|---|---|---|
| **1 — direction anchors** | canvas · audience + core message + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases)* (all §c key info) · `mode` + `visual_style` | the source + user intent |
| **1 — direction anchors** | canvas · audience + core message + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases)* (all §c key info) · `mode` + `visual_style` · `template_adherence` *(only when Step 3 loaded a deck/layout template)* | the source + user intent |
| **2 — design system** (re-derived from Stage 1) | page count · color · typography (font + size) · icons · formula policy | the confirmed Stage 1 |
| **3 — images / execution** (re-derived from Stage 1 + Stage 2) | image usage · generated-image style · AI-image generation path · generation mode · refine-spec toggle | the confirmed direction + design system |
@@ -360,7 +379,7 @@ Steps:
> ⛔ **Steps 2 → 3 → 4 are ONE uninterrupted run — do NOT yield to the user mid-flow.** When an intermediate `--wait` returns, the AI **immediately and autonomously** re-derives and writes the next stage in the **same turn**: do **not** summarize, ask a question, report progress, or end the turn in between. The browser is sitting on a "deriving…" spinner polling for the next stage you must write — stopping here strands the page and the user must prod you in chat to finish (a bug, not the intended flow). **Stage-1 and Stage-2 confirmations are intermediate machine handoffs, not stopping points.** The single ⛔ BLOCKING wait is the **final** confirmation at the end of step 4. (Chat-fallback path — only when the page never opened — is the exception: there you do present each stage in chat and wait for a reply.)
1. **Write Stage 1** to `<project_path>/confirm_ui/recommendations.json` with `"stage": "stage1"` and only the anchor fields. New recommendations MUST use the canonical `stage` selector. Enumerable anchors (`canvas` / `mode` / `visual_style` / `delivery_purpose`) name a recommended canonical `id` in a `recommend` block (the page lists common options from `confirm_ui/static/catalogs.json`); `visual_style` also carries the ≥3-style `visual_style_spectrum` (safe / shifted / bold — same hard rule as h.5). `audience` and `content_divergence` are plain `{ "value": "<free text>" }`. `content_divergence` is the **free-text** field shown under audience in §c — how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level); it is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language (`zh` / `en` / `ja`); visible text matches `lang`, or provide multilingual `name_zh` / `name_en` / `name_ja` + `note_zh` / `note_en` / `note_ja` — when the user's language is Japanese, set `lang: "ja"` and always include the `_ja` variants (labels resolve in the page language first — a `ja` page falls back ja → en → zh, so missing `_ja` labels silently render in English; zh/en pages keep their zh↔en fallback and only try `_ja` last).
1. **Write Stage 1** to `<project_path>/confirm_ui/recommendations.json` with `"stage": "stage1"` and only the anchor fields. New recommendations MUST use the canonical `stage` selector. Enumerable anchors (`canvas` / `mode` / `visual_style` / `delivery_purpose`) name a recommended canonical `id` in a `recommend` block (the page lists common options from `confirm_ui/static/catalogs.json`); `visual_style` also carries the ≥3-style `visual_style_spectrum` (safe / shifted / bold — same hard rule as h.5). When Step 3 loaded a deck/layout template, also set `recommend.template_adherence` to `strict` or `adaptive`; omit the field entirely for free design and brand-only templates so the page does not display it. `audience` and `content_divergence` are plain `{ "value": "<free text>" }`. `content_divergence` is the **free-text** field shown under audience in §c — how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level); it is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language (`zh` / `en` / `ja`); visible text matches `lang`, or provide multilingual `name_zh` / `name_en` / `name_ja` + `note_zh` / `note_en` / `note_ja` — when the user's language is Japanese, set `lang: "ja"` and always include the `_ja` variants (labels resolve in the page language first — a `ja` page falls back ja → en → zh, so missing `_ja` labels silently render in English; zh/en pages keep their zh↔en fallback and only try `_ja` last).
2. **Launch + wait for Stage 1.** Background launch; the parent returns when the page writes the stage-1 `result.json`. **Long tool timeout — 600000 ms** (the `--wait` ≈590 s budget):
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
@@ -571,13 +590,17 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
> Image facts: trust the `analysis/image_analysis.csv` regenerated at the end of Step 5. If `images/` changed since (the user swapped or added files), re-run `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` before laying images out — facts are re-derived on use, never a stale store (Step 4 image-facts note).
**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups (resolves to template SVGs already loaded in the batch read above). Resists context-compression drift on long decks. See executor-base.md §2.1.
**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups and the route-specific `pptx_structure` / `pptx_layouts` native-layout contract. The contract is absent only on legacy/free inputs that have not yet been normalized; it is mandatory for every current template page. Resists context-compression drift on long decks. See executor-base.md §2.1.
> ⚠️ **Main-agent only**: SVG generation MUST stay in the current main agent — page design depends on full upstream context. Do NOT delegate to sub-agents.
> ⚠️ **Generation rhythm**: generate pages sequentially, one at a time, in the same continuous context. Do NOT batch (e.g., 5 per group).
**Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `<project_path>/svg_output/`
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities.
Every `baseline` / free-design page root MUST declare one canonical `data-pptx-page-role`; `template` / `preserve` pages already declare `data-pptx-layout` and MUST NOT duplicate that identity merely to satisfy this rule. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by `data-pptx-layer`, `data-pptx-placeholder`, or `data-pptx-native`; such an element needs a stable unique `id`. Do not add generic content roles to ordinary titles, body text, cards, KPIs, diagrams, charts, icons, or images. Full contract: [`references/semantic-svg.md`](references/semantic-svg.md).
**Quality Check Gate (Mandatory)** — after all SVGs, BEFORE annotation handling and speaker notes:
```bash
python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
@@ -637,6 +660,9 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
# Output (default-flow mode):
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output, reads svg_output/)
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
# backup/<timestamp>/templates/{native_structure.json,source_template.pptx}
# ← preserve mode only
# backup/<timestamp>/spec_lock.md ← preserve mode only
#
# Add --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/)
@@ -660,10 +686,100 @@ 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` — live preview already provides the
> 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.
> **PPTX structure mode** — native export first reads
> `spec_lock.md` `pptx_structure.mode`, then falls back to `baseline` when the
> section is absent. An explicit `--pptx-structure` CLI value overrides the
> lock for diagnostics. Baseline keeps the real slide master/layout
> relationship from the base deck and promotes the strict-majority identical
> native slide background into the slide master (every slide must carry an
> explicit background; minority slides keep their own, which overrides the
> master fill). Baseline may also promote a shared **leading** prefix of
> top-level SVG elements whose explicit `data-pptx-role` marks page chrome
> (`logo`, `footer`, `header`, `watermark`, `chrome`, `page-number`). Exact id
> tokens remain a compatibility fallback only when the role marker is absent.
> Promotion requires identical generated OOXML on a strict majority of slides
> sharing the master, no slide-timing reference, and z-order safety; overlay
> chrome stays slide-local. Minority slides (covers, section pages) keep every
> shape slide-local and are bound to a generated `Cover` layout with
> `showMasterSp="0"`, so promoted master chrome never appears on them. Image
> relationships for promoted chrome are copied to the master. After the SVG
> pages are complete, baseline assigns conservative semantic layout families:
> root `data-pptx-page-role` values `cover` / `toc` / `section` / `ending` become
> `Cover` / `Agenda` / `Section` / `Closing`, while `content` becomes `Content`.
> Filename tokens remain a compatibility fallback for marker-free legacy SVGs.
> It also
> preserves an existing safety `Cover` assignment made while hiding promoted
> Master chrome. It does not inspect visual similarity or infer placeholders,
> and all actual page content remains slide-local. When every slide in one
> family carries the exact same explicit background, that background moves to
> the family layout. The same family may also promote an identical leading
> prefix of explicitly named chrome when every family member carries the same
> generated OOXML, relationships are safe, no timing targets it, and z-order is
> preserved. This is
> an export-only package pass: SVG authoring, live preview, and model context are
> unchanged. Baseline also prunes base-template slide layouts no generated slide
> references, so the PowerPoint new-slide picker only offers layouts that belong
> to the deck, and
> converts `data-pptx-role="page-number"` chrome whose text exactly equals the
> slide's display number into an auto-updating PowerPoint slide-number field
> (legacy page-number id tokens remain a fallback; other numbering schemes keep
> their literal text). For projects with a
> `spec_lock.md` typography contract, baseline also installs `title_family` as
> the PowerPoint theme major font and `body_family` / `font_family` as the minor
> font; matching SVG runs use theme tokens so later theme edits can cascade
> without changing initial rendering. Baseline/template export likewise maps
> the locked background, text, primary/accent, and border roles into the
> PowerPoint color scheme. Exact matching SVG/native-object colors use
> context-safe theme tokens; unrelated local colors, inverse white/black, and
> effects stay concrete, so the first rendering remains the SVG design while
> later theme edits can cascade selectively. Add
> `--pptx-structure flat` only for debugging/comparison when all generated
> backgrounds and chrome must remain slide-local and fonts must stay concrete.
> **Legacy preserved source-template export** — an existing template package
> from a structured PPTX may ship `native_structure.json` plus
> `source_template.pptx`. This pair records a reusable capability; it does not
> select the downstream route by itself. Only a confirmed
> `template_adherence: strict` deck may set `pptx_structure.mode: preserve`.
> Add project-relative
> `source_template` / `native_structure` rows, and map every page in
> `pptx_layouts` to an exact imported layout key/name. Native export verifies the
> source SHA-256 and package parts, creates the generated slides against the
> original layouts (including multiple source masters), removes SVG-only preview
> copies of inherited master/layout visuals, and binds slide content to the
> original placeholder type/index. Preserve mode keeps the source layout picker
> roster; it does not prune unused source layouts.
>
> Current template creation no longer emits this pair. `preserve` is retained
> only so existing template packages and projects remain exportable; do not
> select it for newly created templates.
> **Explicit reusable template export** — deck/layout template routes set
> `spec_lock.md` `pptx_structure.mode: template`. Every SVG root declares
> `data-pptx-layout`, and direct children
> explicitly declare master/layout layers, an optional Slide background, or
> PowerPoint placeholders. This
> mode builds named reusable layouts and placeholder mappings; it does not
> infer them from visual similarity. Static master/layout elements must be
> repeated consistently in PowerPoint paint order. Chart/table placeholders
> additionally require `--native-objects`. The metadata contract and authoring
> example live in
> [`references/shared-standards.md`](references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export).
> Raw PPTX templates still route to `template-fill-pptx`; `template` structure
> mode is for explicitly annotated SVG authoring. Both `strict` and `adaptive`
> template use stay on this deterministic route. Adaptive pages may define new
> layout keys, but they repeat the same Master layer and one internally
> consistent Layout/placeholder contract.
> 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.
> **Paragraph editability vs line fidelity** — by default, mergeable dy-stacked
> paragraph blocks collapse into one editable PowerPoint text frame with multiple
> `<a:p>`, improving body-text editing and resize/reflow behavior. Add `--no-merge`
@@ -679,7 +795,10 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> PowerPoint-editable native tables/charts and accepts that those objects may
> render differently across PowerPoint / Keynote / LibreOffice / WPS. Without
> the flag, marked groups export through their SVG fallback children like
> ordinary SVG content.
> ordinary SVG content. Imported objects that carry
> `data-pptx-native-status` are fallback-only; the quality checker and
> `--native-objects` export surface their reason as warnings rather than silently
> claiming editability.
**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`.
@@ -702,7 +821,8 @@ Full effect list, anchor logic, and limits: [`references/animations.md`](referen
> ❌ **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` (it suppresses one of the two output files)
> ❌ **NEVER** use `--only` in the standard pipeline. Keep it only for explicit
> one-product diagnostics or compatibility checks.
> **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.
@@ -729,6 +849,7 @@ Before switching roles, **MUST first read** the corresponding reference file. Ou
| Resource | Path |
|----------|------|
| Shared technical constraints | `references/shared-standards.md` |
| Semantic SVG marker contract | `references/semantic-svg.md` |
| Canvas format specification | `references/canvas-formats.md` |
| Image-text layout patterns (Primary structures + Modifier layers — combine freely) | `references/image-layout-patterns.md` |
| Image layout sizing (math for side-by-side container dimensions) | `references/image-layout-spec.md` |
@@ -24,7 +24,7 @@ Global artifact ownership rules for PPT Master projects.
| `templates/` | Project template reference | Step 3 imported specs, template SVGs, and non-image assets | Strategist/Executor read only when Step 3 is triggered |
| `confirm_ui/recommendations.json` | Confirmation proposal | Strategist-authored confirmation payload | Confirm UI reads; rewritten between Stage 1, Stage 2, and Stage 3 |
| `confirm_ui/result.json` | Confirmation result | User-confirmed values | Strategist treats final result as authoritative over recommendations |
| `svg_output/` | Author source | Main-agent handwritten SVG pages | Quality checker and native PPTX export read this as canonical page source |
| `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` |
@@ -44,6 +44,8 @@ Global artifact ownership rules for PPT Master projects.
| 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. |
| Confirmation | Final `confirm_ui/result.json` or chat confirmation overrides recommendations. |
@@ -2,6 +2,12 @@
> Narrative skeleton and visual aesthetic come from this deck's locked files under [`modes/`](./modes/_index.md) and [`visual-styles/`](./visual-styles/_index.md). Technical constraints are in shared-standards.md.
**Hard rule — complete page SVG**: Every visible object intended for the exported slide MUST exist in the final page SVG or be explicitly referenced by it. Templates and `spec_lock.md` guide construction; they are not export-time overlays for missing visible content.
**Hard rule — minimal semantics without semantic loss**: `baseline` / free-design roots declare `data-pptx-page-role`; `template` / `preserve` roots already use `data-pptx-layout` and do not duplicate that identity. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by layer, placeholder, or native-object metadata; the marked element uses a stable unique `id`. Ordinary page content keeps normal SVG structure without duplicate semantic classification. See [`semantic-svg.md`](./semantic-svg.md).
> 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.
---
## 1. Template Adherence Rules
@@ -13,6 +19,7 @@
| Source list | Read path |
|---|---|
| Chosen template's `design_spec.md` (read frontmatter to detect `replication_mode`) | `templates/design_spec.md` |
| Legacy preserve-mode native layout/placeholder contract, when present | `templates/native_structure.json` |
| Every distinct `<basename>` in `spec_lock.md page_layouts` | `templates/<basename>.svg` |
| Every distinct chart name in `spec_lock.md page_charts` | `templates/charts/<chart_name>.svg` |
| Chart types in `design_spec.md §VII` not covered above | `templates/charts/<chart_name>.svg` |
@@ -33,9 +40,9 @@ Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative
1. **Mirror-mode template** (template's `design_spec.md` frontmatter has `replication_mode: mirror`) → see §1.1 below. The page is consumed as a **visual reference**, not as a placeholder shell.
2. `spec_lock.md page_layouts` has `P<NN>: <basename>` for this page → inherit the structure of `templates/<basename>.svg` (already in context from §1.0).
3. `page_layouts` exists but **no entry** for this page → **free design**, no template inheritance.
4. `page_layouts` section absent (legacy deck) **and** `templates/` directory exists → fall back to the page-type table below, matching by SVG filename keyword (cover/chapter/content/ending/toc). Read the matched file at first use if §1.0 batch did not cover it.
5. No template at all → free design.
3. `pptx_structure.mode: template` but this page has no `page_layouts` entry → stop; the template contract is incomplete. Adaptive mode must still select a reference SVG.
4. `page_layouts` section absent in a legacy project and `templates/` exists → fall back to the page-type table below, matching by SVG filename keyword. Read the matched file at first use if §1.0 did not cover it.
5. No deck/layout template at all → free design.
> Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot.
@@ -58,14 +65,14 @@ Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative
When the project's chosen template is a `mirror` template (`design_spec.md` frontmatter declares `replication_mode: mirror`), Executor switches to a **reference-style** consumption path that bypasses placeholder substitution:
1. **Per-page reference selection** — Strategist selects one mirror page per project page via `spec_lock.md page_layouts` (e.g., `P04: 015_content`). The basename is the mirror filename without extension; Strategist made this choice by reading `design_spec.md §V Page Roster` descriptions, not by guessing.
2. **Copy, don't fill** — open the referenced mirror SVG (already in context from §1.0). **Copy it as the starting point for the project page**, then edit text elements in place to express the project's content for `P<NN>`. Preserve every non-text element verbatim: backgrounds, decorative shapes, sprite-cropped images, charts, icon usage, color values, font families, geometry, sprite `<svg viewBox>` wrappers, and **which image** each `<image>` points at.
2. **Copy, don't fill** — open the referenced mirror SVG (already in context from §1.0). Copy it as the starting point, then edit slide-specific text in place. Preserve every non-text element and every `data-pptx-*` structure attribute verbatim unless adaptive mode intentionally assigns a new Layout contract.
3. **What you may edit** — the visible text content of `<text>` / `<tspan>` elements that express slide-specific content (title, body, captions, KPI labels, dates, page numbers). Replace the source deck's example text with the project's text for this page from `design_spec.md §IX` and `notes/<NN>_*.md`.
4. **What you must not touch** — element positions, sizes, fonts, colors, fills, strokes, gradients, **which image each `<image>` points at**, `<g>` grouping, sprite-sheet `<svg viewBox>` wrappers, decorative `<rect>` / `<path>` / `<circle>` / `<polygon>` shapes, `<use data-icon="...">` markers, embedded chart data structures. Mirror's value is preserving the source deck's visual identity — any geometric / decorative drift defeats the purpose. **The `href` path is not the image**: normalizing a bare `href="cover_bg.png"` to `href="../images/<name>"` (when Step 3 relocated the asset to `images/`) points at the *same* image and changes nothing visual — that is an allowed path fix, not a fidelity edit. Leaving the bare href as-is is also fine; the exporter and live preview resolve bare hrefs against `images/` either way.
5. **Content fit** — the mirror page was chosen by Strategist because its layout matches the content slot. If the project's content for `P<NN>` legitimately needs more / fewer items than the mirror page provides (e.g. mirror shows 3 KPI cards, project has 4 metrics), keep the mirror page's visual rhythm and either drop one metric to fit or split across two pages — do **not** restructure the mirror page's grid. If neither works, surface a `warning: P<NN> content does not fit mirror reference <basename>; suggest different reference page` and proceed with the closest-fit edit.
6. **No `{{}}` substitution** — mirror SVGs do not contain placeholder markers. Do not search for `{{TITLE}}` / `{{CONTENT_AREA}}` etc.; do not invent placeholders. The whole mirror contract is "verbatim source + in-place text edit".
6. **Visible text editing** — mirror SVGs may keep literal source text rather than `{{...}}` authoring markers. Edit visible text in place, but retain any imported semantic `data-pptx-placeholder` identity.
7. **Output filename** — follow the standard project SVG naming convention (`<NN>_<page_name>.svg` where `<NN>` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*.
**Detecting mirror mode**: read the chosen template's `design_spec.md` frontmatter once during §1.0 batch read. If `replication_mode: mirror`, every page that hits `page_layouts` follows §1.1 above; pages without a `page_layouts` entry still fall through to free design (resolution rule 3 above).
**Detecting mirror mode**: read the chosen template's `design_spec.md` frontmatter once during §1.0 batch read. If `replication_mode: mirror`, every page follows §1.1 through its mandatory `page_layouts` reference.
**Mirror + chart pages**: chart structures inside a mirror SVG are already drawn (axis, series, labels). Treat them as visual references — replace the data labels and series text content to match the project's chart spec, but do not redraw the chart from a `templates/charts/<name>.svg` baseline. A mirror template's `page_charts` entries are normally absent for this reason.
@@ -84,12 +91,44 @@ When the project's chosen template is a `mirror` template (`design_spec.md` fron
Before generating each page, output which template is used:
```
📝 **Template mapping**: `templates/03a_content_image_text.svg` (or "None (free design)")
📝 **Template mapping**: `templates/03a_content_image_text.svg` (free-design routes may use "None")
🎯 **Adherence rules / layout strategy**: [specific description]
```
- **Content pages**: template defines only header/footer; content area is free
- **No template**: generate entirely per the Design Spec
- **No template**: allowed only on free-design or brand-only routes
### 1.2 PowerPoint Master / Layout Mapping
`page_layouts` selects an SVG design reference; `pptx_layouts` declares the native PowerPoint layout produced at export. They are independent contracts.
`pptx_structure.template_adherence` records the Strategist's confirmed template-use policy. Both `strict` and `adaptive` require one real `page_layouts` reference and one `pptx_layouts` output mapping per page. Strict keeps the referenced Layout contract; adaptive may create a new explicit Layout while retaining the template Master.
| `pptx_structure.mode` | Executor behavior |
|---|---|
| `baseline` or missing | Free design or brand-only route. Author complete standalone SVG pages with root page-role and only necessary structural hints; export assigns conservative marker-backed Layout families after generation. |
| `template` | Every generated SVG MUST implement the matching `pptx_layouts` row and the explicit structure contract below. |
| `preserve` | Legacy strict-only compatibility. Use the locked source layout key/name and retain marked preview layers for source-package reuse. |
**Hard rule — every structured page references one layout**: In `template` or `preserve` mode, read `P<NN>: <layout_key> | <layout name>` from `pptx_layouts`, then put both values on the root SVG as `data-pptx-layout` and `data-pptx-layout-name`. Strict copies the selected template key/name. Adaptive may use the new key/name already locked by Strategist; Executor must not improvise another.
**Hard rule — PowerPoint paint order**: Direct visual children appear in this order: Master background, Layout background, optional Slide background, shared Master shapes, same-key Layout shapes, then slide-local content/placeholders. Backgrounds are the special plane beneath all inherited shapes. Repeat the same Master contract on every page and the same Layout contract on every page sharing a key.
**Placeholder ownership**: Keep actual title/subtitle/body/picture/chart/table/object/media/date/footer/slide-number content on the Slide and add the matching `data-pptx-placeholder`. Do not duplicate placeholder identity with `data-pptx-role`; in particular, `data-pptx-placeholder="slide-number"` already owns page-number field behavior. A reconstructed `title` normally omits `data-pptx-placeholder-idx`; preserve an explicit imported title index when present. Every indexed placeholder on one layout uses a unique index. Chart/table placeholders require native markers and a later `--native-objects` export; object/media placeholders must each resolve to one top-level DrawingML object.
**Adaptive Layout creation**: Start from the selected template SVG, keep every Master element identical, and change only the Layout layer/placeholder contract needed for the new composition. Pages sharing the new key repeat the same static Layout elements and placeholder ids/types in the same order.
**Legacy preserve identity**: In `preserve` mode only, copy source `data-pptx-placeholder-idx` values and retain the source placeholder type/index pairing. Master/Layout preview elements keep `data-pptx-layer` plus `data-pptx-editable="false"`.
**Background ownership**:
| Scope | SVG authoring |
|---|---|
| Deck-wide default | Direct full-canvas solid `<rect data-pptx-layer="master">` repeated identically on every page |
| Page-type default | Direct full-canvas solid `<rect data-pptx-layer="layout">` repeated on every page sharing that layout key |
| One-page exception | Direct full-canvas solid `<rect data-pptx-layer="slide">` |
The exporter writes these solid fills as real Master/Layout/Slide `p:bg`, not selectable full-canvas shapes. Gradients, images, textures, and overlay panels stay explicit shapes unless the shared standard says otherwise.
---
@@ -146,11 +185,18 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
Before drawing each page, look up its entry in `page_layouts` to decide which basename to inherit (the SVG itself was loaded in §1.0):
- Entry present (e.g., `P04: 03a_content_image_text`) → inherit the corresponding SVG already in context. The basename **must match** an actual file in the chosen template directory; if it doesn't, emit `warning: page_layouts P<NN> references missing file <basename>.svg — falling back to free design` and proceed.
- No entry for this page → free design, no inheritance. **Not an error** — Strategist intentionally left this page free.
- Whole section absent → see §1 fallback (legacy page-type matching).
- Entry present (e.g., `P04: 03a_content_image_text`) → inherit the corresponding SVG already in context. The basename **must match** an actual file in the chosen template directory. If it does not, stop before drawing and report the invalid mapping; neither `strict` nor `adaptive` may fall back to free design inside a template deck.
- No entry for this page with `template_adherence: strict|adaptive` → stop before drawing and report the missing Strategist mapping. Adaptive mode still selects the closest template SVG as its visual/structural reference; flexibility applies to the new output Layout, not to whether a template reference exists.
- Whole section absent with `pptx_structure.mode: template` → stop before drawing; the current template contract is incomplete.
- Whole section absent only in a legacy project without the current template contract → see §1 fallback (legacy page-type matching).
Do **not** invent a layout entry, and do **not** assume a template just because `templates/` exists — if `page_layouts` is present but silent for this page, that silence is the instruction.
Do **not** invent a layout entry, and do **not** assume a template just because `templates/` exists. For either template-adherence value, a missing or invalid `page_layouts` row is an upstream contract error. Free design is a separate deck route, never a per-page fallback inside template mode.
**Per-page PowerPoint layout lookup — `pptx_layouts` section**:
- `pptx_structure.mode: template` or `preserve` → a `P<NN>` row is mandatory; apply §1.2 to the root and direct children. Preserve mode uses the exact source key/name and placeholder indices.
- `pptx_structure.mode: baseline` or missing → omit explicit PPTX layer/layout metadata, but keep the root page role and only necessary structural hints. Baseline export owns conservative Master/background/chrome promotion plus page-role-backed Layout family assignment; filenames and ids are legacy fallbacks only when the corresponding marker is absent.
- A layout key may repeat across non-adjacent pages. Reuse is based on identical static/placeholder contracts, not page proximity or content wording.
**Per-page chart reference — `page_charts` section**:
@@ -0,0 +1,126 @@
# Minimal Semantic SVG Markers
PPT Master uses a small set of rendering-neutral compiler hints only where
ordinary SVG cannot reliably express a required PowerPoint packaging decision.
These markers are not a second content model and do not abbreviate SVG.
## 1. Boundary
| Marker | Placement | Purpose |
|---|---|---|
| `data-pptx-page-role` | Root `<svg>` | Select the baseline PowerPoint Layout family. Required on newly generated baseline/free-design pages; template/preserve pages already use `data-pptx-layout`. |
| `data-pptx-role` | A structural page-frame element | Identify the few objects whose package or animation behavior is not already expressed by specialized metadata. The element also needs a stable unique `id`. |
The complete geometry, text, styles, grouping, and asset references remain in
ordinary SVG. Removing these markers must not change browser rendering. Do not
copy visible values into metadata, and do not mark ordinary titles, body text,
cards, KPIs, diagrams, charts, icons, or images merely to describe their
content.
Use the existing specialized contracts for specialized facts:
- `data-pptx-layout` and `data-pptx-layer` own Master/Layout/Slide structure;
- `data-pptx-placeholder` owns PowerPoint placeholder identity;
- `data-pptx-native` owns native chart/table reconstruction.
Do not duplicate those facts with `data-pptx-role`. Consumers resolve semantics
in this order: specialized metadata, minimal compiler hints, then legacy
filename/id conventions.
## 2. Canonical Values
### Page roles
| Value | Meaning | Baseline Layout |
|---|---|---|
| `cover` | Opening cover | `Cover` |
| `toc` | Agenda or contents page | `Agenda` |
| `section` | Chapter divider or transition | `Section` |
| `content` | Ordinary information page | `Content` |
| `ending` | Closing, thanks, Q&A, or contact page | `Closing` |
### Structural roles
| Value | Compiler behavior |
|---|---|
| `background` | Treat an otherwise unmarked background as static page framing for animation purposes. |
| `decoration` | Treat decorative page framing as static for animation purposes. |
| `header` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `footer` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `logo` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `watermark` | Eligible for conservative repeated-chrome promotion; skip automatic entrance animation. |
| `chrome` | Generic repeated page-frame object eligible for conservative promotion. |
| `page-number` | Identify a free-design page-number object; template `data-pptx-placeholder="slide-number"` already owns this behavior. |
`background` and `decoration` do not by themselves authorize Master/Layout
promotion. The existing background and exact-shared-structure safety checks
continue to own that decision.
## 3. Examples
### Free-design page
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-page-role="content">
<rect id="page-bg" data-pptx-role="background"
x="0" y="0" width="1280" height="720" fill="#F7F9FC"/>
<!-- Ordinary content keeps normal SVG structure; no duplicate role needed. -->
<g id="growth-story">
<text x="72" y="82" font-size="32" fill="#172033">Quarterly growth</text>
</g>
<text id="slide-number" data-pptx-role="page-number"
x="1200" y="680" font-size="14" fill="#667085">7</text>
</svg>
```
### Reusable template page
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-layout="content-default"
data-pptx-layout-name="Content Default">
<rect id="master-bg"
data-pptx-layer="master"
data-pptx-editable="false"
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<g id="layout-header"
data-pptx-layer="layout"
data-pptx-editable="false">
<!-- Complete reusable header drawing remains here. -->
</g>
<!-- Placeholder identity is already sufficient; no generic title role. -->
<text id="title-slot" data-pptx-placeholder="title"
x="72" y="92" font-size="32" fill="#172033">{{PAGE_TITLE}}</text>
<!-- Logo has no specialized marker, so the minimal structural hint is useful. -->
<text id="brand-mark" data-pptx-role="logo"
x="1180" y="46" text-anchor="end">ACME</text>
<!-- The placeholder already owns slide-number behavior; do not add a role. -->
<text id="page-number" data-pptx-placeholder="slide-number"
x="1200" y="680" text-anchor="end">7</text>
</svg>
```
## 4. Validation and Compatibility
The quality checker validates marker placement, canonical values, and stable
unique IDs. Baseline export consumes explicit markers before compatibility
heuristics:
- root page role is preferred over filename-based Layout classification;
- `data-pptx-placeholder="slide-number"` is preferred over a generic role or id;
- explicit structural role is preferred over id-token chrome detection;
- animation target scanning uses the structural role before id-token fallback.
Filename and id heuristics remain compatibility fallbacks only for older SVGs
that lack the corresponding marker. A canonical page role is authoritative over
the filename. Any explicit structural role prevents id-based reinterpretation;
an unknown role remains renderable but produces a quality-check warning.
@@ -169,6 +169,29 @@ One offending character invalidates the file and aborts export. Numeric refs (`&
## 4. Basic SVG Rules
### 4.0 Complete Page-Design Contract
| Concern | Requirement |
|---|---|
| Visible slide result | The completed `svg_output/<slide>.svg` MUST contain every visible text, image, shape, diagram, chart/table fallback, background, and template-derived layout element intended for that slide. External visual assets are valid when the SVG references them explicitly. |
| Template/control inputs | Templates, `design_spec.md`, and `spec_lock.md` guide authoring. Do not depend on them to add visible elements after the page SVG is complete. |
| PPTX translation | The exporter may map represented SVG content to DrawingML/native objects and deduplicate represented elements into Master/Layout/Slide parts. It MUST NOT invent visible slide content absent from the SVG. |
| Excluded package behavior | Speaker notes, animations, transitions, narration audio, PPTX relationships, and direct native-PPTX workflows remain separately owned. They are not part of the SVG page-design contract. |
**Hard rule — page-design closure**: A final page SVG is the sole visual/design authority for that page on every SVG-authoring route. SVG is not the authority for the entire PPTX package.
### 4.1 Semantic SVG Marker Contract
Semantic markers are minimal compiler hints orthogonal to native SVG semantics.
Existing `data-pptx-layout` / layer / placeholder / native-object metadata is
authoritative and read first. A `baseline` / free-design root declares
`data-pptx-page-role`; template/preserve roots already declare their Layout.
Add `data-pptx-role` only when no specialized marker expresses the required
page-frame behavior; the element also uses a stable unique `id`. Do not classify
ordinary page content or move visible facts out of SVG attributes/text into
metadata. See
[`semantic-svg.md`](semantic-svg.md) for the canonical vocabulary and examples.
- **viewBox** MUST match the canvas dimensions; it is the single source of truth for canvas size. Root `width`/`height` are optional compatibility attributes and are not used as PPT Master canvas authority.
- **Background**: Use `<rect>` to define the page background color
- **`<tspan>`** has two purposes: (1) manual line breaks (use `dy` or explicit `y`); (2) inline run formatting on the same line (color/weight/size). `<foreignObject>` is FORBIDDEN. See "Single logical line" rule below.
@@ -253,7 +276,9 @@ Wrap logically related elements in top-level `<g id="...">` groups. Produces Pow
**Animation-ready rule**: direct children of `<svg>` should be semantic groups, not raw drawing atoms. Aim for **38 top-level content `<g id>` groups per slide** (the 38 budget excludes page chrome — see below); each content group becomes one entrance step under the chosen `--animation-trigger` mode (one click in `on-click`, one cascade slot in `after-previous`, parallel in `with-previous`).
**Chrome groups are excluded automatically.** The exporter treats top-level groups whose id contains chrome tokens as page chrome and skips them in the animation sequence — they appear together with the slide. Tokens (matched against id after splitting on `-` / `_`): `background`, `bg`, `decoration` / `decorations` / `decor`, `header`, `footer`, `chrome`, `watermark`, `pagenumber` / `pagenum` / `page-number`, `nav`, `logo`, `rule`. So `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">` all skip animation while keeping their `<g>` wrapper for editing/grouping. Use these naming conventions for chrome — do **not** strip the `<g>` wrapper.
**Chrome groups are excluded automatically.** Existing `data-pptx-layer` and `data-pptx-placeholder="slide-number"` semantics are read first. Otherwise, explicit `data-pptx-role` values `background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, and `logo` identify static page framing. For marker-free legacy SVGs, ids remain a compatibility fallback using these tokens after splitting on `-` / `_`: `background`, `bg`, `decoration` / `decorations` / `decor`, `header`, `footer`, `chrome`, `watermark`, `pagenumber` / `pagenum` / `page-number`, `nav`, `logo`, `rule`. Keep the `<g>` wrapper for editing/grouping.
Minimal structural roles also drive slide-master promotion in the default native export: `logo` / `footer` / `header` / `watermark` / `chrome` elements drawn **before** content and repeated **verbatim** across pages may move into the slide master (pages without them, like covers, are isolated automatically). A `data-pptx-placeholder="slide-number"` text, or a free-design `data-pptx-role="page-number"` text, whose content exactly equals the page's display number becomes a self-renumbering PowerPoint field. Exact id tokens remain a fallback only when specialized/minimal markers are absent.
**What to group**:
@@ -337,7 +362,7 @@ Full reference: [`animations.md`](animations.md).
**Prohibited**:
- NEVER use `cp` as a substitute for `finalize_svg.py`
- NEVER force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). Default auto-split already gives native the high-fidelity source it needs without affecting legacy.
- NEVER use `--only` (it suppresses one of the two output files)
- NEVER use `--only` in the standard pipeline; keep it for explicit one-product diagnostics or compatibility checks
> Source-directory split: by default `svg_to_pptx.py` reads `svg_output/` for the native pptx (preserves icon `<use>`, image `preserveAspectRatio` as native picture-crop metadata, rounded rect `rx/ry``prstGeom roundRect`) and `svg_final/` for the legacy/preview pptx (PowerPoint's internal SVG parser needs the flattened form). Pass `-s output` or `-s final` only when you specifically want both products to read from a single source.
@@ -690,7 +715,7 @@ The child `<path>`'s `stroke` becomes the foreground color (the pattern's line c
> `svg_quality_checker.py` warns on missing `data-pptx-pattern` and errors on values outside the enum. Catch these pre-export — PowerPoint's repair dialog hides which pattern broke.
### Native PPTX Table / Chart Markers (Experimental)
### Native PPTX Table / Chart Markers (Opt-in)
Native PowerPoint tables and Excel-backed charts activate at export time only. The default chart/table route remains hand-authored SVG geometry so the deck stays pixel-stable across PowerPoint / Keynote / LibreOffice / WPS.
@@ -709,7 +734,12 @@ Native PowerPoint tables and Excel-backed charts activate at export time only. T
`data-pptx-x` / `data-pptx-y` / `data-pptx-width` / `data-pptx-height` on the
marker group. If any bound is omitted, the exporter infers the object frame
from the visible fallback geometry; this keeps SVG fallback and native object
placement aligned.
placement aligned. Complete explicit bounds are absolute slide coordinates;
marker/ancestor `translate` and `scale` transforms apply only when at least one
bound is inferred. `x`, `y`, `width`, and `height` must be finite and resolve
inside PowerPoint's 32-bit DrawingML coordinate range; `width` and `height`
must resolve to at least one EMU. Native table frames must additionally resolve
to at least one EMU per resolved row and column.
**Validation**: `svg_quality_checker.py` validates native marker kind, JSON
metadata, bounds/fallback availability, table rows/columns, supported chart
@@ -735,8 +765,13 @@ type, and chart data shape before export.
**Table schema**: Native tables are rectangular DrawingML grids. Use `columns`
for the optional header row and `rows` for body rows; shorter rows are padded
with blank cells unless `strict_grid: true` is set. Use `column_widths` and
`row_heights` as relative weights. Cell objects accept `text`, `fill`, `color`,
with blank cells unless `strict_grid: true` is set. Tables may contain at most
1000 resolved rows and 1000 resolved columns. Use `column_widths` and
`row_heights` as relative weights. Weight lists must match the resolved grid,
contain finite non-negative numbers, and include at least one positive value.
If present, `header_rows` must be an integer from `0` through the resolved row
count. Write `strict_grid`, `style.band_row`, and cell `bold` as JSON booleans.
Cell objects accept `text`, `fill`, `color`,
`align`, `valign`, `bold`, `font_size`, `padding`, `border_color`, and
`border_width`; the same `padding`, `border_color`, and `border_width` keys may
also live under `style` as table defaults. Native table typography mirrors the
@@ -913,6 +948,234 @@ steal plot area in PPT. Add `show_legend: true` only when the legend is needed;
**Forbidden — native marker transforms**: Do not rotate, skew, or matrix-transform native table/chart marker groups. Translate / scale is accepted; complex transforms fail export because PowerPoint native table/chart frames do not preserve arbitrary SVG transforms.
### Baseline Layout Family Extraction
Native `baseline` export assigns layout families after every SVG page has been
converted. This package-only pass does not change SVG authoring or live preview.
| Root `data-pptx-page-role` | Output layout |
|---|---|
| `cover` | `Cover` |
| `toc` | `Agenda` |
| `section` | `Section` |
| `ending` | `Closing` |
| `content` | `Content` |
Marker-free legacy SVGs retain conservative filename-token fallback: explicit
cover / agenda / section / closing tokens select those families, and every
other page becomes `Content`. When a valid root marker exists, it is
authoritative even if the filename suggests another family.
Keep an existing `Cover` assignment when the Master chrome safety pass already
used it to hide promoted Master shapes from a minority page.
**Hard rule — no visual inference**: Keep every actual title, body, picture,
chart, table, and page-specific shape on the Slide. Baseline layouts do not
infer placeholders or promote visually similar content.
**Background rule**: Move a Slide `p:bg` to its family Layout only when every
slide in that family carries exactly the same explicit background. Otherwise,
keep each background on its Slide. Preserve whether each family shows or hides
the parent Master shape tree.
**Layout chrome rule**: After family assignment, move only the identical
leading prefix of explicitly marked chrome (`logo`, `footer`, `header`,
`watermark`, `chrome`) carried by every family member. Legacy id tokens are
consulted only when `data-pptx-role` is absent. Generated OOXML and
image relationships must match exactly, no animation may target the shapes,
and moving them behind Slide content must preserve z-order. Keep page numbers
and every non-identical object Slide-local.
### Theme Font Inheritance (Baseline and Template Export)
New projects derive PowerPoint theme fonts from `spec_lock.md` typography when
native export uses `baseline` or `template` structure mode:
- `title_family` (falling back to `font_family`) becomes the theme major font.
- `body_family` (falling back to `font_family`) becomes the theme minor font.
- SVG text whose resolved exported face matches either locked family emits
DrawingML `+mj-*` / `+mn-*` tokens instead of a fixed typeface.
- When major and minor resolve to the same face, ordinary slide-local text uses
the minor role; semantic `title` placeholders are forced to major, while all
other semantic placeholders are forced to minor.
- Local emphasis, code, brand, or other families that do not match the locked
title/body faces remain concrete per-run fonts and do not change with the
theme.
This substitution is package-only: the SVG stays the live-preview truth, and
the installed theme resolves the tokens to the same concrete fonts on initial
open. Changing the PowerPoint theme later can therefore update matching text
without changing the SVG geometry or the first export's visual design.
`preserve` mode never rewrites source theme fonts, and `flat` remains the
diagnostic slide-local/fixed-font comparison path. A project without a usable
`spec_lock.md` typography section keeps the legacy concrete-font behavior.
### Theme Color Inheritance (Baseline and Template Export)
Native `baseline` and `template` export also derive the PowerPoint color scheme
from `spec_lock.md` colors. The canonical mapping is:
| Lock role | PowerPoint scheme slot |
|---|---|
| `bg` / `background` / `master_bg` | `lt1` |
| `secondary_bg` / `bg_secondary` | `lt2` |
| `text` / `body_text` | `dk1` |
| `text_secondary` | `dk2` |
| `primary` | `accent1` |
| `accent` | `accent2` |
| `secondary_accent` | `accent3` |
| `border` | `accent4` |
| First two additional non-black/non-white roles | `accent5` / `accent6` |
The converter promotes an exact locked HEX to `a:schemeClr` only when its
usage is compatible with the role: backgrounds prefer background roles, text
prefers text/accent roles, strokes prefer `border`, and chart series use only
the primary/accent family. This prevents the same literal HEX from coupling
unrelated semantics such as a white page background and fixed white inverse
text. Gradients, patterns, bullets, native tables, and exact native-chart
series colors follow the same rule. Shadows, effects, unmatched local colors,
and additional palette roles remain concrete `a:srgbClr` values.
The initial rendering is unchanged because every scheme slot resolves to the
same locked HEX. A later PowerPoint theme edit can update only the promoted
roles; it does not rewrite the SVG or local exceptions. `preserve` never
rewrites the imported source color scheme, `flat` keeps concrete colors for
diagnostics, and projects without a usable colors section retain legacy fixed
colors.
### Explicit PPTX Master / Layout / Placeholder Metadata (Template Export)
**Trigger**: Deck/layout template routes set `spec_lock.md`
`pptx_structure.mode` to `template`; direct diagnostics may pass
`--pptx-structure template`. Both strict and adaptive template adherence use
this mode. Without either trigger, metadata stays visually dormant.
**Project lock**: In the standard project pipeline, template mode requires one
`pptx_layouts` row per page using
`P<NN>: <layout_key> | <PowerPoint layout name>`. The SVG root values MUST
match that row. Strict uses the selected template key/name. Adaptive may create
a new key/name while repeating the same Master contract. Reuse one layout key
only when pages share the same static Layout layer and placeholder contract;
different content is not a reason to create a new layout. Direct diagnostic
exports may pass the CLI flag without a spec lock.
| Metadata | Placement | Behavior |
|---|---|---|
| `data-pptx-layout="content"` | root `<svg>` | Binds the slide to one generated reusable layout key |
| `data-pptx-layout-name="Title and Content"` | root `<svg>` | Sets the PowerPoint layout-picker name; defaults from the layout key |
| `data-pptx-layer="master"` | direct visual child | Moves one repeated static object/background into the slide master |
| `data-pptx-layer="layout"` | direct visual child | Moves one repeated static object/background into the selected layout |
| `data-pptx-layer="slide"` | direct full-canvas solid `<rect>` only | Writes a one-page override as Slide `p:bg` |
| `data-pptx-placeholder="..."` | direct visual child | Keeps actual content on the slide and maps it to a generated layout placeholder |
| `data-pptx-placeholder-bounds="x y width height"` | placeholder element | Overrides the reusable placeholder frame in SVG user units |
| `data-pptx-placeholder-idx="1"` | placeholder element | Retains an imported source layout placeholder index; optional for reconstructed layouts |
| `data-pptx-editable="false"` | master/layout element or slide background | Declares intentional editing outside ordinary slide content |
**Hard rule — explicit only**: Template export never promotes visually similar
content by inference. Every SVG requires a root `data-pptx-layout`; every
master/layout/placeholder element requires a unique `id` and must be a direct
child of the root SVG.
**Layer order**: Author the SVG in PowerPoint paint order: Master background,
Layout background, optional Slide background, Master shapes, Layout shapes,
then slide-local content/placeholders. Backgrounds are a special inheritance
plane beneath every shape; this order keeps standalone SVG preview and
PowerPoint rendering aligned. The exporter rejects interleaved layers.
**Solid background ownership**: A direct full-canvas solid `<rect>` becomes a
real `p:bg`, not a selectable shape. Mark it `data-pptx-layer="master"` for the
deck-wide default, `data-pptx-layer="layout"` for a page-type override, or
`data-pptx-layer="slide"` for a one-slide override. An unmarked direct
full-canvas solid rect in the background plane is also treated as Slide scope. A
Layout background overrides the Master background; a Slide background
overrides both. Use the Master for a globally stable color and the Layout for
cover/section/content variants under the same design language. Gradients,
images, textures, transformed rects, and visible-stroke rects are not promoted
by this solid-background rule.
| Placeholder value | SVG element | PowerPoint placeholder |
|---|---|---|
| `title`, `subtitle`, `body` | direct `<text>` | `title`, `subTitle`, `body` |
| `date`, `footer`, `slide-number` | direct `<text>` | `dt`, `ftr`, `sldNum` |
| `picture` | direct `<image>` or imported crop `<svg>` | `pic` |
| `chart`, `table` | direct matching `data-pptx-native` marker group | `chart`, `tbl` |
| `object` | one direct text, image, or basic SVG shape | `obj` |
| `media` | direct `<image>` or imported crop `<svg>` | `media` |
`title` is normally type-matched without an index in reconstructed layouts; if
an imported source title explicitly has one, preserve that exact index. Every
indexed placeholder on one layout uses a unique non-negative index. Template
export writes the semantic type on both the Layout and Slide placeholder
(except `obj`, whose OOXML default is already
`obj`) so PowerPoint and `python-pptx` retain the same identity. A `date`
placeholder also enables the layout date flag and gets a
`datetimeFigureOut` field in the reusable Layout definition; the current
Slide keeps its authored date content.
**Placeholder prototype**: The first slide using a layout key supplies that
layout's placeholder formatting. `data-pptx-placeholder-bounds` supplies the
reusable frame; when omitted, the exporter uses the prototype object's native
DrawingML bounds. Repeat the same placeholder ids/types on every slide using
that layout. Actual slide content and local geometry may differ.
**Static structure consistency**: Repeat the same master element ids on every
slide and the same layout element ids on every slide sharing a layout. Their
generated OOXML must be identical within the affected master/layout group.
Static structure may carry shapes, text, or images; non-image/external
relationships are rejected. A full-canvas first rect/group may be marked as a
master or layout background.
**Native object placeholders**: `chart` / `table` placeholders require
`--native-objects`; fallback groups contain several shapes and cannot map to one
PowerPoint placeholder. `object` is the generic PowerPoint content slot and
must still resolve to one top-level DrawingML object. `media` currently binds
an authored image/crop to a native `media` placeholder; it does not synthesize
video or audio media from a decorative SVG group.
### Legacy Preserved Source Master / Layout Contract
**Trigger**: An existing project already ships `native_structure.json` and `source_template.pptx`, has strict template adherence, and sets `pptx_structure.mode: preserve`. Current `create-template` output does not emit this pair; retain this contract only for backward compatibility.
| Artifact | Authority |
|---|---|
| `source_template.pptx` | Original master/layout/theme/package parts |
| `native_structure.json` | Stable layout keys, picker names, parent masters, placeholder types/indices, source SHA-256 |
| `pptx_layouts` | Per-generated-page source layout selection |
| SVG metadata | Standalone preview layers and slide-content placeholder binding |
**Hard rule — source package wins**: Mark source master/layout visuals as direct `data-pptx-layer="master|layout"` preview children. Preserve export removes those generated copies and renders the original source parts. Unmarked content stays slide-local.
**Placeholder identity**: Keep actual content on the slide. Copy the source placeholder index into `data-pptx-placeholder-idx` when present; the exporter restores the source placeholder type/idx pair. Imported `subTitle`, `obj`, `media`, and `dt` placeholders retain distinct `subtitle`, `object`, `media`, and `date` semantic roles instead of collapsing into body/other. Multiple placeholders with the same semantic role require explicit indices.
**Multi-master boundary**: Preserve every source master already present in the package. Do not synthesize a new master merely for cover/section differences; rebuilt templates continue to prefer one master plus semantic layouts.
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720"
data-pptx-layout="content"
data-pptx-layout-name="Title and Content">
<rect id="master-bg" data-pptx-layer="master"
data-pptx-editable="false"
width="1280" height="720" fill="#F8FAFC"/>
<rect id="content-bg" data-pptx-layer="layout"
data-pptx-editable="false"
width="1280" height="720" fill="#FFFFFF"/>
<g id="content-rule" data-pptx-layer="layout"
data-pptx-editable="false">
<line x1="48" y1="96" x2="1232" y2="96"
stroke="#CBD5E1" stroke-width="2"/>
</g>
<text id="page-title" data-pptx-placeholder="title"
data-pptx-placeholder-bounds="80 112 1120 72"
x="80" y="158" font-size="40">Actual page title</text>
<image id="hero-image" data-pptx-placeholder="picture"
data-pptx-placeholder-bounds="680 210 480 320"
x="680" y="210" width="480" height="320"
href="../images/hero.png"/>
</svg>
```
### transform: rotate — Element Rotation
Rotation converts to native PPTX `<a:xfrm rot="...">`. Supported on all element types: `rect`, `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline`, `image`, and `text`.
@@ -28,7 +28,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
| Stage | Items | Role |
|---|---|---|
| **1 — direction anchors** | `a` canvas · `c` key info — audience + `content_divergence` + **delivery purpose** *(PPT only)* (promoted out of `g`) · `d` mode + visual_style | confirmed first |
| **1 — direction anchors** | `a` canvas · `c` key info — audience + `content_divergence` + **delivery purpose** *(PPT only)* (promoted out of `g`) · `d` mode + visual_style · template adherence *(only when Step 3 loaded a deck/layout template)* | confirmed first |
| **2 — design system** (re-derived from the user's *actual* Stage 1) | `b` page count · `e` color · `f` icon · `g` typography (font + size, formula policy) | derived from Stage 1 |
| **3 — images / execution** (re-derived from the user's *actual* Stage 1 + Stage 2) | `h` image source + generated-image style · generation mode · refine-spec toggle | derived from confirmed direction + design system |
@@ -100,6 +100,19 @@ Write the locked value to `spec_lock.md` `- visual_style:` and the rationale to
> **Template vs preset**: a style mention may sound like a template name ("academic style" vs the `academic_defense/` template directory). Step 3 only triggers on an explicit template directory path supplied by the user — bare names and style words never copy templates; they map to a visual-style preset here. If a template was triggered upstream, its files are already in `<project_path>/templates/` and its fused design_spec governs.
**Legacy native contract**: When `<project_path>/templates/native_structure.json` exists, treat it as backward-compatible `preserve` input only. Current templates declare their PowerPoint structure directly in the SVG roster.
**Template adherence confirmation**: Surface this closed Stage-1 choice only when Step 3 loaded a `kind: deck` or `kind: layout` template. Omit it for free design and brand-only templates.
| Value | Planning and export behavior |
|---|---|
| `strict` | Map every page to one template SVG. Keep its explicit Master/Layout/placeholder contract and output layout key unchanged. |
| `adaptive` | Map every page to the closest template SVG. Keep the template Master contract, but allow a new explicit Layout key when the selected composition must adapt to the content. |
**Default — adaptive (may override when the user requests exact template use)**: Recommend `adaptive` when the user supplied only a template path with no stricter instruction. Preselect `strict` when the user explicitly asks to follow or preserve the template closely; the Stage-1 confirmation still records the final choice. Record the confirmed value in `design_spec.md §I` and as `template_adherence` under `spec_lock.md pptx_structure`.
> Note: `content_divergence` controls how source material is reorganized; `template_adherence` controls how template visuals/layouts are used. Never infer one from the other.
**Downstream effect**: e / f / g / h realize the locked mode + visual style. Example: `showcase` + `dark-tech` → e applies one luminous accent on a dark field; g pairs a clean sans with mono; f minimal glow icons; h the `digital-dashboard` rendering.
### e. Color Scheme Recommendation
@@ -839,9 +852,11 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
- **page_layouts (write only when a template is in use)**: For each page that inherits a template SVG, add `P<NN>: <svg_basename>` (e.g., `P04: 03a_content_image_text`). Pages designed freely get **no entry** — Executor reads the absence as "free design, no inheritance". If zero pages use a template, omit the section entirely.
- **pptx_structure is mandatory**: Write `mode: baseline` for free design and brand-only routes. When a deck/layout template is confirmed, write `mode: template` plus `template_adherence: strict|adaptive`. Both adherence values stay on explicit template export. Use `mode: preserve` only when reopening a legacy strict project that already carries a validated `native_structure.json` + `source_template.pptx` pair. Do not choose `flat`; it is a CLI-only diagnostic override.
- **pptx_layouts (template/preserve structure)**: In template mode, write one `P<NN>: <layout_key> | <PowerPoint layout name>` row for every page. Strict uses the selected template SVG's exact key/name. Adaptive may introduce a new stable key/name, but pages reuse a key only when their static Layout layer and placeholder contract match. In legacy preserve mode, use the exact source key/name. Omit the whole section in baseline mode.
- **page_layouts (deck/layout templates only)**: Add one `P<NN>: <svg_basename>` row for every page. Strict inherits that SVG contract unchanged. Adaptive uses it as the architecture reference and may evolve the Layout layer under a new `pptx_layouts` key; it must not drop into free design. Omit the section only for free design and brand-only routes.
- **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/<name>.svg`, add `P<NN>: <chart_name>`. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section.
- **Hard rule**: Use both `page_layouts` and `page_charts` for the same page only when the layout template is a compatible shell for the chart. Do not pair chart pages with conflicting page layouts (e.g., `waterfall_chart` + timeline layout, KPI cards + circle-diagram layout). If no compatible layout exists, omit the page from `page_layouts`.
- **Hard rule**: Use both `page_layouts` and `page_charts` only with a compatible template shell. For a chart page without an exact roster match, adaptive mode selects the closest neutral content template and assigns a new explicit Layout key; strict mode must select an existing compatible Layout or revise the outline. Never omit `page_layouts` inside a deck/layout template route.
---
@@ -18,6 +18,8 @@ When the workflow provides a PPTX reference source, the effective input package
- finalized template brief
- `manifest.json` — single source of truth (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates)
- `native_structure.json` — stable source master/layout keys, picker names, parent-master relationships, placeholder type/index/geometry, source hash, and source-graph quality facts
- `source_template.pptx` — byte-preserved analysis copy for visual/package cross-checking; never a final template asset
- `summary.md` — short orientation digest derived from manifest.json
- exported `assets/`
- `svg/master_*.svg` / `svg/layout_*.svg` — every master / layout in the deck rendered once as standalone SVG, including ones no sample slide references (template packages often ship more design surfaces than the embedded samples exercise)
@@ -35,12 +37,17 @@ PPTX import interpretation:
Input priority for PPTX-backed template creation:
1. `manifest.json` for all factual metadata (theme, assets, unique layout/master structure, slide reuse, page-type guidance)
2. `svg/master_*.svg` + `svg/layout_*.svg` — the **primary source for the deck's shared visual language**: backgrounds, page chrome, decorative bars, recurring brand motifs. These are what the new template's fixed structure should adopt or reinterpret. Read these before any slide SVG.
3. `svg/inheritance.json` for confirming which slide uses which layout / master
4. exported `assets/` for reusable visual resources
5. `svg/slide_NN.svg` — each slide's unique content, useful for judging composition rhythm and content density (not for fixed structure)
6. `summary.md` only as a fast scan; never as the canonical fact source
7. screenshots / original PPTX only for style verification
2. `native_structure.json` for source PowerPoint identities, placeholder indices, relationship completeness, and reconstruction facts
3. `svg/master_*.svg` + `svg/layout_*.svg` — the **primary source for the deck's shared visual language**: backgrounds, page chrome, decorative bars, recurring brand motifs. These are what the new template's fixed structure should adopt or reinterpret. Read these before any slide SVG.
4. `svg/inheritance.json` for confirming which slide uses which layout / master
5. exported `assets/` for reusable visual resources
6. `svg/slide_NN.svg` — each slide's unique content, useful for judging composition rhythm and content density (not for fixed structure)
7. `summary.md` only as a fast scan; never as the canonical fact source
8. screenshots / original PPTX only for style verification
**Native structure output**: Always set `native_structure_mode: template`. Do not ship `native_structure.json` or `source_template.pptx`. Reconstruct one clean Master plus semantic Layouts through explicit SVG structure metadata. Every page remains a complete standalone SVG preview.
**Downstream boundary**: The Strategist confirmation stage selects `strict` or `adaptive` when a project consumes this package. Both export through explicit template mode. Strict keeps the referenced Layout contract; adaptive may create a new Layout while retaining the template Master. Template_Designer does not preselect that project-level choice.
---
@@ -52,7 +59,7 @@ The output page set is determined by **replication mode**, declared in the final
|------|-------------|--------|
| `standard` (default) | Most templates — clean, reusable, balanced coverage | `01_cover`, `02_chapter`, `03_content`, `04_ending`, optional `02_toc` |
| `fidelity` | User explicitly wants strict replication of a source PPTX, but still wants the AI to clean / cluster / cap variants | Standard roster + one variant per distinct layout cluster found in `manifest.json` |
| `mirror` | Creation-time harvesting mode: every source slide is preserved 1:1 as a reference page for the template package. Verbatim copy, no abstraction, no placeholders | One SVG per source slide, named `<NNN>_<page_type>.svg` by source order |
| `mirror` | Creation-time harvesting mode: every source slide keeps literal visual geometry while gaining explicit Master/Layout metadata | One SVG per source slide, named `<NNN>_<page_type>.svg` by source order |
### Standard mode
@@ -93,13 +100,14 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
### Mirror mode
When the brief sets `Replication mode: mirror`, the role does **not** abstract or reconstruct. Each source slide becomes one template page **byte-for-byte**:
When the brief sets `Replication mode: mirror`, preserve literal page appearance while reconstructing layer ownership:
- Source: `<import_workspace>/svg-flat/slide_NN.svg` (the self-contained "what PowerPoint shows" view). Do **not** read or use `svg/master_*.svg`, `svg/layout_*.svg`, or `svg/inheritance.json` — chrome / content separation is irrelevant because mirror does not insert placeholders.
- 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.
- Modifications allowed: **only** rewriting `<image href="...">` paths to point at the local `assets/` copy, and renaming asset files to semantic names. Everything else — geometry, decoration, sprite-sheet wrappers, original example text, chart placeholders, embedded fonts — is copied as-is.
- Modifications forbidden: inserting `{{TITLE}}` / `{{CONTENT_AREA}}` / any other placeholder; "cleaning up" decorative complexity; merging similar slides; dropping master/layout chrome (it's already baked into the flat SVG, leave it).
- `design_spec.md` §V Page Roster lists every emitted file with **a one-line description** of what the page contains and what content slot it suits — Strategist selects pages from these descriptions, since the SVG itself carries no placeholder contract.
- Required metadata rewrite: declare the output Layout on the root, mark inherited Master/Layout visuals as direct preview layers, and map source content slots to semantic `data-pptx-placeholder` markers where the imported contract exposes them. Add `data-pptx-role` only to structural page-frame objects whose behavior is not already expressed by those specialized markers.
- Other allowed modifications: rewrite `<image href="...">` paths to local assets and rename assets semantically. Keep geometry, decoration, sprite-sheet wrappers, original example text, chart previews, and fonts visually unchanged.
- Forbidden: simplifying decorative complexity, merging similar slides, or dropping inherited preview chrome.
- `design_spec.md` §V Page Roster lists every emitted file with a one-line content-fit description; SVG metadata owns native reconstruction.
**Mirror consumption boundary**: `mirror` applies only while creating the template package from the source deck. Once created, the package is consumed as an ordinary deck / layout template roster: downstream generation may select, repeat, skip, reorder, or adapt pages according to the new content. The `replication_mode: mirror` field must not force the generated deck to preserve the source page count, source order, or one-output-slide-per-template-slide mapping.
@@ -138,8 +146,10 @@ source_canvas_width: 1280
source_canvas_height: 720
source_viewbox: "0 0 1280 720"
replication_mode: standard | fidelity | mirror
# Required for every deck/layout template. Source packages remain analysis-only.
native_structure_mode: template
# Optional — only when this template overrides canonical placeholder vocabulary.
# Omit entirely for `mirror` mode (mirror has no placeholders).
# Omit only when the page truly exposes no replaceable content slots.
# placeholders:
# 01_cover: ["{{TITLE}}", "{{SUBTITLE}}", "{{BRAND_LOGO}}"]
# 03_content: ["{{KEY_MESSAGE}}", "{{CONTENT_AREA}}"]
@@ -166,7 +176,7 @@ replication_mode: standard | fidelity | mirror
- Optional XML snippet for any reusable component unique to this template
## V. Page Roster
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). For `fidelity` mode, also note the cluster source and visual differentiator. For `mirror` mode the roster is the **load-bearing artifact** — Strategist picks pages from these descriptions, so each row must include enough detail to distinguish it from siblings (column count, hero element, content density, what kind of content slot it fits — "three-column KPI grid with large numbers, suits quarterly summary"). Roster entries must match the actual SVG files on disk.
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). Record the rebuilt Layout key and PowerPoint picker name. For `fidelity` mode, note the cluster source and visual differentiator. For `mirror` mode the roster is the load-bearing content-fit index, so each row must distinguish siblings by column count, hero element, density, and suitable content. Roster entries must match the actual SVG files on disk.
## VI. Assets (omit when none)
Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage.
@@ -229,21 +239,43 @@ Do not:
- mirror PPT-specific complexity when it makes the resulting SVG brittle or hard to edit
- introduce dense low-value vector detail that does not materially improve template reuse
**Explicit template SVG contract**:
| Rebuilt fact | Template SVG declaration |
|---|---|
| Output semantic layout | Root `data-pptx-layout` + stable `data-pptx-layout-name` |
| Rebuilt master/layout visual | Direct preview child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"` |
| Semantic content slot | Direct content child with `data-pptx-placeholder`; add `data-pptx-placeholder-idx` when same-role slots require stable disambiguation |
| Page-only background | Direct full-canvas solid rect with `data-pptx-layer="slide"` |
| Structural page-frame hint | Optional `data-pptx-role` only when background/decoration/header/footer/logo/watermark/chrome/page-number behavior is not already expressed by layer/placeholder metadata; stable unique `id` required |
Repeat inherited visuals in every standalone SVG so browser preview remains complete. Template export validates their equality, moves one copy into the generated Master/Layout parts, and removes the repeated Slide copies. Do not flatten inherited visuals into unmarked slide content.
Use the imported placeholder types verbatim: `title`, `subtitle`, `body`,
`picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, and
`slide-number`. In particular, do not collapse source `subTitle`, `obj`,
`media`, or `dt` placeholders into a generic body marker. A reconstructed
title normally has no index. Assign stable indices only when repeated roles need
disambiguation inside the rebuilt Layout.
### 3. Placeholder Markers
> **Mirror mode skips this section entirely.** Mirror SVGs are verbatim copies of the source flat slides — they carry no `{{}}` markers. Mirror is only a template-creation mode; downstream generation treats the finished files as normal template roster candidates. The rest of this section applies to `standard` and `fidelity` only.
> Mirror may retain literal example text instead of `{{...}}` authoring markers, but imported semantic content slots still receive native `data-pptx-placeholder` metadata. The rest of this section defines the preferred authoring vocabulary for standard and fidelity modes.
Use clear placeholder markers for replaceable content:
```xml
<!-- Text placeholder -->
<text x="80" y="320" fill="#FFFFFF" font-size="48" font-weight="bold">
<text id="title-slot" data-pptx-placeholder="title"
x="80" y="320" fill="#FFFFFF" font-size="48" font-weight="bold">
{{TITLE}}
</text>
<!-- Content area placeholder (content page only) -->
<rect x="40" y="90" width="1200" height="550" fill="#FFFFFF" rx="8"/>
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">
<text id="body-slot" data-pptx-placeholder="body"
data-pptx-placeholder-bounds="40 90 1200 550"
x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">
{{CONTENT_AREA}}
</text>
```
@@ -347,7 +379,7 @@ templates/<kind_dir>/<template_name>/
└── *.png / *.jpg
```
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. No `{{}}` placeholders appear in any mirror SVG.
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. Literal source text may remain, but every page still carries explicit native structure metadata.
### Template Preview
@@ -357,6 +389,7 @@ If the template is based on PPTX import output, briefly note:
- which extracted assets were reused directly
- which complex original decorations were intentionally simplified
- whether any page-type mapping required judgment beyond the import heuristic
- how the source Master/Layout graph was consolidated into the rebuilt Master and semantic Layout roster
---
@@ -364,7 +397,7 @@ If the template is based on PPTX import output, briefly note:
If suitable template resources already exist, use them directly instead of generating new ones:
1. **Copy template**: copy the spec + template SVGs into the project's `templates/`, and any bundled bitmaps into the project's `images/` (the runtime image pool, referenced as `../images/`)
1. **Copy template**: copy the spec + explicitly layered template SVGs into the project's `templates/`, and any bundled bitmaps into the project's `images/` (the runtime image pool, referenced as `../images/`).
2. **Adjust colors**: Modify colors per the project design spec
3. **Customize**: Make project-specific adjustments
@@ -397,6 +430,7 @@ templates/
- [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] 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 (standard / fidelity); mirror SVGs contain **no** `{{}}` markers
- [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>`
```
@@ -43,7 +43,7 @@ python3 scripts/update_repo.py
| Area | Primary scripts | Documentation |
|------|-----------------|---------------|
| 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` | [docs/conversion.md](./docs/conversion.md) |
| 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) |
| Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) |
@@ -62,6 +62,7 @@ python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
python3 scripts/source_to_md/doc_to_md.py <file.docx>
python3 scripts/source_to_md/excel_to_md.py <workbook.xlsx>
python3 scripts/source_to_md/web_to_md.py <url>
python3 scripts/pptx_to_svg.py <deck.pptx> -o <output_dir> # reconstruction/reference SVG import
```
Project setup:
@@ -115,6 +116,22 @@ 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.
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.
Deck/layout template routes use explicit template structure. Each complete SVG names its output Layout, repeats inherited Master/Layout preview layers, and marks supported content placeholders. Strict keeps the selected Layout contract; adaptive may create a new Layout while retaining the Master. The exporter validates cross-slide equality, creates one reusable Layout per key, removes repeated inherited copies, and keeps actual content Slide-local. The complete contract lives in [`references/shared-standards.md`](../references/shared-standards.md#explicit-pptx-master--layout--placeholder-metadata-template-export).
Current `create-template` output always rebuilds explicit SVG structure and does not package `native_structure.json` or `source_template.pptx`. `preserve` remains available only for existing projects that already carry the legacy pair.
`pptx_to_svg.py` annotates supported unmerged tables and conservative classic-chart caches with `data-pptx-native` metadata. Source table-style inheritance, supported solid cell fills/basic text formatting, chart title/legend/axis titles, and plot-level data-label flags for area/bar/column/line charts are retained when the current schema can represent them. Tables with direct borders, non-solid fills, or mixed rich-text formatting remain fallback-only, as do charts with unsupported label scopes/types, custom axis semantics, trendlines/error bars, or subtype options. Unsupported tables keep their rendered SVG table; unsupported charts keep a baked preview or explicit placeholder. Both carry `data-pptx-native-status`, which `svg_quality_checker.py` and `svg_to_pptx.py --native-objects` report as a warning.
Exporter-canonical classic charts also recover canonical solid series/slice
colors and exact one- or two-paragraph title styling; two paragraphs retain
their `title` / `subtitle` roles. Slide-number fields resolve to the display
number defined by `firstSlideNum`; standalone master/layout SVGs retain their
literal field fallback because they are shared by multiple slides.
Image generation:
```bash
@@ -137,7 +154,7 @@ 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`
- Prefer `svg_final/` over `svg_output/` when exporting
- Use `svg_output/` for native export and `svg_final/` for SVG snapshot/preview export
## Related Docs
@@ -148,4 +165,4 @@ python3 scripts/update_repo.py --skip-pip
- [Troubleshooting](./docs/troubleshooting.md)
- [Skill Entry](../SKILL.md)
_Last updated: 2026-04-09_
_Last updated: 2026-07-10_
@@ -460,11 +460,36 @@ def _sync_session_state(
# browser STATE — lost on a refresh. Folding them from result.json into the
# served recommendations lets a refresh / reopen re-initialize from the user's
# actual choices instead of catalog defaults.
_ANCHOR_RECOMMEND_KEYS = ('canvas', 'mode', 'visual_style', 'delivery_purpose')
_ANCHOR_RECOMMEND_KEYS = (
'canvas',
'mode',
'visual_style',
'delivery_purpose',
'template_adherence',
)
_ANCHOR_VALUE_KEYS = ('audience', 'content_divergence')
_DESIGN_RECOMMEND_KEYS = ('icons', 'formula_policy')
def _template_adherence_enabled(project_path: Path) -> bool:
"""Return whether Step 3 loaded a deck/layout template into the project."""
spec_path = project_path / 'templates' / 'design_spec.md'
try:
lines = spec_path.read_text(encoding='utf-8').splitlines()
except OSError:
return False
if not lines or lines[0].strip() != '---':
return False
for line in lines[1:]:
stripped = line.strip()
if stripped == '---':
return False
match = re.fullmatch(r'kind\s*:\s*["\']?(brand|layout|deck)["\']?', stripped)
if match:
return match.group(1) in {'layout', 'deck'}
return False
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
"""Fold already-confirmed choices into later-stage recommendations."""
try:
@@ -818,6 +843,17 @@ def create_app(
# the user's choices instead of catalog defaults.
if _recommendation_stage(data) >= 2 and result_file.exists():
_merge_confirmed_choices(data, result_file)
template_adherence_enabled = _template_adherence_enabled(project_path)
data['_template_adherence_enabled'] = template_adherence_enabled
recommend = data.get('recommend')
if template_adherence_enabled:
if not isinstance(recommend, dict):
recommend = data['recommend'] = {}
recommend.setdefault('template_adherence', 'adaptive')
else:
if isinstance(recommend, dict):
recommend.pop('template_adherence', None)
data.pop('template_adherence', None)
# The page polls this endpoint after a stage-1 confirm until the AI
# overwrites the file with the re-derived stage-2 recommendations, so it
# must never be served from a cache.
@@ -1,6 +1,6 @@
/* PPT Master - Strategist confirmation stage UI
* Finite/enumerable fields (canvas, mode, visual style, icons, image usage,
* AI source, formula policy, generation mode) list ALL options from
* Finite/enumerable fields (canvas, mode, visual style, template adherence,
* icons, image usage, AI source, formula policy, generation mode) list ALL options from
* /static/catalogs.json with the AI's recommendation marked. Open/generative
* fields (color, typography, generated-image style) show >=3 AI candidates. Open fields also expose
* Custom controls. On confirm the page saves result.json and closes.
@@ -38,6 +38,7 @@
sec_refine: "Refine spec first",
sub_mode: "Narrative mode",
sub_visual: "Visual style",
sub_template_adherence: "Template adherence",
sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)",
placeholder_divergence: "In your words — e.g. \"stick closely to the document\" / \"freely restructure and expand within the source\". Leave blank for a balanced default.",
custom: "Custom",
@@ -149,6 +150,7 @@
sec_refine: "先に設計仕様を精査",
sub_mode: "ナラティブモード",
sub_visual: "ビジュアルスタイル",
sub_template_adherence: "テンプレートの適用方法",
sub_divergence: "素材からの発散度(どこまで自由に再構成するか、原文に忠実か)",
placeholder_divergence: "自分の言葉でどうぞ — 例:「文書に忠実に」「元素材の範囲内で自由に再構成・展開」。空欄ならバランス型になります。",
custom: "カスタム",
@@ -260,6 +262,7 @@
sec_refine: "先精修设计规范",
sub_mode: "叙事模式",
sub_visual: "视觉风格",
sub_template_adherence: "模板遵循方式",
sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)",
placeholder_divergence: "用你自己的话写,例如「严格贴着文档来」/「在源材料范围内自由重组并展开」。留空则按平衡处理。",
custom: "自定义",
@@ -719,6 +722,15 @@
function recValue(field) {
return (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
}
function hasTemplateAdherence() {
if (!REC) return false;
if (typeof REC._template_adherence_enabled === "boolean") {
return REC._template_adherence_enabled;
}
if (REC.recommend && REC.recommend.template_adherence != null) return true;
return REC.template_adherence != null;
}
// Guaranteed recommendation: the AI's pick, or the first catalog option as a
// fallback so an enumerable field ALWAYS shows a badged recommendation.
function recOrFirst(field, list) {
@@ -1054,6 +1066,15 @@
function () { return STATE.visual_style; }, function (v) { STATE.visual_style = v; refreshDirectionPreview(); },
{ allowCustom: true, spectrum: REC && REC.visual_style_spectrum });
sec.appendChild(sub2);
if (hasTemplateAdherence()) {
var templateField = el("div", "subfield");
templateField.appendChild(el("div", "subfield-label", t("sub_template_adherence")));
enumField(templateField, CAT.template_adherence,
recOrFirst("template_adherence", CAT.template_adherence),
function () { return STATE.template_adherence; },
function (v) { STATE.template_adherence = v; });
sec.appendChild(templateField);
}
host.appendChild(sec);
}
@@ -2074,6 +2095,11 @@
STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default
STATE.mode = pick("mode", CAT.modes);
STATE.visual_style = pick("visual_style", CAT.visual_styles);
if (hasTemplateAdherence()) {
STATE.template_adherence = pick("template_adherence", CAT.template_adherence);
} else {
delete STATE.template_adherence;
}
// Delivery purpose drives the PPT body px baseline; default balanced
// (not the catalog-first id) when the Strategist did not recommend one.
STATE.delivery_purpose = recId("delivery_purpose") || "balanced";
@@ -2150,6 +2176,7 @@
mode: STATE.mode,
visual_style: STATE.visual_style
};
if (STATE.template_adherence) payload.template_adherence = STATE.template_adherence;
// Delivery purpose is PPT-only and rendered only on PPT canvases (§c).
if (isPptCanvas(STATE.canvas)) payload.delivery_purpose = STATE.delivery_purpose;
return payload;
@@ -2169,6 +2196,7 @@
typography: STATE.typography,
formula_policy: STATE.formula_policy
};
if (STATE.template_adherence) payload.template_adherence = STATE.template_adherence;
if (isPptCanvas(STATE.canvas)) payload.delivery_purpose = STATE.delivery_purpose;
normalizeTypographyForSubmit(payload);
return payload;
@@ -555,6 +555,28 @@
"label_ja": "分割モード"
}
],
"template_adherence": [
{
"id": "adaptive",
"label": "adaptive",
"label_zh": "适应性使用",
"label_en": "adaptive",
"label_ja": "適応的に使用",
"desc_zh": "每页选择一个模板 SVG 作为架构参考;没有合适版式时沿用同一 Master 创建新的显式 Layout。",
"desc_en": "Choose a template SVG as the architecture reference for every page; when no layout fits, create a new explicit Layout under the same Master.",
"desc_ja": "各ページでテンプレートSVGを構造参照として選び、適合する版式がない場合は同じMaster配下に新しい明示的Layoutを作成する。"
},
{
"id": "strict",
"label": "strict",
"label_zh": "严格套用",
"label_en": "strict",
"label_ja": "厳密に適用",
"desc_zh": "每一页都从模板 SVG roster 选择版式,并保持其显式 Master、Layout 与 Placeholder 契约不变。",
"desc_en": "Map every page to the template SVG roster and keep its explicit Master, Layout, and Placeholder contract unchanged.",
"desc_ja": "すべてのページをテンプレートSVG一覧へ割り当て、明示されたMasterLayoutPlaceholder契約を変更せずに維持する。"
}
],
"delivery_purpose": [
{
"id": "text",
@@ -1,6 +1,6 @@
# Confirm UI — Strategist Confirmation Stage Page
> The interactive, visual surface for SKILL.md Step 4 (the Strategist confirmation stage). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show **≥3** AI candidates (creative recommendations always offer real choice — same rule as the h.5 image strategy; fewer only on the honest-shortfall exception, with a stated reason). Fields whose universe is open (canvas, mode, visual style, icons) also get a **Custom** box; image usage is a multi-select source list plus a free-text `image_notes` box. Fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same staged confirmation in chat.
> The interactive, visual surface for SKILL.md Step 4 (the Strategist confirmation stage). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show **≥3** AI candidates (creative recommendations always offer real choice — same rule as the h.5 image strategy; fewer only on the honest-shortfall exception, with a stated reason). Fields whose universe is open (canvas, mode, visual style, icons) also get a **Custom** box; image usage is a multi-select source list plus a free-text `image_notes` box. Fully closed fields (template adherence when a deck/layout template is active, AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same staged confirmation in chat.
## Authority and Scope
@@ -40,6 +40,7 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
- Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists (except `--shutdown`, which needs no recommendations).
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
- Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock.
- `/api/recommendations` derives `_template_adherence_enabled` from `<project>/templates/design_spec.md` frontmatter. Only `kind: deck` / `kind: layout` enables the Stage-1 field; no template and `kind: brand` force it off even if a stale recommendation contains the key. When enabled but no recommendation was authored, the server supplies `adaptive` as the default recommendation.
Dependency:
@@ -52,16 +53,16 @@ pip install flask
- **Enumerable + custom** — canvas / mode / visual_style / icons. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, self-provided icon system, etc.). `visual_style` additionally honors an optional `visual_style_spectrum` that badges a 3-pick personality spectrum (safe / shifted / bold, each with a temperament tag + analogy) in place of the single recommendation — see the schema below.
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for `visual_style`, and renders real sample SVGs from `templates/icons` for `icons`. These thumbnails make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from `recommendations.json`, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to `recommendations.json` or `result.json`, and they do not replace the later Step 6 live preview.
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
- **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji.
- **Closed enumerable** template adherence (conditional), formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji.
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors **≥3 candidates** the page renders as cards (never a single option — creative fields must offer real choice; fewer than 3 only on the honest-shortfall exception). `page_count`, `audience`, and `content_divergence` are free inputs (`content_divergence` is a free-text intent shown under audience in §c, not a fixed-option field).
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, and `icons`. Image usage uses a multi-select source list plus `image_notes` instead of a Custom box. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, and `icons`. Image usage uses a multi-select source list plus `image_notes` instead of a Custom box. Fully closed sets — `template_adherence`, `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` includes `ai`. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
## Catalogs — `static/catalogs.json` (the finite option universe)
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while trilingual labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all three languages (zh / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Chinese and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the trilingual labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while trilingual labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `template_adherence`, `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all three languages (zh / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Chinese and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the trilingual labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
## Round-trip data contract
@@ -73,7 +74,7 @@ The page runs as a **three-stage wizard in one browser session**. `recommendatio
| `recommendations.json stage` | Page renders | Button | On submit |
|---|---|---|---|
| `"stage1"` | direction anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)*, mode + visual_style | **Next** | writes `result.json` `{ stage: "stage1", status: "stage1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
| `"stage1"` | direction anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)*, mode + visual_style, plus `template_adherence` only when a deck/layout template is active | **Next** | writes `result.json` `{ stage: "stage1", status: "stage1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
| `"stage2"` | design system — page count, color, icons, typography, formula policy | **Next** | writes `result.json` `{ stage: "stage2", status: "stage2-confirmed", <anchors + design system> }`; the page stays open and polls for Stage 3 |
| `"stage3"` | images and execution — image usage + generated-image style, generation mode, refine spec | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
| *(absent)* | legacy single-pass — every section on one page | **Confirm** | single final write (`status: "confirmed"`) — backward-compatible |
@@ -90,6 +91,7 @@ The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-deri
"canvas": "ppt169",
"mode": "pyramid",
"visual_style": "swiss-minimal",
"template_adherence": "adaptive",
"icons": "tabler-outline",
"image_usage": ["ai", "provided"],
"image_ai_path": "auto",
@@ -151,6 +153,7 @@ The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-deri
> Each `candidates` array above shows **one** entry for brevity — `color` and `typography` must each carry **≥3** in a real file, while `image_strategy.candidates` should carry **exactly 3 non-custom recommendation** entries when AI image generation is offered. The UI adds the fourth **Custom** card itself; `selected` indexes the recommended default among the recommendation entries.
- `recommend.*` names the recommended `id` for each enumerable field (must match a `catalogs.json` id, or be a free string for a recommended custom value). The page badges and pre-selects it. **Guarantee**: if a `recommend.*` is omitted, the page falls back to the first catalog option so every enumerable field always shows one badged recommendation — but the AI should still set them for a meaningful default. Legacy aliases are accepted for old files (`line``tabler-outline`, `filled``tabler-filled`, `monochrome``chunk-filled`, `search``web`, `default``auto`, `builtin``host-native`), but new files should write canonical ids.
- `recommend.template_adherence` is conditional. Write `adaptive` or `strict` only when Step 3 loaded a `kind: deck` or `kind: layout` template. The server independently checks the copied template spec and enables the field only for those two kinds; free design and brand-only templates cannot display or return it. If the template is active but the recommendation key is missing, the server supplies `adaptive`. Both values use explicit template export and require a reference SVG for every page: `strict` keeps the selected Layout contract; `adaptive` may create a new Layout under the same Master.
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
- When `recommend.image_usage` includes `ai`, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
@@ -176,6 +179,7 @@ The AI launches Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-deri
"content_divergence": "freely restructure and expand within the source",
"mode": "pyramid",
"visual_style": "swiss-minimal",
"template_adherence": "adaptive",
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
"icons": "tabler-outline",
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
@@ -228,6 +228,36 @@ Usage boundary:
- Beautify promotes selected identity/content fields into locked constraints after confirmation.
- Template-fill uses the slide library as the native PPTX fill contract.
## `pptx_to_svg.py`
Reconstruct a PPTX package as editable SVG views by reading OOXML directly.
```bash
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode both
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode layered
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode flat
```
| Mode | Output |
|---|---|
| `both` (default) | Layered master/layout/slide SVGs under `svg/`, plus self-contained slides under `svg-flat/` |
| `layered` | Only the layered `svg/` view and inheritance metadata |
| `flat` | One self-contained slide SVG per page under `svg/` |
Supported unmerged tables and conservative classic-chart caches carry
`data-pptx-native` metadata beside their SVG fallback. Markers remain dormant
unless a later export uses `--native-objects`. Unsupported tables keep their
rendered SVG table; unsupported charts keep a baked preview or explicit
placeholder. Both carry `data-pptx-native-status`.
Exporter-canonical charts recover canonical solid series/slice colors and exact
one- or two-paragraph title styling; two paragraphs retain their `title` /
`subtitle` roles. This is not a general source-chart style round-trip guarantee.
Concrete slide SVGs resolve `<a:fld type="slidenum">` using the presentation's
`firstSlideNum` display numbering. Standalone master/layout SVGs keep the
literal field fallback because one shared part can serve multiple slides.
## `source_to_md/web_to_md.py`
Convert web pages to Markdown and download images locally.
@@ -110,7 +110,7 @@ Notes:
- **SVG output emits two views by default** (`--inheritance-mode both`):
- `svg/` — layered template view for designers: every master and layout in the deck rendered once as `svg/master_*.svg` / `svg/layout_*.svg` (including ones no sample slide currently references); `svg/slide_NN.svg` contains only that slide's own shapes; `svg/inheritance.json` records which layout / master each slide consumes.
- `svg-flat/` — companion view: each `slide_NN.svg` is self-contained (master + layout + slide painted into one file), so opening any slide in isolation shows the full page like PowerPoint would. Useful for previews, screenshots, and "did this slide actually render correctly" sanity checks.
- `manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, an asset map used by SVG `href` values, and common assets reused through slide / layout / master inheritance
- `manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, an asset map used by SVG `href` values, and common assets reused through slide / layout / master inheritance. Placeholder semantics keep `subTitle`, `obj`, `media`, and `dt` distinct as `subtitle`, `object`, `media`, and `date`.
- Layered slide SVGs keep only the slide's own background; inherited master / layout backgrounds stay in the corresponding master / layout SVGs
- Placeholder guides are intentionally lightweight in `svg/` master / layout files; `svg-flat/` hides those guides and is the visual preview source
- Charts, SmartArt, diagrams, and OLE objects become typed placeholders in `svg/`; `svg-flat/` shows a preview image with a corner badge when one exists, otherwise a visible placeholder. Tables are converted into real SVG content.
@@ -30,8 +30,11 @@ Convert project SVGs into PPTX.
```bash
python3 scripts/svg_to_pptx.py <project_path>
python3 scripts/svg_to_pptx.py <project_path> --only native
python3 scripts/svg_to_pptx.py <project_path> --only legacy
python3 scripts/svg_to_pptx.py <project_path> --native-objects
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure template # explicit SVG template metadata
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure preserve # imported source package contract
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure 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 <project_path> --no-notes
python3 scripts/svg_to_pptx.py <project_path> -t none
@@ -55,9 +58,20 @@ Behavior:
- 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
- Recommended source directory: `svg_final/`
- Native export reads `svg_output/`; `--svg-snapshot` and legacy export read
`svg_final/`. Use `-s` only as an explicit diagnostic override.
- 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,
narration, transitions, and direct native-PPTX workflows keep their separate
inputs and package-level processing.
- For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide.
- Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content.
- Omitting `--pptx-structure` reads `spec_lock.md` and falls back to `baseline`. Baseline assigns Layout families from root `data-pptx-page-role`, keeps content Slide-local, and promotes only exact family-wide backgrounds plus exact leading structurally marked chrome; filenames and ids are compatibility fallbacks for marker-free legacy SVGs. It never infers placeholders or visual similarity. Template mode builds reusable PowerPoint structure only from explicit SVG metadata and validates every `pptx_layouts` row plus cross-slide equality. Both strict and adaptive template adherence use this mode; adaptive may introduce a new Layout key under the same Master.
- Template/preserve placeholder semantics distinguish title, subtitle, body, picture, chart, table, generic object, media, date, footer, and slide number. Reconstructed titles are normally type-matched without an index; explicit imported title indices and all other source indices are retained. Imported `subTitle`, `obj`, `media`, and `dt` identities remain distinct through `manifest.json`, `native_structure.json`, Layout XML, and Slide XML.
- Baseline/template native export reads `spec_lock.md` typography into the PowerPoint theme: `title_family` becomes the major font and `body_family` / `font_family` becomes the minor font. Matching SVG text emits `+mj-*` / `+mn-*` tokens, while unrelated emphasis/code/brand families stay concrete. Preserve mode keeps the imported source theme; flat mode keeps fixed-font diagnostic output.
- 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.
@@ -74,11 +88,16 @@ Behavior:
- Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
- Page transitions are controlled by `-t/--transition`; per-element entrance animations are controlled by `-a/--animation`
- Per-element animation applies to top-level SVG `<g id="...">` groups in z-order; aim for 38 content groups per slide. Page chrome (background / header / footer / decorations / watermark / page number, by id token) is skipped automatically
- Per-element animation applies to top-level SVG `<g id="...">` groups in z-order; aim for 38 content groups per slide. Existing layer/slide-number placeholder semantics are read before minimal structural roles; exact id tokens remain a fallback only when all explicit markers are absent
- 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
- `auto` (default) maps effect from the group's SVG id: information-dense elements get a single stable effect (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (hero/figure-/image/img-/kpi) cycle through a richer visual pool (zoom/dissolve/circle/box/diamond/wheel) so multiple images vary across the deck; unmatched ids cycle through a fade/wipe/fly/zoom fallback pool
- Per-element animation defaults to `none`. `auto` is opt-in (`-a auto`) and maps
effects from the group's SVG id: information-dense elements get a stable
effect (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like
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
- `--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`
@@ -5,6 +5,8 @@ Reads OOXML directly via `pptx_to_svg` and writes a reusable reference workspace
- `manifest.json` single source of truth for slide size, theme colors, fonts,
asset inventory, and per-slide / per-layout / per-master metadata
- `native_structure.json` + `source_template.pptx` source-structure facts and
a byte-identical analysis copy used to rebuild explicit SVG structure
- `summary.md` short human-readable digest derived from manifest.json
- `assets/` extracted reusable image assets
- `svg/` primary view: by default the layered template view (every master
@@ -24,6 +26,7 @@ from pathlib import Path
from console_encoding import configure_utf8_stdio
from template_import.manifest import build_manifest
from template_import.native_structure import write_native_structure_bundle
configure_utf8_stdio()
@@ -42,14 +45,17 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--skip-manifest",
action="store_true",
help="Skip PPTX metadata extraction and asset inventory generation",
help=(
"Skip metadata, asset inventory, native structure contract, and "
"preserved source-package generation"
),
)
parser.add_argument(
"--manifest-only",
action="store_true",
help=(
"Only extract manifest.json + summary.md + reusable assets, "
"without exporting slides to SVG"
"Only extract manifest.json + summary.md + reusable assets + the "
"native structure/source pair, without exporting slides to SVG"
),
)
parser.add_argument(
@@ -98,6 +104,7 @@ def main() -> int:
return 1
manifest = None
native_structure = None
manifest_path = output_dir / "manifest.json"
if not args.skip_manifest:
try:
@@ -110,12 +117,28 @@ def main() -> int:
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
try:
native_structure = write_native_structure_bundle(
pptx_path,
output_dir,
manifest,
)
except (OSError, ValueError) as exc:
print(f"Error: failed to write native structure bundle: {exc}")
return 1
if args.manifest_only:
print(f"Imported PPTX template source: {pptx_path.name}")
print(f"Output directory: {output_dir}")
if manifest is not None:
print(f"Manifest: {manifest_path.name}")
print("Native structure: native_structure.json")
print("Source package analysis copy: source_template.pptx")
print(
"Source structure assessment: "
f"{native_structure['strategy']['recommendedMode']}"
)
print("Template output mode: explicit SVG structure")
print("Summary: summary.md")
print(f"Assets exported: {len(manifest['assets']['allAssets'])}")
print(f"Common assets: {len(manifest['assets']['commonAssets'])}")
@@ -147,6 +170,12 @@ def main() -> int:
print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)")
print(f"SVG bytes (primary): {total_bytes}")
print(f"Output directory: {output_dir}")
if native_structure is not None:
print(
"Source structure assessment: "
f"{native_structure['strategy']['recommendedMode']}; "
"create-template rebuilds explicit SVG structure"
)
return 0
@@ -93,6 +93,18 @@ def percent_to_ratio(val: float | int | str | None, default: float = 0.0) -> flo
return default
def ooxml_bool(value: str | None, default: bool = False) -> bool:
"""Parse the boolean lexical forms accepted by OOXML."""
if value is None:
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "on"}:
return True
if normalized in {"0", "false", "off"}:
return False
return default
# ---------------------------------------------------------------------------
# xfrm / transform parsing
# ---------------------------------------------------------------------------
@@ -179,8 +191,8 @@ def parse_xfrm(xfrm_elem: ET.Element | None) -> Xfrm:
return Xfrm()
rot = angle_to_deg(xfrm_elem.get("rot"))
flip_h = xfrm_elem.get("flipH") == "1"
flip_v = xfrm_elem.get("flipV") == "1"
flip_h = ooxml_bool(xfrm_elem.get("flipH"))
flip_v = ooxml_bool(xfrm_elem.get("flipV"))
off = xfrm_elem.find("a:off", NS)
ext = xfrm_elem.find("a:ext", NS)
@@ -8,6 +8,7 @@ accessible for downstream shape conversion.
from __future__ import annotations
import posixpath
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
@@ -57,8 +58,8 @@ def _parse_rels(zf: zipfile.ZipFile, rels_path: str) -> dict[str, dict[str, str]
return {}
try:
root = ET.fromstring(zf.read(rels_path))
except ET.ParseError:
return {}
except ET.ParseError as exc:
raise RuntimeError(f"Invalid relationships XML in {rels_path}: {exc}") from exc
base = rels_path.replace("/_rels/", "/") # source part path
base = base[:-len(".rels")] # strip .rels suffix
@@ -82,8 +83,8 @@ def _load_xml(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None:
return None
try:
return ET.fromstring(zf.read(part_path))
except ET.ParseError:
return None
except ET.ParseError as exc:
raise RuntimeError(f"Invalid OOXML part {part_path}: {exc}") from exc
# ---------------------------------------------------------------------------
@@ -150,6 +151,7 @@ class OoxmlPackage:
self.presentation: PartRef | None = None
self.slide_size_px: tuple[float, float] = (1280.0, 720.0)
self.slide_size_emu: tuple[int, int] = (12192000, 6858000)
self.first_slide_number: int = 1
self._slides: list[SlideRef] = []
self._layouts: dict[str, PartRef] = {}
self._masters: dict[str, PartRef] = {}
@@ -186,6 +188,10 @@ class OoxmlPackage:
rels = _parse_rels(self.zip, _rels_path_for(part_path))
return PartRef(path=part_path, xml=xml, rels=rels)
def load_part(self, part_path: str) -> PartRef | None:
"""Load an arbitrary XML part by package path."""
return self._load_part(part_path)
def read_media(self, part_path: str) -> bytes | None:
"""Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
assert self.zip is not None
@@ -215,6 +221,25 @@ class OoxmlPackage:
if self.presentation is None:
raise RuntimeError(f"presentation.xml missing in {self.path}")
raw_first_slide_number = self.presentation.xml.attrib.get("firstSlideNum")
if raw_first_slide_number is not None:
first_slide_token = raw_first_slide_number.strip(" \t\r\n")
if re.fullmatch(r"[+-]?[0-9]+", first_slide_token) is None:
raise RuntimeError(
f"Invalid presentation firstSlideNum: {raw_first_slide_number!r}"
)
digits = first_slide_token.lstrip("+-").lstrip("0") or "0"
if len(digits) > 10:
raise RuntimeError("Presentation firstSlideNum is outside xsd:int")
first_slide_number = int(digits)
if first_slide_token.startswith("-"):
first_slide_number = -first_slide_number
if not -(2**31) <= first_slide_number <= 2**31 - 1:
raise RuntimeError(
f"Presentation firstSlideNum is outside xsd:int: {first_slide_number}"
)
self.first_slide_number = first_slide_number
# slide size
size = self.presentation.xml.find("p:sldSz", NS)
if size is not None:
@@ -372,4 +397,3 @@ class OoxmlPackage:
continue
self._layouts[target] = cached
yield cached, master
@@ -10,6 +10,8 @@ Handles:
- <p:grpSp> -> GROUP (recurses; resolves a:chOff/a:chExt frame)
- <p:graphicFrame> -> GRAPHIC (table / chart / SmartArt emitted as opaque
placeholder for v1 so callers can decide a fallback)
- <mc:AlternateContent> -> supported Choice shape with the baked Fallback
preview retained for graphic frames
"""
from __future__ import annotations
@@ -17,7 +19,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from .emu_units import NS, Xfrm, parse_xfrm
from .emu_units import NS, Xfrm, ooxml_bool, parse_xfrm
# ---------------------------------------------------------------------------
@@ -84,7 +86,7 @@ def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, Pla
if cnv is not None:
name = cnv.attrib.get("name", "")
spid = cnv.attrib.get("id", "")
if cnv.attrib.get("hidden") == "1":
if ooxml_bool(cnv.attrib.get("hidden")):
hidden = True
nv_pr = container.find("p:nvPr", NS)
@@ -160,6 +162,44 @@ _KIND_MAP = {
}
def _first_shape_child(container: ET.Element | None) -> ET.Element | None:
if container is None:
return None
for child in list(container):
if not isinstance(child.tag, str):
continue
if child.tag.split("}", 1)[-1] in _KIND_MAP:
return child
return None
def _resolve_alternate_content(wrapper: ET.Element) -> ET.Element | None:
"""Select an AlternateContent shape while retaining its baked preview."""
choice = wrapper.find("mc:Choice", NS)
fallback = wrapper.find("mc:Fallback", NS)
selected = _first_shape_child(choice)
selected_from_choice = selected is not None
if selected is None:
selected = _first_shape_child(fallback)
if selected is None:
return None
clone = ET.fromstring(ET.tostring(selected, encoding="utf-8"))
if (
selected_from_choice
and clone.tag.split("}", 1)[-1] == "graphicFrame"
and fallback is not None
):
graphic_data = clone.find("a:graphic/a:graphicData", NS)
if graphic_data is not None:
preview = ET.Element(f"{{{NS['mc']}}}AlternateContent")
preview.append(
ET.fromstring(ET.tostring(fallback, encoding="utf-8"))
)
graphic_data.append(preview)
return clone
def _walk_container(
container: ET.Element,
parent_group_xfrm: Xfrm | None,
@@ -176,6 +216,12 @@ def _walk_container(
if not isinstance(child.tag, str):
continue
local = child.tag.split("}", 1)[-1]
if local == "AlternateContent":
resolved = _resolve_alternate_content(child)
if resolved is None:
continue
child = resolved
local = child.tag.split("}", 1)[-1]
kind_info = _KIND_MAP.get(local)
if kind_info is None:
continue
@@ -22,10 +22,12 @@ animation anchor.
from __future__ import annotations
import json
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload
from .custgeom_to_svg import convert_custom_geom
from .effect_to_svg import convert_effects
from .emu_units import NS, Xfrm, fmt_num
@@ -59,6 +61,7 @@ class AssemblyContext:
palette: ColorPalette | None
pkg: OoxmlPackage
slide_part: PartRef
slide_number: int | None = None
theme_fonts: dict[str, str] = field(default_factory=dict)
media_subdir: str = "assets"
embed_images: bool = False
@@ -110,6 +113,7 @@ def assemble_slide(
palette=palette,
pkg=pkg,
slide_part=slide.part,
slide_number=pkg.first_slide_number + slide.index - 1,
theme_fonts=theme_fonts or {},
media_subdir=media_subdir,
embed_images=embed_images,
@@ -321,6 +325,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
text_result = convert_vertical_txbody(
tx_body, node.xfrm, ctx.palette,
theme_fonts=ctx.theme_fonts,
slide_number=ctx.slide_number,
default_fill=text_default_fill,
default_font_size_px=DEFAULT_FONT_SIZE_PX,
fallback_lst_styles=node.inherited_lst_styles,
@@ -331,6 +336,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
text_result = convert_txbody(
tx_body, node.xfrm, ctx.palette,
theme_fonts=ctx.theme_fonts,
slide_number=ctx.slide_number,
default_fill=text_default_fill,
default_font_size_px=DEFAULT_FONT_SIZE_PX,
fallback_lst_styles=node.inherited_lst_styles,
@@ -579,17 +585,35 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
- ``...presentationml/2006/ole`` render the ``mc:Fallback`` preview
bitmap that PowerPoint bakes alongside every embedded OLE object.
Visually identical to what PowerPoint shows for an unedited embed.
- everything else (chart / SmartArt / diagram) labelled bounding
rectangle so the slide composition is preserved even though the inner
content can't be drawn yet.
- supported classic charts baked preview plus native chart metadata.
- everything else (SmartArt / diagram / unsupported chart) labelled
preview or bounding rectangle plus transparent unsupported metadata.
"""
graphic_data = node.xml.find("a:graphic/a:graphicData", NS)
uri = graphic_data.attrib.get("uri", "graphicFrame") if graphic_data is not None else "graphicFrame"
if uri == "http://schemas.openxmlformats.org/drawingml/2006/table":
rendered = _render_graphic_table(node, ctx, graphic_data)
rendered, native_attrs = _render_graphic_table(node, ctx, graphic_data)
if rendered:
return _wrap_shape_group(rendered, node, ctx, top_level=top_level)
return _wrap_shape_group(
rendered,
node,
ctx,
top_level=top_level,
extra_attrs=native_attrs,
)
chart_native_attrs: list[str] = []
if uri in {CHART_URI, CHARTEX_URI}:
rendered, chart_native_attrs = _render_graphic_chart(node, ctx, graphic_data)
if rendered:
return _wrap_shape_group(
rendered,
node,
ctx,
top_level=top_level,
extra_attrs=chart_native_attrs,
)
if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole" and ctx.render_graphic_previews:
rendered = _render_graphic_preview(node, ctx)
@@ -613,7 +637,9 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
f'text-anchor="middle" font-size="14" fill="#999999">'
f"[{_xml_escape(label)}]</text>"
)
return _wrap_shape_group(placeholder, node, ctx, top_level=top_level)
return _wrap_shape_group(
placeholder, node, ctx, top_level=top_level, extra_attrs=chart_native_attrs,
)
def _graphic_preview_label(node: ShapeNode, label: str) -> str:
@@ -626,24 +652,83 @@ def _graphic_preview_label(node: ShapeNode, label: str) -> str:
)
def _render_graphic_table(node: ShapeNode, ctx: AssemblyContext,
graphic_data: ET.Element | None) -> str:
"""Convert the <a:tbl> child of a graphicFrame to SVG, or return ''."""
def _render_graphic_table(
node: ShapeNode,
ctx: AssemblyContext,
graphic_data: ET.Element | None,
) -> tuple[str, list[str]]:
"""Convert the <a:tbl> child of a graphicFrame to SVG plus metadata."""
if graphic_data is None:
return ""
return "", []
tbl = graphic_data.find("a:tbl", NS)
if tbl is None:
return ""
return "", []
result = convert_tbl(
tbl, node.xfrm, ctx.palette,
theme_fonts=ctx.theme_fonts,
slide_number=ctx.slide_number,
id_prefix=f"tbl{ctx.shape_seq[0]}",
grad_seq=ctx.grad_seq,
marker_seq=ctx.marker_seq,
)
if result.defs:
ctx.defs.extend(result.defs)
return result.svg
native_attrs: list[str] = ['data-pptx-native-source="pptx"']
if result.native_payload:
if node.name and not result.native_payload.get("name"):
result.native_payload["name"] = node.name
payload_json = json.dumps(
result.native_payload,
allow_nan=False,
ensure_ascii=False,
separators=(",", ":"),
)
native_attrs.extend([
'data-pptx-native="table"',
f'data-pptx-json="{_xml_escape(payload_json)}"',
])
elif result.native_status:
native_attrs.append(
f'data-pptx-native-status="{_xml_escape(result.native_status)}"'
)
return result.svg, native_attrs
def _render_graphic_chart(
node: ShapeNode,
ctx: AssemblyContext,
graphic_data: ET.Element | None,
) -> tuple[str, list[str]]:
"""Return a chart preview plus native chart marker attributes."""
result = extract_native_chart_payload(
graphic_data,
node.xfrm,
ctx.slide_part,
ctx.pkg,
)
native_attrs: list[str] = ['data-pptx-native-source="pptx"']
if result.native_payload:
if node.name and not result.native_payload.get("name"):
result.native_payload["name"] = node.name
payload_json = json.dumps(
result.native_payload,
allow_nan=False,
ensure_ascii=False,
separators=(",", ":"),
)
native_attrs.extend([
'data-pptx-native="chart"',
f'data-pptx-json="{_xml_escape(payload_json)}"',
])
elif result.native_status:
native_attrs.append(
f'data-pptx-native-status="{_xml_escape(result.native_status)}"'
)
rendered = ""
if ctx.render_graphic_previews:
rendered = _render_graphic_preview(node, ctx)
return rendered, native_attrs
def _render_graphic_preview(node: ShapeNode, ctx: AssemblyContext) -> str:
@@ -889,8 +974,14 @@ def _convert_placeholder_guide(node: ShapeNode, ctx: AssemblyContext,
# Wrap / utilities
# ---------------------------------------------------------------------------
def _wrap_shape_group(inner: str, node: ShapeNode, ctx: AssemblyContext,
*, top_level: bool) -> str:
def _wrap_shape_group(
inner: str,
node: ShapeNode,
ctx: AssemblyContext,
*,
top_level: bool,
extra_attrs: list[str] | None = None,
) -> str:
"""Wrap a shape's body in a <g> that carries the transform (rotation /
flip) and an id for animation anchoring."""
if not inner.strip():
@@ -907,6 +998,8 @@ def _wrap_shape_group(inner: str, node: ShapeNode, ctx: AssemblyContext,
attrs.append(f'data-name="{_xml_escape(node.name)}"')
if node.placeholder is not None and node.placeholder.type:
attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"')
if extra_attrs:
attrs.extend(extra_attrs)
if transform:
attrs.append(f'transform="{transform}"')
return f"<g {' '.join(attrs)}>\n{inner}\n</g>"
@@ -37,10 +37,18 @@ Cell painting order:
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET
from .color_resolver import ColorPalette
from .emu_units import NS, Xfrm, emu_to_px, fmt_num
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .emu_units import (
NS,
Xfrm,
emu_to_px,
fmt_num,
hundredths_pt_to_px,
ooxml_bool,
)
from .fill_to_svg import resolve_fill
from .ln_to_svg import resolve_stroke
from .txbody_to_svg import convert_txbody
@@ -48,10 +56,12 @@ from .txbody_to_svg import convert_txbody
@dataclass
class TableResult:
"""Composite render output: SVG body + accumulated <defs>."""
"""Composite render output plus optional native table metadata."""
svg: str = ""
defs: list[str] = None
native_payload: dict[str, Any] | None = None
native_status: str | None = None
def __post_init__(self) -> None:
if self.defs is None:
@@ -68,6 +78,7 @@ def convert_tbl(
palette: ColorPalette | None,
*,
theme_fonts: dict[str, str] | None = None,
slide_number: int | None = None,
id_prefix: str = "tbl",
grad_seq: list[int] | None = None,
marker_seq: list[int] | None = None,
@@ -102,6 +113,35 @@ def convert_tbl(
# and dropped cells don't render anything. PowerPoint expresses merges via
# gridSpan/rowSpan on the anchor cell + hMerge/vMerge on the dropped cells.
cells = _build_cell_grid(rows, len(col_widths))
if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
native_status = "unsupported-native-transform"
elif _table_has_merges(rows):
native_status = "unsupported-merge"
elif len(rows) > 1000 or len(col_widths) > 1000:
native_status = "unsupported-table-size"
elif (
any(width < 0 for width in col_widths)
or any(height < 0 for height in row_heights)
or sum(col_widths) <= 0
or sum(row_heights) <= 0
):
native_status = "unsupported-table-geometry"
elif _table_has_unsupported_style(tbl):
native_status = "unsupported-table-style"
elif _table_has_unsupported_direct_formatting(tbl, palette):
native_status = "unsupported-table-direct-formatting"
else:
native_status = None
native_payload = (
None if native_status else _native_table_payload(
tbl,
xfrm,
col_widths,
row_heights,
cells,
palette,
)
)
body_parts: list[str] = []
defs: list[str] = []
@@ -146,6 +186,7 @@ def convert_tbl(
cell_xfrm = Xfrm(x=cell_x, y=cell_y, w=cell_w, h=cell_h)
text_result = _convert_cell_text(
tx_body, tcPr, cell_xfrm, palette, theme_fonts,
slide_number=slide_number,
id_prefix=f"{id_prefix}txt",
id_seq=grad_seq,
)
@@ -176,7 +217,12 @@ def convert_tbl(
if line_xml:
body_parts.append(line_xml)
return TableResult(svg="\n".join(body_parts), defs=defs)
return TableResult(
svg="\n".join(body_parts),
defs=defs,
native_payload=native_payload,
native_status=native_status,
)
# ---------------------------------------------------------------------------
@@ -252,8 +298,8 @@ def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellS
grid_span = _safe_int(tc.attrib.get("gridSpan"), 1)
row_span = _safe_int(tc.attrib.get("rowSpan"), 1)
h_merge = tc.attrib.get("hMerge") == "1"
v_merge = tc.attrib.get("vMerge") == "1"
h_merge = ooxml_bool(tc.attrib.get("hMerge"))
v_merge = ooxml_bool(tc.attrib.get("vMerge"))
if h_merge or v_merge:
# This cell is a merge slave; leave the slot tied to the anchor
@@ -294,6 +340,345 @@ def _safe_int(value: str | None, default: int) -> int:
return default
def _table_has_merges(rows: list[ET.Element]) -> bool:
"""Return True when the table uses merged cells."""
for row in rows:
for tc in row.findall("a:tc", NS):
if ooxml_bool(tc.attrib.get("hMerge")) or ooxml_bool(tc.attrib.get("vMerge")):
return True
if _safe_int(tc.attrib.get("gridSpan"), 1) > 1:
return True
if _safe_int(tc.attrib.get("rowSpan"), 1) > 1:
return True
return False
def _table_has_unsupported_style(tbl: ET.Element) -> bool:
tbl_pr = tbl.find("a:tblPr", NS)
if tbl_pr is None:
return False
allowed_attrs = {
"firstRow", "bandRow", "firstCol", "lastCol", "lastRow",
"bandCol", "rtl",
}
if any(name not in allowed_attrs for name in tbl_pr.attrib):
return True
if any(
ooxml_bool(tbl_pr.attrib.get(name))
for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl")
):
return True
return any(
child.tag.rsplit("}", 1)[-1] != "tableStyleId"
for child in tbl_pr
)
def _table_has_unsupported_direct_formatting(
tbl: ET.Element,
palette: ColorPalette | None,
) -> bool:
"""Reject direct cell features the compact native schema cannot retain."""
for tc in tbl.findall(".//a:tc", NS):
tc_pr = tc.find("a:tcPr", NS)
if tc_pr is not None:
allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"}
if any(name not in allowed_attrs for name in tc_pr.attrib):
return True
if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}:
return True
if any(
child.tag.rsplit("}", 1)[-1] != "solidFill"
for child in tc_pr
):
return True
solid_fill = tc_pr.find("a:solidFill", NS)
if solid_fill is not None:
if solid_fill.find(".//a:alpha", NS) is not None:
return True
if _cell_fill_hex(tc_pr, palette) is None:
return True
tx_body = tc.find("a:txBody", NS)
if _text_body_has_unsupported_formatting(tx_body):
return True
if (
tx_body is not None
and tx_body.find(".//a:solidFill", NS) is not None
and _cell_text_color(tx_body, palette) is None
):
return True
return False
def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool:
if tx_body is None:
return False
body_pr = tx_body.find("a:bodyPr", NS)
if body_pr is not None and (body_pr.attrib or list(body_pr)):
return True
list_style = tx_body.find("a:lstStyle", NS)
if list_style is not None and (list_style.attrib or list(list_style)):
return True
if (
tx_body.find(".//a:br", NS) is not None
or tx_body.find(".//a:fld", NS) is not None
or tx_body.find(".//a:tab", NS) is not None
):
return True
paragraphs = tx_body.findall("a:p", NS)
if len(paragraphs) > 1:
return True
alignments: set[str | None] = set()
run_signatures: set[tuple[str | None, str | None, bytes | None]] = set()
for paragraph in paragraphs:
p_pr = paragraph.find("a:pPr", NS)
alignment = p_pr.get("algn") if p_pr is not None else None
if alignment not in {None, "l", "ctr", "r"}:
return True
alignments.add(alignment)
if p_pr is not None:
if any(name != "algn" for name in p_pr.attrib):
return True
if any(
child.tag.rsplit("}", 1)[-1] not in {"defRPr", "buNone"}
for child in p_pr
):
return True
default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
if _run_props_have_unsupported_formatting(default_r_pr):
return True
for run in paragraph.findall("a:r", NS):
r_pr = run.find("a:rPr", NS)
if _run_props_have_unsupported_formatting(r_pr):
return True
run_signatures.add(_effective_run_signature(r_pr, default_r_pr))
end_r_pr = paragraph.find("a:endParaRPr", NS)
if _run_props_have_unsupported_formatting(end_r_pr):
return True
if not paragraph.findall("a:r", NS) and end_r_pr is not None:
run_signatures.add(_effective_run_signature(end_r_pr, default_r_pr))
return len(alignments) > 1 or len(run_signatures) > 1
def _run_props_have_unsupported_formatting(r_pr: ET.Element | None) -> bool:
if r_pr is None:
return False
if ooxml_bool(r_pr.get("i")):
return True
if r_pr.get("u") not in {None, "none"}:
return True
if r_pr.get("strike") not in {None, "noStrike"}:
return True
if r_pr.get("baseline") not in {None, "0"}:
return True
if r_pr.get("cap") not in {None, "none"}:
return True
if r_pr.get("spc") not in {None, "0"}:
return True
allowed_attrs = {
"lang", "altLang", "sz", "b", "i", "u", "strike", "dirty",
"baseline", "cap", "spc",
}
if any(name not in allowed_attrs for name in r_pr.attrib):
return True
solid_fill = r_pr.find("a:solidFill", NS)
if solid_fill is not None and solid_fill.find(".//a:alpha", NS) is not None:
return True
return any(
child.tag.rsplit("}", 1)[-1] != "solidFill"
for child in r_pr
)
def _effective_run_signature(
r_pr: ET.Element | None,
default_r_pr: ET.Element | None,
) -> tuple[str | None, str | None, bytes | None]:
def attr(name: str) -> str | None:
if r_pr is not None and r_pr.get(name) is not None:
return r_pr.get(name)
return default_r_pr.get(name) if default_r_pr is not None else None
solid_fill = r_pr.find("a:solidFill", NS) if r_pr is not None else None
if solid_fill is None and default_r_pr is not None:
solid_fill = default_r_pr.find("a:solidFill", NS)
fill_xml = (
ET.tostring(solid_fill, encoding="utf-8")
if solid_fill is not None else None
)
return attr("b"), attr("sz"), fill_xml
def _round_payload_number(value: float) -> int | float:
rounded = round(float(value), 3)
return int(rounded) if rounded.is_integer() else rounded
def _native_table_payload(
tbl: ET.Element,
xfrm: Xfrm,
column_widths: list[float],
row_heights: list[float],
cells: list[list[_CellSlot | None]],
palette: ColorPalette | None,
) -> dict[str, Any]:
"""Build the SVG data-pptx-native payload for an unmerged table."""
tbl_pr = tbl.find("a:tblPr", NS)
payload: dict[str, Any] = {
"x": _round_payload_number(xfrm.x),
"y": _round_payload_number(xfrm.y),
"width": _round_payload_number(xfrm.w),
"height": _round_payload_number(xfrm.h),
"strict_grid": True,
"header_rows": (
1 if tbl_pr is not None and ooxml_bool(tbl_pr.get("firstRow")) else 0
),
"column_widths": [_round_payload_number(width) for width in column_widths],
"row_heights": [_round_payload_number(height) for height in row_heights],
"rows": [],
}
style: dict[str, Any] = {
"band_row": bool(tbl_pr is not None and ooxml_bool(tbl_pr.get("bandRow"))),
}
if tbl_pr is not None:
table_style_id = tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip()
if table_style_id:
style["table_style_id"] = table_style_id
payload["style"] = style
rows_payload: list[list[Any]] = []
for row_cells in cells:
row_payload: list[Any] = []
for slot in row_cells:
if slot is None or slot.is_dropped:
row_payload.append("")
continue
row_payload.append(_native_cell_payload(slot.element, palette))
rows_payload.append(row_payload)
payload["rows"] = rows_payload
return payload
def _native_cell_payload(tc: ET.Element, palette: ColorPalette | None) -> dict[str, Any]:
tx_body = tc.find("a:txBody", NS)
tc_pr = tc.find("a:tcPr", NS)
cell: dict[str, Any] = {"text": _cell_plain_text(tx_body)}
fill = _cell_fill_hex(tc_pr, palette)
if fill:
cell["fill"] = fill
color = _cell_text_color(tx_body, palette)
if color:
cell["color"] = color
font_size = _cell_font_size_px(tx_body)
if font_size:
cell["font_size"] = font_size
align = _cell_align(tx_body)
if align:
cell["align"] = align
valign = _cell_valign(tc_pr)
if valign:
cell["valign"] = valign
bold = _cell_bold(tx_body)
if bold is not None:
cell["bold"] = bold
_copy_cell_margins(tc_pr, cell)
return cell
def _cell_plain_text(tx_body: ET.Element | None) -> str:
if tx_body is None:
return ""
paragraphs: list[str] = []
for paragraph in tx_body.findall("a:p", NS):
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
if text:
paragraphs.append(text)
return "\n".join(paragraphs)
def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None:
fill = resolve_fill(tc_pr, palette)
color = fill.attrs.get("fill") if fill.attrs else None
if color and color.startswith("#"):
return color
return None
def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None:
for r_pr in _text_run_props_in_priority(tx_body):
solid_fill = r_pr.find("a:solidFill", NS)
if solid_fill is not None:
color, _alpha = resolve_color(find_color_elem(solid_fill), palette)
return color
return None
def _cell_font_size_px(tx_body: ET.Element | None) -> int | float | None:
for r_pr in _text_run_props_in_priority(tx_body):
if not r_pr.get("sz"):
continue
size = hundredths_pt_to_px(r_pr.get("sz"))
if size > 0:
return _round_payload_number(size)
return None
def _cell_align(tx_body: ET.Element | None) -> str | None:
if tx_body is None:
return None
p_pr = tx_body.find("a:p/a:pPr", NS)
align = p_pr.get("algn") if p_pr is not None else None
if align in {"l", "ctr", "r"}:
return align
return None
def _cell_valign(tc_pr: ET.Element | None) -> str | None:
anchor = tc_pr.get("anchor") if tc_pr is not None else None
return {
"t": "top",
"ctr": "middle",
"b": "bottom",
}.get(anchor)
def _cell_bold(tx_body: ET.Element | None) -> bool | None:
for r_pr in _text_run_props_in_priority(tx_body):
if r_pr.get("b") is not None:
return ooxml_bool(r_pr.get("b"))
return None
def _text_run_props_in_priority(tx_body: ET.Element | None) -> list[ET.Element]:
if tx_body is None:
return []
props: list[ET.Element] = []
for path in (".//a:r/a:rPr", ".//a:pPr/a:defRPr", ".//a:endParaRPr"):
r_pr = tx_body.find(path, NS)
if r_pr is not None:
props.append(r_pr)
return props
def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None:
if tc_pr is None:
return
for source, target in (
("marL", "padding_left"),
("marR", "padding_right"),
("marT", "padding_top"),
("marB", "padding_bottom"),
):
if source not in tc_pr.attrib:
continue
cell[target] = _round_payload_number(emu_to_px(tc_pr.attrib[source]))
# ---------------------------------------------------------------------------
# Cell text & borders
# ---------------------------------------------------------------------------
@@ -305,6 +690,7 @@ def _convert_cell_text(
palette: ColorPalette | None,
theme_fonts: dict[str, str] | None,
*,
slide_number: int | None,
id_prefix: str,
id_seq: list[int] | None,
):
@@ -323,6 +709,7 @@ def _convert_cell_text(
try:
return convert_txbody(
tx_body, cell_xfrm, palette, theme_fonts=theme_fonts,
slide_number=slide_number,
id_prefix=id_prefix,
id_seq=id_seq,
)
@@ -103,6 +103,7 @@ def convert_txbody(
palette: ColorPalette | None,
*,
theme_fonts: dict[str, str] | None = None,
slide_number: int | None = None,
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
@@ -118,7 +119,7 @@ def convert_txbody(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
id_prefix=id_prefix, id_seq=id_seq,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
if not paragraphs or not _has_visible_text(paragraphs):
return TextResult()
@@ -188,6 +189,7 @@ def convert_vertical_txbody(
palette: ColorPalette | None,
*,
theme_fonts: dict[str, str] | None = None,
slide_number: int | None = None,
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
@@ -208,7 +210,7 @@ def convert_vertical_txbody(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
fallback_lst_styles=fallback_lst_styles,
id_prefix=id_prefix, id_seq=id_seq,
slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq,
)
runs = [
run
@@ -342,6 +344,7 @@ def _parse_paragraphs(
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
fallback_lst_styles: tuple[ET.Element, ...] = (),
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> list[TextParagraph]:
@@ -360,6 +363,7 @@ def _parse_paragraphs(
lst_styles=lst_styles,
default_fill=default_fill,
default_font_size_px=default_font_size_px,
slide_number=slide_number,
id_prefix=id_prefix, id_seq=id_seq,
)
paragraphs.append(para)
@@ -376,6 +380,7 @@ def _parse_paragraph(
lst_styles: tuple[ET.Element, ...] = (),
default_fill: str = DEFAULT_FILL_HEX,
default_font_size_px: float = DEFAULT_FONT_SIZE_PX,
slide_number: int | None = None,
id_prefix: str = "txt",
id_seq: list[int] | None = None,
) -> TextParagraph:
@@ -430,10 +435,15 @@ def _parse_paragraph(
is_break=True,
))
elif local == "fld":
# Field (datetime / slidenum). Use the literal a:t fallback.
# Slide SVGs have a concrete page context, so resolve slide-number
# fields there. Standalone master/layout renders keep the literal
# fallback because one shared part can serve many slide numbers.
rpr = child.find("a:rPr", NS)
text_elem = child.find("a:t", NS)
text = text_elem.text or "" if text_elem is not None else ""
field_type = child.attrib.get("type", "").strip().lower()
if field_type == "slidenum" and slide_number is not None:
text = str(slide_number)
if text:
run = _build_run(
text, rpr, end_rpr, palette, theme_fonts,
@@ -73,6 +73,43 @@ try:
except ImportError:
_native_object_marker_warnings = None
try:
from svg_to_pptx.semantic_markers import (
SEMANTIC_ATTRS as _SEMANTIC_ATTRS,
validate_semantic_markers as _validate_semantic_markers,
)
except ImportError:
_SEMANTIC_ATTRS = frozenset({
'data-pptx-page-role',
'data-pptx-role',
})
_validate_semantic_markers = None
try:
from svg_to_pptx.pptx_package.template_structure import (
TemplateStructureError as _TemplateStructureError,
PptxLayoutReference as _PptxLayoutReference,
PptxStructureLock as _PptxStructureLock,
load_native_structure_contract as _load_native_structure_contract,
load_pptx_structure_lock as _load_pptx_structure_lock,
native_structure_lock_errors as _native_structure_lock_errors,
parse_preserve_slides as _parse_preserve_structure_slides,
parse_template_slides as _parse_template_structure_slides,
template_lock_errors as _template_lock_errors,
validate_template_svg as _validate_template_structure_svg,
)
except ImportError:
_TemplateStructureError = None
_PptxLayoutReference = None
_PptxStructureLock = None
_load_native_structure_contract = None
_load_pptx_structure_lock = None
_native_structure_lock_errors = None
_parse_preserve_structure_slides = None
_parse_template_structure_slides = None
_template_lock_errors = None
_validate_template_structure_svg = None
try:
from svg_finalize.embed_icons import (
resolve_icon_path as _resolve_icon_path,
@@ -310,6 +347,7 @@ class SVGQualityChecker:
self._template_issues: List[Tuple[str, str, str]] = []
self._animation_issues: List[Tuple[str, str]] = []
self._illustration_issues: List[Tuple[str, str, str]] = []
self._pptx_structure_issues: List[Tuple[str, str]] = []
self._aggregate_counts_applied = False
def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
@@ -383,6 +421,12 @@ class SVGQualityChecker:
# 8c. Check opt-in native table/chart markers before export.
self._check_native_object_markers(root, result)
# 8d. Validate explicit master/layout/placeholder metadata.
self._check_pptx_structure_metadata(root, svg_path, result)
# 8e. Validate rendering-neutral page/structure compiler hints.
self._check_semantic_markers(root, svg_path, result)
# 9. Check spec_lock drift (colors / font-family / font-size).
# Templates do not ship a spec_lock.md, so skip in template
# mode to avoid noise.
@@ -765,6 +809,8 @@ class SVGQualityChecker:
href = image.get('href') or image.get(f'{{{XLINK_NS}}}href')
if not href or href.startswith('data:'):
continue
if self.template_mode and '{{' in href and '}}' in href:
continue
if _resolve_external_image_reference is None or _unresolved_external_image_reference_path is None:
result['warnings'].append(
"Detected image references, but shared image resolver could not be imported; "
@@ -926,6 +972,15 @@ class SVGQualityChecker:
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
"""Validate opt-in native table/chart markers before PPTX export."""
for elem in root.iter():
status = elem.get('data-pptx-native-status')
if not status or elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
result['warnings'].append(
f"Native PPTX object {marker_id} is fallback-only: {status}"
)
markers = [
elem for elem in root.iter()
if elem.get('data-pptx-native') and elem.tag.rsplit('}', 1)[-1] != 'metadata'
@@ -939,11 +994,27 @@ class SVGQualityChecker:
)
return
parent_map = {
child: parent
for parent in root.iter()
for child in parent
}
for marker in markers:
marker_id = marker.get('id') or '<unnamed>'
ancestors = []
parent = parent_map.get(marker)
while parent is not None and parent is not root:
if parent.tag.rsplit('}', 1)[-1] == 'g':
ancestors.append(parent)
parent = parent_map.get(parent)
ancestors_tuple = tuple(reversed(ancestors))
if _validate_native_object_marker_with_warnings is not None:
try:
warnings = _validate_native_object_marker_with_warnings(marker)
warnings = _validate_native_object_marker_with_warnings(
marker,
ancestors=ancestors_tuple,
)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-native marker {marker_id}: {exc}"
@@ -956,7 +1027,7 @@ class SVGQualityChecker:
continue
try:
_validate_native_object_marker(marker)
_validate_native_object_marker(marker, ancestors=ancestors_tuple)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-native marker {marker_id}: {exc}"
@@ -964,11 +1035,76 @@ class SVGQualityChecker:
continue
if _native_object_marker_warnings is None:
continue
for warning in _native_object_marker_warnings(marker):
for warning in _native_object_marker_warnings(
marker,
ancestors=ancestors_tuple,
):
result['warnings'].append(
f"data-pptx-native marker {marker_id}: {warning}"
)
def _check_pptx_structure_metadata(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Validate explicit template-export metadata when any is present."""
structure_attrs = {
'data-pptx-layer',
'data-pptx-layout',
'data-pptx-layout-name',
'data-pptx-placeholder',
'data-pptx-placeholder-bounds',
'data-pptx-placeholder-idx',
'data-pptx-editable',
}
if not any(
elem.get(attr) is not None
for elem in root.iter()
for attr in structure_attrs
):
return
if _validate_template_structure_svg is None:
result['warnings'].append(
"Detected PPTX template structure metadata, but its validator "
"could not be imported; template export will still validate it."
)
return
result['errors'].extend(_validate_template_structure_svg(svg_path))
def _check_semantic_markers(
self,
root: ET.Element,
svg_path: Path,
result: Dict,
) -> None:
"""Validate minimal compiler hints without changing SVG rendering."""
has_semantics = any(
elem.get(attr) is not None
for elem in root.iter()
for attr in _SEMANTIC_ATTRS
)
require_page_role = (
svg_path.parent.name in {'svg_output', 'svg_final'}
and root.get('data-pptx-layout') is None
)
if _validate_semantic_markers is None:
if has_semantics:
result['warnings'].append(
"Detected Semantic SVG markers, but their validator could "
"not be imported."
)
return
for issue in _validate_semantic_markers(
root,
require_page_role=require_page_role,
):
if issue.severity == 'error':
result['errors'].append(issue.message)
else:
result['warnings'].append(issue.message)
def _get_spec_lock(self, svg_path: Path):
"""Locate and parse spec_lock.md near the SVG. Returns dict or None.
@@ -1348,6 +1484,7 @@ class SVGQualityChecker:
result = self.check_file(str(svg_file), expected_format)
self._print_result(result)
self._check_pptx_structure_contract(dir_path, svg_files)
if self.template_mode and dir_path.is_dir():
self._check_template_contract(dir_path, svg_files)
elif dir_path.is_dir():
@@ -1356,6 +1493,126 @@ class SVGQualityChecker:
return self.results
def _check_pptx_structure_contract(
self,
target_path: Path,
svg_files: List[Path],
) -> None:
"""Validate project-level layout reuse and spec_lock mappings."""
if (
_load_pptx_structure_lock is None
or _parse_template_structure_slides is None
or _template_lock_errors is None
or _TemplateStructureError is None
):
return
project_path = self._resolve_project_path(target_path)
try:
structure_lock = _load_pptx_structure_lock(project_path)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
if (
structure_lock is not None
and structure_lock.mode == 'template'
and structure_lock.template_adherence in {'strict', 'adaptive'}
and _parse_spec_lock is not None
):
lock_path = project_path / 'spec_lock.md'
lock = _parse_spec_lock(lock_path)
page_layouts = lock.get('page_layouts', {})
expected_pages = {f'P{index:02d}' for index in range(1, len(svg_files) + 1)}
missing_pages = sorted(expected_pages - set(page_layouts))
if missing_pages:
self._pptx_structure_issues.append((
'error',
'spec_lock.md template export requires page_layouts '
'rows for every generated page; missing: ' + ', '.join(missing_pages),
))
template_dir = project_path / 'templates'
template_svgs = {path.stem for path in template_dir.glob('*.svg')}
missing_templates = sorted({
value.strip().removesuffix('.svg')
for page, value in page_layouts.items()
if page in expected_pages
and value.strip().removesuffix('.svg') not in template_svgs
})
if missing_templates:
self._pptx_structure_issues.append((
'error',
'spec_lock.md template export references missing '
'template SVG(s): ' + ', '.join(missing_templates),
))
structure_attrs = {
'data-pptx-layer',
'data-pptx-layout',
'data-pptx-layout-name',
'data-pptx-placeholder',
'data-pptx-placeholder-bounds',
'data-pptx-placeholder-idx',
'data-pptx-editable',
}
has_metadata = False
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
if any(
elem.get(attr) is not None
for elem in root.iter()
for attr in structure_attrs
):
has_metadata = True
break
locked_mode = structure_lock.mode if structure_lock is not None else None
structure_locked = locked_mode in {'template', 'preserve'}
if not has_metadata and not structure_locked:
return
try:
if locked_mode == 'preserve':
if _parse_preserve_structure_slides is None:
return
specs = _parse_preserve_structure_slides(svg_files)
else:
specs = _parse_template_structure_slides(svg_files)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
if not structure_locked:
return
self._pptx_structure_issues.extend(
('error', message)
for message in _template_lock_errors(specs, structure_lock)
)
if locked_mode != 'preserve':
return
if (
_load_native_structure_contract is None
or _native_structure_lock_errors is None
):
self._pptx_structure_issues.append((
'error',
'Preserve mode validator could not load the native structure contract.',
))
return
try:
contract = _load_native_structure_contract(structure_lock)
except _TemplateStructureError as exc:
self._pptx_structure_issues.append(('error', str(exc)))
return
self._pptx_structure_issues.extend(
('error', message)
for message in _native_structure_lock_errors(
specs,
structure_lock,
contract,
)
)
def _check_illustration_resource_contract(self, dir_path: Path) -> None:
"""Project-level illustration resource checks."""
project_path = self._resolve_project_path(dir_path)
@@ -1743,11 +2000,12 @@ class SVGQualityChecker:
def _check_template_contract(self, dir_path: Path,
svg_files: List[Path]) -> None:
"""Template-mode-only checks: roster ↔ design_spec consistency and
per-page placeholder hints.
"""Check reusable-template structure, roster, and placeholder hints.
- **Roster mismatch (orphan / missing)** is reported as an *error*: a
stale roster will produce a wrong ``layouts_index.json`` entry.
- **Explicit structure gaps** are errors: every reusable SVG declares
its Layout and at least one direct structure/placeholder element.
- **Placeholder gaps** are reported as *warnings*. Templates may
legitimately omit conventional placeholders or swap them out (e.g.
``{{CLOSING_MESSAGE}}`` instead of ``{{THANK_YOU}}``), and a content
@@ -1758,8 +2016,134 @@ class SVGQualityChecker:
Issues are aggregated and printed in :py:meth:`print_summary` so the
per-file report stays focused on intrinsic SVG validity.
"""
native_contract_path = dir_path / 'native_structure.json'
source_template_path = dir_path / 'source_template.pptx'
native_contract = None
for svg_file in svg_files:
try:
root = ET.parse(svg_file).getroot()
except (OSError, ET.ParseError):
continue
if not root.get('data-pptx-layout'):
self._template_issues.append((
'error',
'explicit_structure_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-layout metadata",
))
if not root.get('data-pptx-layout-name'):
self._template_issues.append((
'error',
'explicit_structure_name_missing',
f"{svg_file.name}: reusable templates require root "
"data-pptx-layout-name metadata",
))
has_structure_child = any(
child.get('data-pptx-layer') in {'master', 'layout'}
or child.get('data-pptx-placeholder')
for child in list(root)
)
if not has_structure_child:
self._template_issues.append((
'error',
'explicit_structure_empty',
f"{svg_file.name}: reusable templates require at least one "
"direct master/layout/placeholder declaration",
))
if native_contract_path.exists() != source_template_path.exists():
self._template_issues.append((
'error',
'native_structure_pair',
"native_structure.json and source_template.pptx must be shipped together",
))
elif native_contract_path.exists():
if (
_PptxStructureLock is None
or _load_native_structure_contract is None
or _TemplateStructureError is None
):
self._template_issues.append((
'error',
'native_structure_validator',
"native structure validator is unavailable",
))
else:
try:
native_contract = _load_native_structure_contract(_PptxStructureLock(
mode='preserve',
source_template=source_template_path,
native_structure=native_contract_path,
))
except _TemplateStructureError as exc:
self._template_issues.append((
'error',
'native_structure_invalid',
str(exc),
))
if (
native_contract is not None
and _PptxLayoutReference is not None
and _parse_preserve_structure_slides is not None
and _template_lock_errors is not None
and _native_structure_lock_errors is not None
):
try:
native_specs = _parse_preserve_structure_slides(svg_files)
native_layouts = {
layout.key: layout for layout in native_contract.layouts
}
references = tuple(
_PptxLayoutReference(
slide_num=spec.slide_num,
layout_key=spec.layout_key,
layout_name=(
native_layouts[spec.layout_key].name
if spec.layout_key in native_layouts
else spec.layout_name
),
)
for spec in native_specs
)
native_lock = _PptxStructureLock(
mode='preserve',
layouts=references,
source_template=source_template_path,
native_structure=native_contract_path,
)
native_errors = _template_lock_errors(native_specs, native_lock)
native_errors.extend(_native_structure_lock_errors(
native_specs,
native_lock,
native_contract,
))
for message in native_errors:
self._template_issues.append((
'error',
'native_structure_svg',
message,
))
except _TemplateStructureError as exc:
self._template_issues.append((
'error',
'native_structure_svg',
str(exc),
))
spec_path = dir_path / 'design_spec.md'
spec_text = spec_path.read_text(encoding='utf-8') if spec_path.exists() else ""
mode_match = re.search(
r'^native_structure_mode:\s*([A-Za-z0-9_-]+)\s*$',
spec_text,
re.MULTILINE,
)
declared_structure_mode = mode_match.group(1).lower() if mode_match else None
if native_contract is None and declared_structure_mode != 'template':
self._template_issues.append((
'error',
'explicit_structure_mode',
"design_spec.md frontmatter must declare "
"native_structure_mode: template",
))
spec_pages = self._extract_spec_roster(spec_text) if spec_text else []
custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {}
@@ -1890,18 +2274,40 @@ class SVGQualityChecker:
that as "skip orphan/missing checks", not as "no pages declared".
"""
# Pass 1: explicit roster section, any roman numeral.
section = re.search(
r"^##\s+[IVX]+\.\s+(?:Page Roster|Page Structure|Pages|Page Types)\b.*?(?=^##\s+|\Z)",
sections = list(re.finditer(
r"^##\s+[IVX]+\.\s+(?:(?:SVG\s+)?Page Roster|Page Structure|Pages|Page Types)\b.*?(?=^##\s+|\Z)",
spec_text,
re.MULTILINE | re.DOTALL | re.IGNORECASE,
))
roster_scope = next(
(
section.group(0)
for section in sections
if re.match(
r"^##\s+[IVX]+\.\s+(?:SVG\s+)?Page Roster\b",
section.group(0),
re.IGNORECASE,
)
),
None,
)
scope = roster_scope or next(
(
section.group(0)
for section in sections
if re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", section.group(0))
),
sections[0].group(0) if sections else None,
)
scope = section.group(0) if section else None
# Pass 2: full document. We *only* trust this scan when the explicit
# roster scan came up empty (no `<stem>.svg` references inside it) —
# otherwise the explicit section's deliberate roster wins over loose
# mentions elsewhere.
if scope and re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", scope):
explicit_scope = bool(
scope and re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", scope)
)
if explicit_scope:
text = scope
else:
text = spec_text
@@ -1913,7 +2319,7 @@ class SVGQualityChecker:
svg_ref_re = re.compile(r"[`\(]([0-9A-Za-z_]+\.svg)[`\)]")
for match in svg_ref_re.finditer(text):
stem = match.group(1)[:-4]
if stem in seen or not re.match(r"^\d", stem):
if stem in seen or (not explicit_scope and not re.match(r"^\d", stem)):
continue
seen.add(stem)
stems.append(stem)
@@ -2034,6 +2440,9 @@ class SVGQualityChecker:
# Illustration strategy aggregation.
self._print_illustration_summary()
# Explicit PowerPoint master/layout structure aggregation.
self._print_pptx_structure_summary()
# Fix suggestions
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
print(f"\n[TIP] Common fixes:")
@@ -2074,6 +2483,14 @@ class SVGQualityChecker:
for _severity, kind, msg in warnings:
print(f" [{kind}] {msg}")
def _print_pptx_structure_summary(self):
"""Print project-level PowerPoint structure contract issues."""
if not self._pptx_structure_issues:
return
print("\n[PPTX STRUCTURE] Master/layout contract checks")
for severity, message in self._pptx_structure_issues:
print(f" [{severity.upper()}] {message}")
def _print_template_summary(self):
"""Aggregate template-mode roster / placeholder issues at the bottom.
@@ -2127,6 +2544,13 @@ class SVGQualityChecker:
for severity, kind, _msg in self._illustration_issues:
self.issue_types[f'illustration_{kind}_{severity}'] += 1
structure_errors = [item for item in self._pptx_structure_issues if item[0] == 'error']
structure_warnings = [item for item in self._pptx_structure_issues if item[0] == 'warning']
self.summary['errors'] += len(structure_errors)
self.summary['warnings'] += len(structure_warnings)
for severity, _msg in self._pptx_structure_issues:
self.issue_types[f'pptx_structure_{severity}'] += 1
def _print_drift_summary(self):
"""Print spec_lock drift aggregation if any was observed.
@@ -17,4 +17,4 @@ from svg_to_pptx import main
configure_utf8_stdio()
if __name__ == '__main__':
main()
raise SystemExit(main())
@@ -10,6 +10,7 @@ from typing import Any
from xml.etree import ElementTree as ET
from .drawingml.utils import SVG_NS
from .semantic_markers import is_static_page_frame
try:
from pptx_animations import ANIMATIONS, TRANSITIONS
@@ -24,8 +25,8 @@ _CHROME_ID_TOKENS = frozenset({
'decoration', 'decorations', 'decor',
'header', 'footer',
'chrome', 'watermark',
'pagenumber', 'pagenum',
'page-number',
'pagenumber', 'pagenum', 'slidenumber', 'slidenum',
'logo', 'nav', 'rule',
})
@@ -73,12 +74,21 @@ def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
if not group_id:
anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}')
continue
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:
chrome = True
elif has_explicit_semantics:
chrome = is_static_page_frame(role, placeholder)
else:
chrome = is_chrome_id(group_id)
targets.append(
GroupTarget(
slide=svg_path.stem,
group_id=group_id,
order=visual_index,
chrome=is_chrome_id(group_id),
chrome=chrome,
)
)
@@ -204,10 +214,12 @@ def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[st
def build_scaffold(project_path: Path) -> dict[str, Any]:
"""Build an editable animation override scaffold from current SVGs.
Chrome groups are omitted exporter auto-detects them as ``none`` via
``is_chrome_id`` at render time, so listing them in the scaffold is pure
noise. A ``defaults`` stub is emitted up front to remind the editor that
deck-wide overrides exist and most pages should inherit them.
Chrome groups are omitted layer/slide-number placeholder semantics are
authoritative, followed by an explicit structural role. ``is_chrome_id``
remains only for marker-free legacy SVGs. Listing static page framing in
the scaffold would be pure noise. A ``defaults`` stub is emitted up front
to remind the editor that deck-wide overrides exist and most pages should
inherit them.
"""
targets_by_slide, _anonymous = scan_project_targets(project_path)
slides: dict[str, Any] = {}
@@ -3,10 +3,14 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from xml.etree import ElementTree as ET
from dataclasses import dataclass, field
if TYPE_CHECKING:
from .theme_colors import ThemeColorSpec
from .theme_fonts import ThemeFontSpec
AffineMatrix = tuple[float, float, float, float, float, float]
IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
@@ -67,6 +71,12 @@ class ConvertContext:
# Optional per-element conversion diagnostics. Shared by child contexts so
# callers can inspect native / skipped / unsupported decisions per slide.
trace_events: list[dict[str, Any]] | None = None
# Optional project theme contract. Matching SVG title/body families emit
# DrawingML +mj/+mn tokens instead of fixed typeface names.
theme_font_spec: ThemeFontSpec | None = None
# Optional project theme-color contract. Exact locked colors are promoted
# to context-safe DrawingML scheme slots while local colors stay concrete.
theme_color_spec: ThemeColorSpec | None = None
def next_id(self) -> int:
"""Allocate the next shape ID."""
@@ -178,6 +188,8 @@ class ConvertContext:
image_scale=self.image_scale,
image_quality=self.image_quality,
trace_events=self.trace_events,
theme_font_spec=self.theme_font_spec,
theme_color_spec=self.theme_color_spec,
)
def sync_from_child(self, child_ctx: ConvertContext) -> None:
@@ -11,6 +11,8 @@ from xml.etree import ElementTree as ET
from resource_paths import icon_search_dirs_for_svg
from .context import ConvertContext, ShapeResult
from .theme_colors import ThemeColorSpec
from .theme_fonts import ThemeFontSpec
from .utils import (
SVG_NS, EMU_PER_PX,
_extract_inheritable_styles, _f, _get_attr, parse_transform_matrix, resolve_url_id,
@@ -25,42 +27,15 @@ from .elements import (
convert_polygon, convert_polyline,
convert_text, convert_image, convert_nested_svg,
)
from ..native_objects import convert_native_object
from ..animation_config import is_chrome_id
from ..native_objects import convert_native_object, native_marker_transform
from ..semantic_markers import is_static_page_frame
class SvgNativeConversionError(RuntimeError):
"""Raised when an SVG cannot be faithfully converted to native DrawingML."""
# ---------------------------------------------------------------------------
# Animation anchor selection
# ---------------------------------------------------------------------------
# Tokens that mark a top-level <g id="..."> as page chrome rather than animated
# content. When any token (after splitting id on '-' and '_') matches, the group
# is excluded from the per-element entrance animation cascade so background,
# header/footer, decorations etc. appear together with the slide instead of
# requiring presenter clicks.
_CHROME_ID_TOKENS = frozenset({
'background', 'bg',
'decoration', 'decorations', 'decor',
'header', 'footer',
'chrome', 'watermark',
'pagenumber', 'pagenum',
'nav', 'logo', 'rule',
})
def _is_chrome_id(elem_id: str | None) -> bool:
if not elem_id:
return False
lower = elem_id.lower()
if lower.replace('-', '').replace('_', '') in _CHROME_ID_TOKENS:
return True
tokens = re.split(r'[-_]', lower)
return any(t in _CHROME_ID_TOKENS for t in tokens if t)
# ---------------------------------------------------------------------------
# Transform & layout helpers
# ---------------------------------------------------------------------------
@@ -166,18 +141,42 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
keep their absolute slide coordinates unchanged.
"""
transform = elem.get('transform', '')
dx, dy, sx, sy, angle_deg = parse_transform(transform)
native_subtree_active = ctx.native_objects_enabled and any(
descendant.get('data-pptx-native')
and descendant.tag.replace(f'{{{SVG_NS}}}', '') != 'metadata'
for descendant in elem.iter()
)
if native_subtree_active:
dx, dy, sx, sy = native_marker_transform(transform)
angle_deg = 0.0
else:
dx, dy, sx, sy, angle_deg = parse_transform(transform)
filter_id = resolve_url_id(elem.get('filter', ''))
style_overrides = _extract_inheritable_styles(elem)
elem_id = elem.get('id')
should_animate_group = ctx.depth == 0 and elem_id and not _is_chrome_id(elem_id)
semantic_role = elem.get('data-pptx-role')
placeholder = elem.get('data-pptx-placeholder')
has_explicit_semantics = (
semantic_role is not None or placeholder is not None
)
is_chrome = (
is_static_page_frame(semantic_role, placeholder)
if has_explicit_semantics
else is_chrome_id(elem_id)
)
should_animate_group = (
ctx.depth == 0
and elem_id
and not is_chrome
and not elem.get('data-pptx-layer')
)
visual_children = [
child for child in elem
if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
]
matrix_supported = bool(transform) and visual_children and all(
matrix_supported = not native_subtree_active and bool(transform) and visual_children and all(
_supports_matrix_transform(child) for child in visual_children
)
# A pure ``rotate(angle [cx cy])`` falls through to the fallback path
@@ -203,7 +202,14 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
style_overrides=style_overrides,
)
else:
child_ctx = ctx.child(dx, dy, sx, sy, filter_id=filter_id, style_overrides=style_overrides)
child_ctx = ctx.child(
ctx.scale_x * dx if native_subtree_active else dx,
ctx.scale_y * dy if native_subtree_active else dy,
sx,
sy,
filter_id=filter_id,
style_overrides=style_overrides,
)
if child_ctx.native_objects_enabled:
native_result = convert_native_object(elem, child_ctx)
@@ -420,7 +426,12 @@ def _background_xml_from_rect(
ctx: ConvertContext,
) -> str:
"""Build native ``p:bg`` XML from a full-slide SVG background rect."""
fill_xml = build_fill_xml(elem, ctx, get_fill_opacity(elem, ctx))
fill_xml = build_fill_xml(
elem,
ctx,
get_fill_opacity(elem, ctx),
usage="background",
)
if not fill_xml or '<a:noFill' in fill_xml:
return ''
return f'<p:bg><p:bgPr>{fill_xml}<a:effectLst/></p:bgPr></p:bg>'
@@ -506,6 +517,16 @@ def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
}
if elem_id:
event['id'] = elem_id
for attr in (
'data-pptx-layer',
'data-pptx-placeholder',
'data-pptx-placeholder-bounds',
'data-pptx-placeholder-idx',
'data-pptx-role',
):
value = elem.get(attr)
if value is not None:
event[attr] = value
event.update(metadata)
ctx.trace_events.append(event)
@@ -573,6 +594,8 @@ def convert_svg_to_slide_shapes(
image_scale: float = 2.0,
image_quality: int = 85,
native_objects: bool = False,
theme_font_spec: ThemeFontSpec | None = None,
theme_color_spec: ThemeColorSpec | None = None,
trace_out: list[dict[str, Any]] | None = None,
) -> tuple[
str,
@@ -600,6 +623,11 @@ 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.
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
locked colors emit DrawingML scheme tokens while local colors stay
fixed.
trace_out: Optional list populated with one per-slide trace dictionary.
Returns:
@@ -677,6 +705,8 @@ def convert_svg_to_slide_shapes(
image_quality=image_quality,
native_objects_enabled=native_objects,
trace_events=trace_events,
theme_font_spec=theme_font_spec,
theme_color_spec=theme_color_spec,
)
shapes: list[str] = []
@@ -705,8 +735,12 @@ def convert_svg_to_slide_shapes(
shapes.append(result.xml)
converted += 1
m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
if m:
fallback_targets.append((int(m.group(1)), tag))
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))
else:
if tag not in _NON_VISUAL_TAGS:
skipped += 1
@@ -732,6 +766,7 @@ def convert_svg_to_slide_shapes(
trace_out.append({
'slide_num': slide_num,
'svg': str(svg_path),
'page_role': root.get('data-pptx-page-role'),
'summary': {
'converted': converted,
'skipped': skipped,
@@ -14,6 +14,8 @@ from xml.etree import ElementTree as ET
from resource_paths import resolve_external_image_reference
from .context import ConvertContext, ShapeResult
from .theme_colors import color_node_xml
from .theme_fonts import theme_font_tokens
from .utils import (
SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT, DASH_PRESETS,
px_to_emu, _f, _get_attr, parse_svg_length,
@@ -555,10 +557,18 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
op = get_fill_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(ctx.defs[grad_id], op)
fill = build_gradient_fill(
ctx.defs[grad_id],
op,
ctx.theme_color_spec,
"fill",
)
elif stroke_val:
color = parse_hex_color(stroke_val)
fill = build_solid_fill(color, op) if color else '<a:noFill/>'
fill = (
build_solid_fill(color, op, ctx.theme_color_spec, "fill")
if color else '<a:noFill/>'
)
else:
fill = '<a:noFill/>'
@@ -1209,12 +1219,18 @@ def _bullet_indent_px(bullet: dict[str, Any], font_size: float) -> float:
return -_bullet_margin_px(bullet, font_size)
def _build_bullet_xml(bullet: dict[str, Any] | None) -> str:
def _build_bullet_xml(
bullet: dict[str, Any] | None,
ctx: ConvertContext | None,
) -> str:
if not bullet:
return ''
fill = bullet.get('fill')
if isinstance(fill, str) and re.fullmatch(r'[0-9A-Fa-f]{6}', fill):
color_xml = f'<a:buClr><a:srgbClr val="{fill.upper()}"/></a:buClr>'
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>'
)
else:
color_xml = '<a:buClrTx/>'
return (
@@ -1229,13 +1245,14 @@ def _paragraph_pr_xml(
font_size: float,
body_xml: str = '',
bullet: dict[str, Any] | None = None,
ctx: ConvertContext | None = None,
) -> str:
attrs = f'algn="{algn}"'
if bullet:
margin = px_to_emu(_bullet_margin_px(bullet, font_size))
indent = px_to_emu(_bullet_indent_px(bullet, font_size))
attrs += f' marL="{margin}" indent="{indent}"'
return f'<a:pPr {attrs}>{body_xml}{_build_bullet_xml(bullet)}</a:pPr>'
return f'<a:pPr {attrs}>{body_xml}{_build_bullet_xml(bullet, ctx)}</a:pPr>'
def _estimate_bullet_line_width(runs: list[dict[str, Any]]) -> float:
@@ -1387,15 +1404,28 @@ def _build_text_fill_xml(
grad_id = resolve_url_id(fill_raw)
if grad_id and ctx and grad_id in ctx.defs:
return build_gradient_fill(ctx.defs[grad_id], opacity)
return build_gradient_fill(
ctx.defs[grad_id],
opacity,
ctx.theme_color_spec,
"text",
)
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alphaMod val="{int(opacity * 100000)}"/>'
return f'<a:solidFill><a:srgbClr val="{fill}">{alpha_xml}</a:srgbClr></a:solidFill>'
theme_spec = ctx.theme_color_spec if ctx is not None else None
return (
'<a:solidFill>'
f'{color_node_xml(fill, theme_spec, "text", alpha_xml)}'
'</a:solidFill>'
)
def _build_text_outline_xml(run: dict[str, Any]) -> str:
def _build_text_outline_xml(
run: dict[str, Any],
ctx: ConvertContext | None,
) -> str:
"""Build DrawingML outline XML for a text run from SVG stroke attributes."""
stroke_raw = run.get('stroke_raw')
if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
@@ -1411,9 +1441,12 @@ def _build_text_outline_xml(run: dict[str, Any]) -> str:
if stroke_opacity is not None and stroke_opacity < 1.0:
alpha_xml = f'<a:alphaMod val="{int(stroke_opacity * 100000)}"/>'
theme_spec = ctx.theme_color_spec if ctx is not None else None
return (
f'<a:ln w="{px_to_emu(stroke_width)}">'
f'<a:solidFill><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:solidFill>'
'<a:solidFill>'
f'{color_node_xml(color, theme_spec, "stroke", alpha_xml)}'
'</a:solidFill>'
'</a:ln>'
)
@@ -1449,11 +1482,14 @@ def _build_run_xml(
spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px)
fonts = parse_font_family(ff) if ff else default_fonts
run_fonts = resolve_text_run_fonts(text, fonts)
run_fonts = theme_font_tokens(
fonts,
ctx.theme_font_spec if ctx is not None else None,
) or resolve_text_run_fonts(text, fonts)
lang = detect_text_lang(text)
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
outline_xml = _build_text_outline_xml(run)
outline_xml = _build_text_outline_xml(run, ctx)
space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else ''
@@ -1711,6 +1747,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
font_size=float(line[0].get('font_size', font_size)) if line else font_size,
body_xml=f'{ln_spc_xml}{spc_bef_xml}',
bullet=bullet,
ctx=ctx,
)
paragraph_xml_chunks.append(
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
@@ -1722,6 +1759,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
algn=algn,
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
bullet=single_bullet,
ctx=ctx,
)
paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
@@ -7,6 +7,7 @@ import re
from xml.etree import ElementTree as ET
from .context import ConvertContext
from .theme_colors import ThemeColorSpec, color_node_xml
from .utils import (
SVG_NS, ANGLE_UNIT, DASH_PRESETS,
px_to_emu, _f, _get_attr, parse_svg_length,
@@ -14,17 +15,28 @@ from .utils import (
)
def build_solid_fill(color: str, opacity: float | None = None) -> str:
def build_solid_fill(
color: str,
opacity: float | None = None,
theme_color_spec: ThemeColorSpec | None = None,
usage: str = "fill",
) -> str:
"""Build <a:solidFill> XML."""
alpha = ''
if opacity is not None and opacity < 1.0:
alpha = f'<a:alpha val="{int(opacity * 100000)}"/>'
return f'<a:solidFill><a:srgbClr val="{color}">{alpha}</a:srgbClr></a:solidFill>'
return (
'<a:solidFill>'
f'{color_node_xml(color, theme_color_spec, usage, alpha)}'
'</a:solidFill>'
)
def build_gradient_fill(
grad_elem: ET.Element,
opacity: float | None = None,
theme_color_spec: ThemeColorSpec | None = None,
usage: str = "fill",
) -> str:
"""Build <a:gradFill> from SVG linearGradient or radialGradient element."""
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
@@ -67,7 +79,9 @@ def build_gradient_fill(
alpha_xml = f'<a:alpha val="{int(effective_opacity * 100000)}"/>'
stops_xml.append(
f'<a:gs pos="{pos}"><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:gs>'
f'<a:gs pos="{pos}">'
f'{color_node_xml(color, theme_color_spec, usage, alpha_xml)}'
'</a:gs>'
)
if not stops_xml:
@@ -112,6 +126,7 @@ def build_fill_xml(
elem: ET.Element,
ctx: ConvertContext,
opacity: float | None = None,
usage: str = "fill",
) -> str:
"""Build fill XML for a shape element, with inherited style support."""
fill = _get_attr(elem, 'fill', ctx)
@@ -126,15 +141,25 @@ def build_fill_xml(
ref_elem = ctx.defs[ref_id]
ref_tag = ref_elem.tag.replace(f'{{{SVG_NS}}}', '')
if ref_tag == 'pattern':
patt_xml = build_pattern_fill(ref_elem, opacity)
patt_xml = build_pattern_fill(
ref_elem,
opacity,
ctx.theme_color_spec,
usage,
)
if patt_xml:
return patt_xml
return '<a:noFill/>'
return build_gradient_fill(ref_elem, opacity)
return build_gradient_fill(
ref_elem,
opacity,
ctx.theme_color_spec,
usage,
)
color = parse_hex_color(fill)
if color:
return build_solid_fill(color, opacity)
return build_solid_fill(color, opacity, ctx.theme_color_spec, usage)
return '<a:noFill/>'
@@ -142,6 +167,8 @@ def build_fill_xml(
def build_pattern_fill(
pattern_elem: ET.Element,
opacity: float | None = None,
theme_color_spec: ThemeColorSpec | None = None,
usage: str = "fill",
) -> str:
"""Build <a:pattFill> from an SVG <pattern> emitted by pptx_to_svg.
@@ -172,11 +199,11 @@ def build_pattern_fill(
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
fg_xml = f'<a:srgbClr val="{fg_hex}">{alpha_xml}</a:srgbClr>'
fg_xml = color_node_xml(fg_hex, theme_color_spec, usage, alpha_xml)
if bg_hex:
bg_xml = f'<a:srgbClr val="{bg_hex}"/>'
bg_xml = color_node_xml(bg_hex, theme_color_spec, usage)
else:
bg_xml = '<a:srgbClr val="FFFFFF"/>'
bg_xml = color_node_xml('FFFFFF', theme_color_spec, usage)
return (
f'<a:pattFill prst="{prst}">'
@@ -396,7 +423,12 @@ def build_stroke_xml(
# Gradient stroke
grad_id = resolve_url_id(stroke)
if grad_id and grad_id in ctx.defs:
grad_fill = build_gradient_fill(ctx.defs[grad_id], opacity)
grad_fill = build_gradient_fill(
ctx.defs[grad_id],
opacity,
ctx.theme_color_spec,
"stroke",
)
return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
# Solid color stroke
@@ -408,8 +440,9 @@ def build_stroke_xml(
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
color_xml = color_node_xml(color, ctx.theme_color_spec, "stroke", alpha_xml)
return f'''<a:ln w="{width_emu}"{cap_attr}>
<a:solidFill><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:solidFill>{dash_xml}{join_xml}{line_ends}
<a:solidFill>{color_xml}</a:solidFill>{dash_xml}{join_xml}{line_ends}
</a:ln>'''
@@ -0,0 +1,276 @@
"""Theme-color contracts shared by SVG conversion and PPTX package assembly."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET
from .utils import parse_hex_color
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
_SRGB_PAIR_RE = re.compile(
r"<a:srgbClr\b(?P<attrs>[^>]*?)(?<!/)>(?P<body>.*?)</a:srgbClr>",
re.DOTALL,
)
_SRGB_EMPTY_RE = re.compile(r"<a:srgbClr\b(?P<attrs>[^>]*)/>")
_VAL_RE = re.compile(r'\bval="(?P<color>[0-9A-Fa-f]{6})"')
_OFFICE_DEFAULTS = {
"dk1": "000000",
"lt1": "FFFFFF",
"dk2": "1F497D",
"lt2": "EEECE1",
"accent1": "4F81BD",
"accent2": "C0504D",
"accent3": "9BBB59",
"accent4": "8064A2",
"accent5": "4BACC6",
"accent6": "F79646",
"hlink": "0000FF",
"folHlink": "800080",
}
_ROLE_SLOTS = {
"bg": "lt1",
"background": "lt1",
"master_bg": "lt1",
"secondary_bg": "lt2",
"bg_secondary": "lt2",
"text": "dk1",
"body_text": "dk1",
"text_secondary": "dk2",
"primary": "accent1",
"accent": "accent2",
"secondary_accent": "accent3",
"border": "accent4",
}
_USAGE_ROLE_ORDER = {
"background": (
"bg", "background", "master_bg", "secondary_bg", "bg_secondary",
"primary", "accent", "secondary_accent",
),
"fill": (
"primary", "accent", "secondary_accent", "bg", "background",
"master_bg", "secondary_bg", "bg_secondary", "text", "body_text",
"text_secondary", "border",
),
"text": (
"text", "body_text", "text_secondary", "primary", "accent",
"secondary_accent",
),
"stroke": (
"border", "primary", "accent", "secondary_accent", "text",
"body_text", "text_secondary",
),
"chart": ("primary", "accent", "secondary_accent"),
}
_BACKGROUND_ROLE_TOKENS = (
"bg", "background", "surface", "paper", "card", "panel", "tint",
)
_TEXT_ROLE_TOKENS = ("text", "ink", "muted")
_STROKE_ROLE_TOKENS = ("border", "grid", "line", "stroke")
_BACKGROUND_USAGES = frozenset({"background", "fill"})
_TEXT_USAGES = frozenset({"text"})
_STROKE_USAGES = frozenset({"stroke", "fill"})
_SEMANTIC_USAGES = frozenset({"fill", "text", "stroke"})
class ThemeColorError(RuntimeError):
"""Raised when a project theme-color contract cannot be loaded or applied."""
@dataclass(frozen=True)
class ThemeColorSpec:
"""PowerPoint color scheme derived from one project's color lock."""
slots: dict[str, str]
roles: dict[str, str]
role_slots: dict[str, str]
extra_roles: tuple[str, ...] = ()
def scheme_for(self, color: str, usage: str) -> str | None:
"""Resolve one concrete color to a safe scheme slot for its usage."""
normalized = parse_hex_color(color)
if normalized is None:
return None
for role in _USAGE_ROLE_ORDER.get(usage, ()):
if self.roles.get(role) == normalized:
slot = self.role_slots[role]
if self.slots.get(slot) == normalized:
return slot
for role in self.extra_roles:
if usage in _extra_role_usages(role) and self.roles.get(role) == normalized:
slot = self.role_slots[role]
if self.slots.get(slot) == normalized:
return slot
return None
def _extra_role_usages(role: str) -> frozenset[str]:
"""Limit extra theme slots to contexts implied by their lock role name."""
normalized = role.lower()
if any(token in normalized for token in _BACKGROUND_ROLE_TOKENS):
return _BACKGROUND_USAGES
if any(token in normalized for token in _TEXT_ROLE_TOKENS):
return _TEXT_USAGES
if any(token in normalized for token in _STROKE_ROLE_TOKENS):
return _STROKE_USAGES
return _SEMANTIC_USAGES
def _color_rows(lock_path: Path) -> dict[str, str]:
rows: dict[str, str] = {}
current_section: str | None = None
try:
lines = lock_path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise ThemeColorError(f"Cannot read {lock_path}: {exc}") from exc
for raw_line in lines:
line = raw_line.strip()
if line.startswith("## "):
current_section = line[3:].strip()
continue
if current_section != "colors":
continue
match = _LOCK_ROW_RE.fullmatch(line)
if not match:
continue
color = parse_hex_color(match.group(2))
if color is not None:
rows[match.group(1)] = color
return rows
def _first_color(roles: dict[str, str], *keys: str, default: str) -> str:
return next((roles[key] for key in keys if key in roles), default)
def load_theme_color_spec(project_path: Path) -> ThemeColorSpec | None:
"""Load a PowerPoint color scheme from ``spec_lock.md`` color rows."""
lock_path = project_path / "spec_lock.md"
if not lock_path.is_file():
return None
roles = _color_rows(lock_path)
if not roles:
return None
extra_roles = tuple(
key
for key, color in roles.items()
if key not in _ROLE_SLOTS and color not in {"000000", "FFFFFF"}
)[:2]
extra_slots = dict(zip(extra_roles, ("accent5", "accent6")))
role_slots = {**_ROLE_SLOTS, **extra_slots}
slots = dict(_OFFICE_DEFAULTS)
slots.update({
"lt1": _first_color(
roles,
"bg", "background", "master_bg",
default=slots["lt1"],
),
"dk1": _first_color(
roles,
"text", "body_text", "primary",
default=slots["dk1"],
),
"lt2": _first_color(
roles,
"secondary_bg", "bg_secondary", "bg", "background", "master_bg",
default=slots["lt2"],
),
"dk2": _first_color(roles, "text_secondary", "text", default=slots["dk2"]),
"accent1": _first_color(roles, "primary", "accent", default=slots["accent1"]),
"accent2": _first_color(
roles,
"accent", "secondary_accent", "primary",
default=slots["accent2"],
),
"accent3": _first_color(roles, "secondary_accent", "accent", default=slots["accent3"]),
"accent4": _first_color(roles, "border", "primary", default=slots["accent4"]),
"hlink": _first_color(roles, "accent", "primary", default=slots["hlink"]),
"folHlink": _first_color(roles, "secondary_accent", "accent", default=slots["folHlink"]),
})
for role, slot in extra_slots.items():
slots[slot] = roles[role]
return ThemeColorSpec(
slots=slots,
roles=roles,
role_slots=role_slots,
extra_roles=extra_roles,
)
def color_node_xml(
color: str,
spec: ThemeColorSpec | None,
usage: str,
inner_xml: str = "",
) -> str:
"""Build an srgbClr or schemeClr node while preserving child transforms."""
normalized = parse_hex_color(color) or color.strip().lstrip("#").upper()
scheme = spec.scheme_for(normalized, usage) if spec is not None else None
if scheme:
return f'<a:schemeClr val="{scheme}">{inner_xml}</a:schemeClr>'
return f'<a:srgbClr val="{normalized}">{inner_xml}</a:srgbClr>'
def _set_scheme_color(parent: ET.Element, slot: str, color: str) -> None:
target = parent.find(f"{{{DML_NS}}}{slot}")
if target is None:
target = ET.SubElement(parent, f"{{{DML_NS}}}{slot}")
for child in list(target):
target.remove(child)
ET.SubElement(target, f"{{{DML_NS}}}srgbClr", {"val": color})
def apply_theme_color_spec(extract_dir: Path, spec: ThemeColorSpec) -> None:
"""Install the locked color scheme into every existing PPTX theme part."""
theme_dir = extract_dir / "ppt" / "theme"
theme_paths = sorted(theme_dir.glob("theme*.xml"))
if not theme_paths:
raise ThemeColorError(f"PPTX package has no theme part under {theme_dir}")
ET.register_namespace("a", DML_NS)
for theme_path in theme_paths:
try:
tree = ET.parse(theme_path)
except (OSError, ET.ParseError) as exc:
raise ThemeColorError(f"Cannot parse {theme_path}: {exc}") from exc
color_scheme = tree.getroot().find(f".//{{{DML_NS}}}clrScheme")
if color_scheme is None:
raise ThemeColorError(f"Theme has no clrScheme: {theme_path}")
color_scheme.set("name", "PPT Master")
for slot, color in spec.slots.items():
_set_scheme_color(color_scheme, slot, color)
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
def rewrite_chart_accent_colors(data: bytes, spec: ThemeColorSpec | None) -> bytes:
"""Promote exact locked accent colors inside native chart XML."""
if spec is None or b"<a:srgbClr" not in data:
return data
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return data
def replacement(match: re.Match[str], *, paired: bool) -> str:
attrs = match.group("attrs")
value_match = _VAL_RE.search(attrs)
if value_match is None:
return match.group(0)
scheme = spec.scheme_for(value_match.group("color"), "chart")
if scheme is None:
return match.group(0)
new_attrs = _VAL_RE.sub(f'val="{scheme}"', attrs, count=1)
if paired:
return f'<a:schemeClr{new_attrs}>{match.group("body")}</a:schemeClr>'
return f'<a:schemeClr{new_attrs}/>'
text = _SRGB_PAIR_RE.sub(lambda match: replacement(match, paired=True), text)
text = _SRGB_EMPTY_RE.sub(lambda match: replacement(match, paired=False), text)
return text.encode("utf-8")
@@ -0,0 +1,153 @@
"""Theme-font contracts shared by SVG conversion and PPTX package assembly."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET
from .utils import parse_font_family
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
class ThemeFontError(RuntimeError):
"""Raised when a project theme-font contract cannot be loaded or applied."""
@dataclass(frozen=True)
class ThemeFontFace:
"""Concrete Latin, East Asian, and complex-script theme faces."""
latin: str
ea: str
cs: str
def matches(self, fonts: dict[str, str]) -> bool:
"""Return whether resolved SVG fonts represent this theme face."""
return fonts.get("latin") == self.latin and fonts.get("ea") == self.ea
@dataclass(frozen=True)
class ThemeFontSpec:
"""Major/minor theme fonts derived from one project's typography lock."""
major: ThemeFontFace
minor: ThemeFontFace
major_family: str
minor_family: str
def _font_face(font_family: str) -> ThemeFontFace:
fonts = parse_font_family(font_family)
return ThemeFontFace(
latin=fonts["latin"],
ea=fonts["ea"],
cs=fonts["latin"],
)
def _typography_rows(lock_path: Path) -> dict[str, str]:
rows: dict[str, str] = {}
current_section: str | None = None
try:
lines = lock_path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise ThemeFontError(f"Cannot read {lock_path}: {exc}") from exc
for raw_line in lines:
line = raw_line.strip()
if line.startswith("## "):
current_section = line[3:].strip()
continue
if current_section != "typography":
continue
match = _LOCK_ROW_RE.fullmatch(line)
if match:
rows[match.group(1)] = match.group(2)
return rows
def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
"""Load major/minor theme fonts from ``spec_lock.md`` typography rows."""
lock_path = project_path / "spec_lock.md"
if not lock_path.is_file():
return None
rows = _typography_rows(lock_path)
default_family = rows.get("font_family")
major_family = rows.get("title_family") or default_family
minor_family = rows.get("body_family") or default_family
if not major_family or not minor_family:
return None
return ThemeFontSpec(
major=_font_face(major_family),
minor=_font_face(minor_family),
major_family=major_family,
minor_family=minor_family,
)
def theme_font_tokens(
fonts: dict[str, str],
spec: ThemeFontSpec | None,
) -> dict[str, str] | None:
"""Return DrawingML major/minor tokens for a locked SVG font face."""
if spec is None:
return None
major_match = spec.major.matches(fonts)
minor_match = spec.minor.matches(fonts)
if major_match and not minor_match:
prefix = "+mj"
elif minor_match:
# When title/body use the same family, minor is the least surprising
# default for ordinary text boxes. Template assembly forces semantic
# title placeholders to the major role after SVG conversion.
prefix = "+mn"
else:
return None
return {
"latin": f"{prefix}-lt",
"ea": f"{prefix}-ea",
"cs": f"{prefix}-cs",
}
def _patch_font_collection(collection: ET.Element, face: ThemeFontFace) -> None:
for tag, value in (("latin", face.latin), ("ea", face.ea), ("cs", face.cs)):
elem = collection.find(f"{{{DML_NS}}}{tag}")
if elem is None:
elem = ET.SubElement(collection, f"{{{DML_NS}}}{tag}")
elem.set("typeface", value)
for supplemental in collection.findall(f"{{{DML_NS}}}font"):
if supplemental.get("script") in _CJK_THEME_SCRIPTS:
supplemental.set("typeface", face.ea)
def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None:
"""Install locked major/minor fonts into every existing PPTX theme part."""
theme_dir = extract_dir / "ppt" / "theme"
theme_paths = sorted(theme_dir.glob("theme*.xml"))
if not theme_paths:
raise ThemeFontError(f"PPTX package has no theme part under {theme_dir}")
ET.register_namespace("a", DML_NS)
for theme_path in theme_paths:
try:
tree = ET.parse(theme_path)
except (OSError, ET.ParseError) as exc:
raise ThemeFontError(f"Cannot parse {theme_path}: {exc}") from exc
font_scheme = tree.getroot().find(f".//{{{DML_NS}}}fontScheme")
if font_scheme is None:
raise ThemeFontError(f"Theme has no fontScheme: {theme_path}")
major = font_scheme.find(f"{{{DML_NS}}}majorFont")
minor = font_scheme.find(f"{{{DML_NS}}}minorFont")
if major is None or minor is None:
raise ThemeFontError(f"Theme has no major/minor font collection: {theme_path}")
font_scheme.set("name", "PPT Master")
_patch_font_collection(major, spec.major)
_patch_font_collection(minor, spec.minor)
tree.write(theme_path, encoding="utf-8", xml_declaration=True)
@@ -18,6 +18,7 @@ from .chart_style import (
_native_chart_chrome_errors,
_native_chart_chrome_warnings,
_native_chart_export_payload,
_validate_chart_companion_boxes,
)
from .chart_xml import _chart_rels_xml, _chart_xml
from .chartex import (
@@ -39,7 +40,9 @@ from .marker_common import (
_bounds,
_load_payload,
_local_tag,
_native_marker_validation_context,
_validate_bounds_inputs,
native_marker_transform,
)
from .table import (
_build_native_table,
@@ -55,6 +58,7 @@ from .workbook import (
__all__ = [
"convert_native_object",
"native_object_marker_warnings",
"native_marker_transform",
"validate_native_object_marker",
"validate_native_object_marker_with_warnings",
]
@@ -164,6 +168,8 @@ def _validate_native_object_marker_payload(
elem: ET.Element,
*,
validate_chrome: bool = True,
ctx: ConvertContext | None = None,
ancestors: tuple[ET.Element, ...] = (),
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
kind = (elem.get("data-pptx-native") or "").strip().lower()
if not kind:
@@ -172,32 +178,52 @@ def _validate_native_object_marker_payload(
raise RuntimeError(f"Unsupported data-pptx-native value: {kind}")
if _local_tag(elem) != "g":
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
transform = elem.get("transform", "")
if transform and any(token in transform for token in ("rotate", "matrix", "skew")):
raise RuntimeError("Native PPTX table/chart markers support translate/scale transforms only")
native_marker_transform(elem.get("transform"))
payload = _load_payload(elem, kind)
_validate_bounds_inputs(elem, payload)
bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors)
off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx)
table_rows = None
if kind == "table":
table_rows, _ = _validate_table_payload(payload)
table_rows, col_count = _validate_table_payload(payload)
if ext_cx < col_count or ext_cy < len(table_rows):
raise RuntimeError(
"Native PPTX table bounds must provide at least one EMU per row and column"
)
else:
_chart_data(payload)
if validate_chrome:
chart_data = _chart_data(payload)
_validate_chart_companion_boxes(
payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
include_title=chart_data["kind"] == "chartex",
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
if validate_chrome and elem.get("data-pptx-native-source") != "pptx":
chrome_errors = _native_chart_chrome_errors(elem, payload)
if chrome_errors:
raise RuntimeError("; ".join(chrome_errors))
return kind, payload, table_rows
def validate_native_object_marker(elem: ET.Element) -> None:
def validate_native_object_marker(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
) -> None:
"""Validate a data-pptx-native marker without mutating the PPTX package."""
_validate_native_object_marker_payload(elem)
_validate_native_object_marker_payload(elem, ancestors=ancestors)
def validate_native_object_marker_with_warnings(elem: ET.Element) -> list[str]:
def validate_native_object_marker_with_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
) -> list[str]:
"""Validate a data-pptx-native marker and return non-fatal warnings."""
kind, payload, table_rows = _validate_native_object_marker_payload(elem)
kind, payload, table_rows = _validate_native_object_marker_payload(
elem,
ancestors=ancestors,
)
if kind == "table" and table_rows is not None:
return _native_table_warnings(elem, table_rows)
if kind == "chart":
@@ -205,9 +231,13 @@ def validate_native_object_marker_with_warnings(elem: ET.Element) -> list[str]:
return []
def native_object_marker_warnings(elem: ET.Element) -> list[str]:
def native_object_marker_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
) -> list[str]:
"""Return non-fatal warnings for a data-pptx-native marker."""
return validate_native_object_marker_with_warnings(elem)
return validate_native_object_marker_with_warnings(elem, ancestors=ancestors)
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
@@ -216,7 +246,11 @@ def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult
if not kind:
return None
kind, payload, _ = _validate_native_object_marker_payload(elem, validate_chrome=False)
kind, payload, _ = _validate_native_object_marker_payload(
elem,
validate_chrome=False,
ctx=ctx,
)
if kind == "table":
return _build_native_table(elem, ctx, payload)
payload, warnings = _native_chart_export_payload(elem, payload)
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any
from .marker_common import (
@@ -9,7 +10,9 @@ from .marker_common import (
_clean_hex,
_compact_key,
_first_present,
_hex_or_none,
_number,
_powerpoint_line_width_emu,
)
@@ -18,8 +21,10 @@ def _chart_number(value: Any) -> int | float:
raise RuntimeError("Native PPTX chart values must be numeric")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Native PPTX chart value is not numeric: {value}") from exc
except (TypeError, ValueError, OverflowError) as exc:
raise RuntimeError("Native PPTX chart value is not numeric") from exc
if not math.isfinite(number):
raise RuntimeError(f"Native PPTX chart value must be finite: {value}")
return int(number) if number.is_integer() else number
@@ -42,6 +47,136 @@ def _data_labels_config(payload: dict[str, Any]) -> dict[str, Any] | None:
return raw
def _data_label_position(value: Any, chart_type: str, grouping: str | None) -> str | None:
"""Normalize and validate a label position for its chart plot."""
if chart_type == "area":
if value is not None:
raise RuntimeError("Native PPTX area data labels do not support label position")
return None
is_stacked = chart_type in {"bar", "column"} and grouping in {
"percentStacked", "stacked",
}
default = "ctr" if is_stacked else (
"outEnd" if chart_type in {"bar", "column"} else "t"
)
if value is None:
return default
aliases = {
"above": "t",
"bestfit": "bestFit",
"center": "ctr",
"inbase": "inBase",
"insidebase": "inBase",
"insideend": "inEnd",
"inend": "inEnd",
"outend": "outEnd",
"outsideend": "outEnd",
}
position = aliases.get(_compact_key(value))
if not position:
raise RuntimeError(
"Native PPTX chart data label position must be one of: "
"above, best_fit, center, inside_base, inside_end, outside_end"
)
if chart_type in {"bar", "column"}:
if position not in {"ctr", "inBase", "inEnd", "outEnd"}:
raise RuntimeError(
"Native PPTX bar/column data label position must be one of: "
"center, inside_base, inside_end, outside_end"
)
if is_stacked and position == "outEnd":
raise RuntimeError(
"Native PPTX stacked bar/column data labels do not support outside_end"
)
elif chart_type == "line" and position not in {"bestFit", "ctr", "t"}:
raise RuntimeError(
"Native PPTX line data label position must be one of: above, best_fit, center"
)
return position
def _chart_data_labels(
payload: dict[str, Any],
chart_type: str,
grouping: str | None,
point_count: int,
) -> dict[str, Any] | None:
config = _data_labels_config(payload)
if config is None:
return None
if chart_type not in {"area", "bar", "column", "line"}:
raise RuntimeError(
f"Native PPTX {chart_type} chart data labels are outside current support"
)
_data_label_position(config.get("position"), chart_type, grouping)
if config.get("color") is not None and _hex_or_none(config["color"]) is None:
raise RuntimeError("Native PPTX chart data_labels.color must be a color")
point_items = _data_label_point_items(
config,
chart_type,
grouping,
point_count,
)
for item in point_items:
if item.get("color") is not None and _hex_or_none(item["color"]) is None:
raise RuntimeError(
"Native PPTX chart data_labels.points color must be a color"
)
colors = _chart_list(
_first_present(
config.get("colors"),
config.get("label_colors"),
config.get("labelColors"),
),
"data_labels.colors",
)
if colors and len(colors) != point_count:
raise RuntimeError("Native PPTX chart data_labels.colors must match point count")
if any(_hex_or_none(color) is None for color in colors):
raise RuntimeError("Native PPTX chart data_labels.colors entries must be colors")
return config
def _data_label_point_items(
config: dict[str, Any],
chart_type: str,
grouping: str | None,
point_count: int,
) -> list[dict[str, Any]]:
"""Normalize selected point labels and validate their plot semantics."""
raw_points = config.get("points")
if raw_points is None:
return []
items: list[dict[str, Any]] = []
seen: set[int] = set()
for item in _chart_list(raw_points, "data_labels.points"):
if isinstance(item, dict):
raw_index = item.get("idx")
data = dict(item)
else:
raw_index = item
data = {}
if isinstance(raw_index, bool):
raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
index_value = _number(raw_index, "data_labels.points idx")
if not index_value.is_integer():
raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
index = int(index_value)
if index < 0 or index >= point_count:
raise RuntimeError("Native PPTX chart data_labels.points idx is outside point range")
if index in seen:
raise RuntimeError("Native PPTX chart data_labels.points idx values must be unique")
_data_label_position(
_first_present(data.get("position"), config.get("position")),
chart_type,
grouping,
)
seen.add(index)
data["idx"] = index
items.append(data)
return items
_CATEGORY_CHART_TYPES = {
"area",
"bar",
@@ -345,12 +480,21 @@ def _category_series(payload: dict[str, Any], categories: list[str]) -> list[dic
item.get("fillOpacity"),
)
if fill_opacity is not None:
fill_opacity = _number(fill_opacity, "series fill_opacity")
if not 0 <= fill_opacity <= 1:
raise RuntimeError(
"Native PPTX chart series fill_opacity must be between 0 and 1"
)
series_item["fill_opacity"] = fill_opacity
line_width = _first_present(
item.get("line_width"),
item.get("lineWidth"),
)
if line_width is not None:
line_width = _number(line_width, "series line_width")
if line_width <= 0:
raise RuntimeError("Native PPTX chart series line_width must be positive")
_powerpoint_line_width_emu(line_width, "series line_width")
series_item["line_width"] = line_width
series.append(series_item)
return series
@@ -398,13 +542,16 @@ def _category_chart_data(
if alias_style == "exploded" or payload.get("exploded"):
raise RuntimeError("Native PPTX exploded pie/doughnut is outside current basic chart support")
grouping = (
_chart_grouping(chart_type, payload, alias_grouping)
if chart_type in {"bar", "column", "line", "area"}
else None
)
return {
"kind": "category",
"type": chart_type,
"categories": categories,
"grouping": _chart_grouping(chart_type, payload, alias_grouping)
if chart_type in {"bar", "column", "line", "area"}
else None,
"grouping": grouping,
"of_pie_type": of_pie_type,
"line_style": line_style,
"radar_marker_style": radar_marker_style,
@@ -418,7 +565,12 @@ def _category_chart_data(
),
True,
),
"data_labels": _data_labels_config(payload),
"data_labels": _chart_data_labels(
payload,
chart_type,
grouping,
len(categories),
),
"series": series,
}
@@ -475,12 +627,20 @@ def _combo_plot_entry(
"Native PPTX combo line plot with series fill_opacity requires area_fill: true"
)
plot_series = fallback_series or _category_series(plot_payload, categories)
grouping = (
_chart_grouping(chart_type, plot_payload, alias_grouping)
if chart_type in {"area", "column", "line"}
else None
)
entry: dict[str, Any] = {
"axis": _combo_axis_name(plot_payload),
"data_labels": _data_labels_config(plot_payload),
"grouping": _chart_grouping(chart_type, plot_payload, alias_grouping)
if chart_type in {"area", "column", "line"}
else None,
"data_labels": _chart_data_labels(
plot_payload,
chart_type,
grouping,
len(categories),
),
"grouping": grouping,
"series": plot_series,
"type": chart_type,
}
@@ -523,7 +683,11 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
previous.get("line_style"),
previous.get("type"),
) if previous else None
if previous is not None and signature == previous_signature:
if (
previous is not None
and signature == previous_signature
and plot.get("data_labels") == previous.get("data_labels")
):
previous["series"].extend(plot["series"])
else:
plots.append(plot)
@@ -645,11 +809,27 @@ def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, A
"values": values,
}
if chart_type == "waterfall":
subtotals = payload.get("subtotals", payload.get("subtotal_indices", []))
data["subtotals"] = [
int(_chart_number(value))
for value in _chart_list(subtotals, "subtotals")
]
raw_subtotals = payload.get(
"subtotals",
payload.get("subtotal_indices", []),
)
subtotals: list[int] = []
seen_subtotals: set[int] = set()
for value in _chart_list(raw_subtotals, "subtotals"):
index = _chart_number(value)
if not isinstance(index, int):
raise RuntimeError("Native PPTX waterfall subtotal indices must be integers")
if index < 0 or index >= len(values):
raise RuntimeError(
"Native PPTX waterfall subtotal index is outside point range"
)
if index in seen_subtotals:
raise RuntimeError(
"Native PPTX waterfall subtotal indices must be unique"
)
seen_subtotals.add(index)
subtotals.append(index)
data["subtotals"] = subtotals
return data
if chart_type == "box_whisker":
@@ -684,6 +864,8 @@ def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, A
def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
if _data_labels_config(payload) is not None:
raise RuntimeError("Native PPTX stock chart data labels are outside current support")
categories = [
_chart_number(item)
for item in _chart_list(payload.get("categories", payload.get("dates", [])), "categories")
@@ -725,6 +907,10 @@ def _xy_chart_data(
chart_type: str,
alias_style: str | None,
) -> dict[str, Any]:
if _data_labels_config(payload) is not None:
raise RuntimeError(
f"Native PPTX {chart_type} chart data labels are outside current support"
)
raw_series = payload.get("series", [])
if not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX XY chart requires non-empty series")
@@ -789,6 +975,13 @@ def _xy_chart_data(
def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _chart_kind(payload)
if (
chart_type not in _CATEGORY_CHART_TYPES | {"combo", "stock"} | _XY_CHART_TYPES
and _data_labels_config(payload) is not None
):
raise RuntimeError(
f"Native PPTX {chart_type} chart data labels are outside current support"
)
if chart_type == "combo":
return _combo_chart_data(payload)
if chart_type in _CHARTEX_CHART_TYPES:
@@ -798,3 +991,25 @@ def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
if chart_type in _XY_CHART_TYPES:
return _xy_chart_data(payload, chart_type, alias_style)
return _category_chart_data(payload, chart_type, alias_grouping, alias_style)
def validate_chart_payload(payload: dict[str, Any]) -> None:
"""Check a native chart payload against the export schema.
Public contract for the pptx_to_svg importer: raises RuntimeError on any
payload the native chart exporter cannot represent.
"""
_chart_data(payload)
def validate_data_label_position(
value: Any,
chart_type: str,
grouping: str | None,
) -> None:
"""Check a data-label position against the export schema.
Public contract for the pptx_to_svg importer: raises RuntimeError when the
position is not representable for the given plot.
"""
_data_label_position(value, chart_type, grouping)
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any
from xml.etree import ElementTree as ET
@@ -26,6 +27,8 @@ from .marker_common import (
_most_common_color,
_number,
_normalized_fallback_text,
_powerpoint_emu,
_powerpoint_emu_value,
_relative_luminance,
_style_attr,
_visible_fallback_texts,
@@ -354,8 +357,10 @@ def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
try:
alpha = float(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
if not math.isfinite(alpha):
raise RuntimeError(f"Native PPTX chart {field_name} must be finite")
if alpha < 0 or alpha > 1:
raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
return f'<a:alpha val="{int(round(alpha * 100000))}"/>'
@@ -476,6 +481,8 @@ def _native_chart_export_payload(
elem: ET.Element,
payload: dict[str, Any],
) -> tuple[dict[str, Any], list[str]]:
if elem.get("data-pptx-native-source") == "pptx":
return payload, []
fallback_texts = set(_visible_fallback_texts(elem))
output = payload
messages: list[str] = []
@@ -705,6 +712,52 @@ def _chart_companion_entries(
return entries
def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
"""Validate and resolve an optional explicit companion text box."""
box_keys = ("x", "y", "width", "height")
provided_box_keys = [key for key in box_keys if key in item]
if provided_box_keys and len(provided_box_keys) != len(box_keys):
raise RuntimeError(
"Native PPTX chart companion text boxes require x/y/width/height together"
)
if not provided_box_keys:
return None
return (
_powerpoint_emu(item["x"], "companion text x"),
_powerpoint_emu(item["y"], "companion text y"),
_powerpoint_emu(item["width"], "companion text width", positive=True),
_powerpoint_emu(item["height"], "companion text height", positive=True),
)
def _validate_chart_companion_boxes(
payload: dict[str, Any],
*,
chart_bounds: tuple[int, int, int, int],
include_title: bool,
include_subtitle_as_caption: bool,
) -> None:
"""Validate companion boxes without allocating shapes or relationships."""
_, chart_off_y, _, chart_ext_cy = chart_bounds
below_index = 0
for item in _chart_companion_entries(
payload,
include_title=include_title,
include_subtitle_as_caption=include_subtitle_as_caption,
):
text = str(item.get("text") or "").strip()
if not text:
continue
if _chart_companion_box(item) is not None:
continue
if str(item.get("role") or "note") != "title":
_powerpoint_emu_value(
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
"companion text y",
)
below_index += 1
def _chart_companion_text_xml(
ctx: ConvertContext,
payload: dict[str, Any],
@@ -742,12 +795,9 @@ def _chart_companion_text_xml(
font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
align = str(item.get("align") or ("ctr" if role == "title" else "l"))
bold = bool(item.get("bold", role == "title"))
has_box = all(_maybe_number(item.get(key)) is not None for key in ("x", "y", "width", "height"))
if has_box:
off_x = px_to_emu(_number(item["x"], "companion text x"))
off_y = px_to_emu(_number(item["y"], "companion text y"))
ext_cx = px_to_emu(_number(item["width"], "companion text width"))
ext_cy = px_to_emu(_number(item["height"], "companion text height"))
explicit_box = _chart_companion_box(item)
if explicit_box is not None:
off_x, off_y, ext_cx, ext_cy = explicit_box
elif role == "title":
off_x = chart_off_x
off_y = chart_off_y
@@ -755,7 +805,10 @@ def _chart_companion_text_xml(
ext_cy = px_to_emu(28)
else:
off_x = chart_off_x
off_y = chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18)
off_y = _powerpoint_emu_value(
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
"companion text y",
)
ext_cx = chart_ext_cx
ext_cy = px_to_emu(16)
below_index += 1
@@ -5,8 +5,14 @@ from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.utils import detect_text_lang, px_to_emu, _xml_escape
from .chart_data import _DEFAULT_CHART_COLORS, _chart_list, _data_labels_config
from ..drawingml.utils import detect_text_lang, _xml_escape
from .chart_data import (
_DEFAULT_CHART_COLORS,
_chart_list,
_data_label_position,
_data_label_point_items,
_data_labels_config,
)
from .chart_style import (
_alpha_xml,
_axis_title_xml,
@@ -33,7 +39,7 @@ from .marker_common import (
_first_present,
_font_size_hpt,
_hex_or_none,
_number,
_powerpoint_line_width_emu,
)
@@ -69,7 +75,9 @@ def _series_color_xml(
alpha_xml = _alpha_xml(fill_opacity, "series fill_opacity")
line_width_xml = ""
if line_width is not None:
line_width_xml = f' w="{max(px_to_emu(_number(line_width, "series line_width")), 1)}"'
line_width_xml = (
f' w="{_powerpoint_line_width_emu(line_width, "series line_width")}"'
)
line_xml = (
f'<a:ln{line_width_xml}><a:solidFill><a:srgbClr val="{clean}"/></a:solidFill></a:ln>'
if line else '<a:ln><a:noFill/></a:ln>'
@@ -82,49 +90,6 @@ def _series_color_xml(
)
def _data_label_position(value: Any, chart_type: str, grouping: str | None) -> str | None:
if chart_type == "area":
if value is not None:
raise RuntimeError("Native PPTX area data labels do not support label position")
return None
is_stacked = chart_type in {"bar", "column"} and grouping in {"percentStacked", "stacked"}
default = "ctr" if is_stacked else ("outEnd" if chart_type in {"bar", "column"} else "t")
if value is None:
return default
aliases = {
"above": "t",
"bestfit": "bestFit",
"center": "ctr",
"inbase": "inBase",
"insidebase": "inBase",
"insideend": "inEnd",
"inend": "inEnd",
"outend": "outEnd",
"outsideend": "outEnd",
}
position = aliases.get(_compact_key(value))
if not position:
raise RuntimeError(
"Native PPTX chart data label position must be one of: "
"above, best_fit, center, inside_base, inside_end, outside_end"
)
if chart_type in {"bar", "column"}:
if position not in {"ctr", "inBase", "inEnd", "outEnd"}:
raise RuntimeError(
"Native PPTX bar/column data label position must be one of: "
"center, inside_base, inside_end, outside_end"
)
if is_stacked and position == "outEnd":
raise RuntimeError(
"Native PPTX stacked bar/column data labels do not support outside_end"
)
elif chart_type == "line" and position not in {"bestFit", "ctr", "t"}:
raise RuntimeError(
"Native PPTX line data label position must be one of: above, best_fit, center"
)
return position
def _data_label_flags_xml(config: dict[str, Any]) -> str:
show_value = _chart_bool(
_first_present(config.get("show_value"), config.get("showValue"), config.get("value")),
@@ -152,35 +117,6 @@ def _data_label_flags_xml(config: dict[str, Any]) -> str:
)
def _data_label_point_items(config: dict[str, Any], point_count: int) -> list[dict[str, Any]]:
raw_points = config.get("points")
if raw_points is None:
return []
items: list[dict[str, Any]] = []
seen: set[int] = set()
for item in _chart_list(raw_points, "data_labels.points"):
if isinstance(item, dict):
raw_index = item.get("idx")
data = dict(item)
else:
raw_index = item
data = {}
if isinstance(raw_index, bool):
raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
index_value = _number(raw_index, "data_labels.points idx")
if not index_value.is_integer():
raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
index = int(index_value)
if index < 0 or index >= point_count:
raise RuntimeError("Native PPTX chart data_labels.points idx is outside point range")
if index in seen:
raise RuntimeError("Native PPTX chart data_labels.points idx values must be unique")
seen.add(index)
data["idx"] = index
items.append(data)
return items
def _data_labels_xml(
config: dict[str, Any] | None,
*,
@@ -193,6 +129,14 @@ def _data_labels_xml(
) -> str:
if config is None:
return ""
show_leader_lines = _chart_bool(
_first_present(
config.get("show_leader_lines"),
config.get("showLeaderLines"),
),
False,
)
leader_lines_xml = f'<c:showLeaderLines val="{_bool_attr(show_leader_lines)}"/>'
position = _data_label_position(config.get("position"), chart_type, grouping)
raw_font_size = _first_present(config.get("font_size"), config.get("fontSize"))
label_font_size = _font_size_hpt(raw_font_size, 12) if raw_font_size is not None else font_size
@@ -210,7 +154,12 @@ def _data_labels_xml(
if num_fmt else ""
)
flags_xml = _data_label_flags_xml(config)
point_items = _data_label_point_items(config, point_count)
point_items = _data_label_point_items(
config,
chart_type,
grouping,
point_count,
)
if point_items:
selected_items = {int(item["idx"]): item for item in point_items}
point_label_xml = ""
@@ -251,7 +200,7 @@ def _data_labels_xml(
f"{_data_label_flags_xml({**config, **item})}"
"</c:dLbl>"
)
return f"<c:dLbls>{point_label_xml}<c:showLeaderLines val=\"0\"/></c:dLbls>"
return f"<c:dLbls>{point_label_xml}{leader_lines_xml}</c:dLbls>"
label_colors = [
_clean_hex(item, "#404040")
@@ -280,11 +229,21 @@ def _data_labels_xml(
f"{num_fmt_xml}{tx_pr_xml}"
f"{position_xml}"
f"{flags_xml}"
'<c:showLeaderLines val="0"/>'
f'{leader_lines_xml}'
"</c:dLbls>"
)
def _series_scoped_data_labels(config: dict[str, Any] | None) -> bool:
"""Return whether point-level overrides require a series ``c:dLbls``."""
if config is None:
return False
return any(
bool(config.get(key))
for key in ("points", "colors", "label_colors", "labelColors")
)
def _chart_color(colors: list[str], index: int) -> str:
if index < len(colors):
return colors[index]
@@ -379,7 +338,9 @@ def _series_xml(
default_color=data_label_color,
default_font_face=data_label_font_face,
)
if chart_type in {"area", "bar", "column", "line"} else ""
if _series_scoped_data_labels(data_labels)
and chart_type in {"area", "bar", "column", "line"}
else ""
)
parts.append(
"<c:ser>"
@@ -569,6 +530,7 @@ def _bar_chart_group_xml(
cat_ax_id: str,
val_ax_id: str,
vary_colors: bool = False,
data_labels_xml: str = "",
) -> str:
bar_dir = "bar" if chart_type == "bar" else "col"
vary_colors_xml = '<c:varyColors val="1"/>' if vary_colors else '<c:varyColors val="0"/>'
@@ -582,6 +544,7 @@ def _bar_chart_group_xml(
f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>'
f"{vary_colors_xml}"
f"{ser_xml}"
f"{data_labels_xml}"
'<c:gapWidth val="150"/>'
f"{overlap_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
@@ -596,12 +559,14 @@ def _line_area_chart_group_xml(
*,
cat_ax_id: str,
val_ax_id: str,
data_labels_xml: str = "",
) -> str:
tag = "lineChart" if chart_type == "line" else "areaChart"
line_tail_xml = '<c:marker val="1"/><c:smooth val="0"/>' if chart_type == "line" else ""
return (
f'<c:{tag}><c:grouping val="{grouping}"/><c:varyColors val="0"/>'
f"{ser_xml}"
f"{data_labels_xml}"
f"{line_tail_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
f"</c:{tag}>"
@@ -708,6 +673,20 @@ def _combo_plot_xml(
start_column=2 + start_index,
start_index=start_index,
)
data_labels = _data_labels_config(plot)
data_labels_xml = (
_data_labels_xml(
data_labels,
chart_type=chart_type,
grouping=grouping,
point_count=len(plot["series"][0]["values"]),
font_size=axis_font_size,
default_color=chart_style.get("text_color"),
default_font_face=chart_style.get("font_face"),
)
if not _series_scoped_data_labels(data_labels)
else ""
)
if chart_type == "column":
parts.append(_bar_chart_group_xml(
chart_type,
@@ -716,6 +695,7 @@ def _combo_plot_xml(
cat_ax_id=cat_ax_id,
val_ax_id=val_ax_id,
vary_colors=any(item.get("point_colors") for item in plot["series"]),
data_labels_xml=data_labels_xml,
))
elif chart_type in {"area", "line"}:
parts.append(_line_area_chart_group_xml(
@@ -724,6 +704,7 @@ def _combo_plot_xml(
ser_xml,
cat_ax_id=cat_ax_id,
val_ax_id=val_ax_id,
data_labels_xml=data_labels_xml,
))
else:
raise RuntimeError("Native PPTX combo plots support column, line, and area only")
@@ -856,6 +837,20 @@ def _chart_plot_xml(
data_label_color=chart_style.get("text_color"),
data_label_font_face=chart_style.get("font_face"),
)
data_labels_xml = (
_data_labels_xml(
chart_data.get("data_labels"),
chart_type=chart_type,
grouping=series_grouping,
point_count=len(series[0]["values"]),
font_size=axis_font_size,
default_color=chart_style.get("text_color"),
default_font_face=chart_style.get("font_face"),
)
if chart_type in {"area", "bar", "column", "line"}
and not _series_scoped_data_labels(chart_data.get("data_labels"))
else ""
)
if chart_type in {"bar", "column"}:
bar_dir = "bar" if chart_type == "bar" else "col"
@@ -886,6 +881,7 @@ def _chart_plot_xml(
f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>'
f"{vary_colors_xml}"
f"{ser_xml}"
f"{data_labels_xml}"
'<c:gapWidth val="150"/>'
f"{overlap_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
@@ -910,6 +906,7 @@ def _chart_plot_xml(
return (
f'<c:{tag}><c:grouping val="{grouping}"/><c:varyColors val="0"/>'
f"{ser_xml}"
f"{data_labels_xml}"
f"{line_tail_xml}"
f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>'
f"</c:{tag}>"
@@ -3,12 +3,15 @@
from __future__ import annotations
import json
import math
import re
from collections.abc import Iterable
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, IDENTITY_MATRIX
from ..drawingml.utils import (
EMU_PER_PX,
FONT_PX_TO_HUNDREDTHS_PT,
ctx_h,
ctx_w,
@@ -16,7 +19,6 @@ from ..drawingml.utils import (
ctx_y,
matrix_multiply,
parse_transform_matrix,
px_to_emu,
transform_point,
)
@@ -34,6 +36,20 @@ CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
_NATIVE_KINDS = {"table", "chart"}
_POWERPOINT_COORD_MIN = -(2**31)
_POWERPOINT_COORD_MAX = 2**31 - 1
_POWERPOINT_LINE_WIDTH_MAX = 20116800
_TEXT_FONT_SIZE_MIN = 100
_TEXT_FONT_SIZE_MAX = 400000
_NATIVE_TRANSFORM_OPERATION_RE = re.compile(r"([A-Za-z]+)\s*\(([^()]*)\)")
_NATIVE_TRANSFORM_SEPARATOR_RE = re.compile(r"\s*,?\s*")
_NATIVE_TRANSFORM_ARGS_RE = re.compile(
r"\s*"
r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)"
r"(?:\s*(?:,\s*|\s+)"
r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?))?"
r"\s*"
)
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_RGB_RE = re.compile(r"^rgba?\(([^)]+)\)$", re.IGNORECASE)
_POINT_RE = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
@@ -101,6 +117,8 @@ def _hex_or_none(value: Any) -> str | None:
value_float = float(part)
except ValueError:
return None
if not math.isfinite(value_float):
return None
channels.append(max(0, min(255, int(round(value_float)))))
return "".join(f"{channel:02X}" for channel in channels)
@@ -164,19 +182,125 @@ def _visible_fallback_texts(elem: ET.Element, *, include_metadata: bool = False)
def _number(value: Any, field_name: str) -> float:
if isinstance(value, bool):
raise RuntimeError(f"Native PPTX object requires numeric {field_name}")
try:
return float(value)
except (TypeError, ValueError) as exc:
number = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise RuntimeError(f"Native PPTX object requires numeric {field_name}") from exc
if not math.isfinite(number):
raise RuntimeError(f"Native PPTX object requires finite numeric {field_name}")
return number
def _maybe_number(value: Any) -> float | None:
if value is None:
if value is None or isinstance(value, bool):
return None
try:
return float(value)
except (TypeError, ValueError):
number = float(value)
except (TypeError, ValueError, OverflowError):
return None
return number if math.isfinite(number) else None
def _powerpoint_emu_value(
emu: int,
field_name: str,
*,
positive: bool = False,
) -> int:
"""Validate an already-resolved EMU value for a PowerPoint coordinate."""
lower_bound = 1 if positive else _POWERPOINT_COORD_MIN
if emu < lower_bound or emu > _POWERPOINT_COORD_MAX:
qualifier = "positive " if positive else ""
raise RuntimeError(
f"Native PPTX object {field_name} must resolve to a {qualifier}"
"32-bit PowerPoint coordinate"
)
return emu
def _powerpoint_emu(value: Any, field_name: str, *, positive: bool = False) -> int:
"""Convert SVG px to an EMU value that PowerPoint can represent."""
number = _number(value, field_name)
scaled = number * EMU_PER_PX
if not math.isfinite(scaled):
raise RuntimeError(f"Native PPTX object {field_name} exceeds PowerPoint coordinates")
return _powerpoint_emu_value(round(scaled), field_name, positive=positive)
def _powerpoint_line_width_emu(value: Any, field_name: str) -> int:
"""Convert SVG px to a legal DrawingML ``ST_LineWidth`` value."""
emu = _powerpoint_emu(value, field_name, positive=True)
if emu > _POWERPOINT_LINE_WIDTH_MAX:
raise RuntimeError(
f"Native PPTX object {field_name} exceeds DrawingML line-width range"
)
return emu
def native_marker_transform(transform: str | None) -> tuple[float, float, float, float]:
"""Return a strict native-marker transform as ``dx, dy, sx, sy``."""
raw = (transform or "").strip()
if not raw:
return 0.0, 0.0, 1.0, 1.0
cursor = 0
operation_count = 0
for match in _NATIVE_TRANSFORM_OPERATION_RE.finditer(raw):
gap = raw[cursor:match.start()]
valid_gap = (
not gap
if operation_count == 0
else _NATIVE_TRANSFORM_SEPARATOR_RE.fullmatch(gap) is not None
)
if not valid_gap:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
name = match.group(1).lower()
args_match = _NATIVE_TRANSFORM_ARGS_RE.fullmatch(match.group(2))
if name not in {"translate", "scale"} or args_match is None:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
values = [float(value) for value in args_match.groups() if value is not None]
if not all(math.isfinite(value) for value in values):
raise RuntimeError("Native PPTX marker transform values must be finite")
operation_count += 1
cursor = match.end()
if operation_count == 0 or raw[cursor:]:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
a, b, c, d, e, f = parse_transform_matrix(raw)
components = (a, b, c, d, e, f)
if not all(math.isfinite(value) for value in components):
raise RuntimeError("Native PPTX marker transform exceeds finite coordinates")
if b != 0.0 or c != 0.0:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
return e, f, a, d
def _native_marker_validation_context(
elem: ET.Element,
ancestors: Iterable[ET.Element] = (),
) -> ConvertContext:
"""Build the scalar context used by native export for one marker path."""
ctx = ConvertContext()
for current in (*ancestors, elem):
dx, dy, sx, sy = native_marker_transform(current.get("transform"))
ctx = ctx.child(
dx=ctx.scale_x * dx,
dy=ctx.scale_y * dy,
sx=sx,
sy=sy,
)
return ctx
def _bbox_union(
@@ -560,8 +684,12 @@ def _relative_luminance(color: str) -> float:
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
"""Return object bounds as DrawingML EMU tuple."""
def _resolved_bounds(
elem: ET.Element,
payload: dict[str, Any],
ctx: ConvertContext,
) -> tuple[float, float, float, float, bool]:
"""Resolve object bounds in SVG px plus whether all bounds were explicit."""
if ctx.use_transform_matrix:
raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
@@ -601,40 +729,61 @@ def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> t
resolved_y = ctx_y(y, ctx)
resolved_w = ctx_w(width, ctx)
resolved_h = ctx_h(height, ctx)
off_x = px_to_emu(resolved_x)
off_y = px_to_emu(resolved_y)
ext_cx = px_to_emu(resolved_w)
ext_cy = px_to_emu(resolved_h)
return off_x, off_y, ext_cx, ext_cy
return resolved_x, resolved_y, resolved_w, resolved_h, explicit_bounds
def _validate_bounds_inputs(elem: ET.Element, payload: dict[str, Any]) -> None:
raw_x = payload.get("x", elem.get("data-pptx-x"))
raw_y = payload.get("y", elem.get("data-pptx-y"))
raw_width = payload.get("width", elem.get("data-pptx-width"))
raw_height = payload.get("height", elem.get("data-pptx-height"))
inferred = None
if any(value is None for value in (raw_x, raw_y, raw_width, raw_height)):
inferred = _inferred_bounds(elem)
if inferred is None:
raise RuntimeError(
"Native PPTX object requires x/y/width/height or visible fallback geometry"
)
width = (
_number(raw_width, "width")
if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
"""Return object bounds as DrawingML EMU tuple."""
x, y, width, height, _ = _resolved_bounds(elem, payload, ctx)
return (
_powerpoint_emu(x, "x"),
_powerpoint_emu(y, "y"),
_powerpoint_emu(width, "width", positive=True),
_powerpoint_emu(height, "height", positive=True),
)
height = (
_number(raw_height, "height")
if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
def _validate_bounds_inputs(
elem: ET.Element,
payload: dict[str, Any],
ctx: ConvertContext,
) -> tuple[int, int, int, int, bool]:
x, y, width, height, explicit_bounds = _resolved_bounds(elem, payload, ctx)
return (
_powerpoint_emu(x, "x"),
_powerpoint_emu(y, "y"),
_powerpoint_emu(width, "width", positive=True),
_powerpoint_emu(height, "height", positive=True),
explicit_bounds,
)
if raw_x is not None:
_number(raw_x, "x")
if raw_y is not None:
_number(raw_y, "y")
if width <= 0 or height <= 0:
raise RuntimeError("Native PPTX object width/height must be positive")
def _validate_payload_xml_strings(value: Any) -> None:
"""Reject JSON strings that cannot be serialized into PPTX XML 1.0 parts."""
if isinstance(value, dict):
for key, item in value.items():
_validate_payload_xml_strings(key)
_validate_payload_xml_strings(item)
return
if isinstance(value, list):
for item in value:
_validate_payload_xml_strings(item)
return
if not isinstance(value, str):
return
for char in value:
codepoint = ord(char)
if (
codepoint in {0x09, 0x0A, 0x0D}
or 0x20 <= codepoint <= 0xD7FF
or 0xE000 <= codepoint <= 0xFFFD
or 0x10000 <= codepoint <= 0x10FFFF
):
continue
raise RuntimeError(
"Native PPTX metadata contains an XML 1.0-invalid character "
f"U+{codepoint:04X}"
)
def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
@@ -656,17 +805,29 @@ def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Native PPTX {kind} metadata is not valid JSON: {exc.msg}") from exc
except (ValueError, RecursionError) as exc:
raise RuntimeError(f"Native PPTX {kind} metadata cannot be decoded") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Native PPTX {kind} metadata must be a JSON object")
_validate_payload_xml_strings(payload)
return payload
def _font_size_hpt(value: Any, default_px: int = 18) -> int:
try:
px = float(value)
except (TypeError, ValueError):
px = float(default_px)
return int(round(px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
def convert(raw: Any) -> int | None:
try:
px = float(raw)
except (TypeError, ValueError, OverflowError):
return None
scaled = px * FONT_PX_TO_HUNDREDTHS_PT
if not math.isfinite(scaled):
return None
size = int(round(scaled / 10.0)) * 10
if not _TEXT_FONT_SIZE_MIN <= size <= _TEXT_FONT_SIZE_MAX:
return None
return size
return convert(value) or convert(default_px) or 1350
def _first_present(*values: Any) -> Any:
@@ -6,19 +6,21 @@ from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import px_to_emu, _xml_escape
from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml
from ..drawingml.utils import _xml_escape
from .chart_style import _font_face_xml
from .marker_common import (
TABLE_URI,
_bool_attr,
_bounds,
_chart_bool,
_clean_hex,
_compact_key,
_first_present,
_font_size_hpt,
_normalized_fallback_text,
_number,
_powerpoint_emu,
_powerpoint_line_width_emu,
_visible_fallback_texts,
)
@@ -26,18 +28,25 @@ from .marker_common import (
def _table_text_run(
text: str,
*,
color: str,
bold: bool,
font_size: int,
color: str | None,
bold: bool | None,
font_size: int | None,
font_face: str | None,
theme_color_spec: ThemeColorSpec | None,
) -> str:
bold_attr = ' b="1"' if bold else ""
size_attr = f' sz="{font_size}"' if font_size is not None else ""
bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else ""
color_xml = (
f'<a:solidFill>{color_node_xml(color, theme_color_spec, "text")}</a:solidFill>'
if color else ""
)
space_attr = ' xml:space="preserve"' if text != text.strip() else ""
return (
f'<a:r><a:rPr lang="en-US" sz="{font_size}"{bold_attr}>'
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
f'<a:r><a:rPr lang="en-US"{size_attr}{bold_attr}>'
f'{color_xml}'
f'{_font_face_xml(font_face)}'
"</a:rPr>"
f"<a:t>{_xml_escape(text)}</a:t></a:r>"
f"<a:t{space_attr}>{_xml_escape(text)}</a:t></a:r>"
)
@@ -65,6 +74,8 @@ _TABLE_TOP_LEVEL_SPAN_KEYS = {
"merges",
"spans",
}
_TABLE_MAX_ROWS = 1000
_TABLE_MAX_COLUMNS = 1000
def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
@@ -102,7 +113,36 @@ def _check_table_spans(payload: dict[str, Any], table_rows: list[list[Any]]) ->
def _grid_is_strict(payload: dict[str, Any]) -> bool:
return bool(payload.get("strict_grid", payload.get("strictGrid", False)))
value = payload.get("strict_grid", payload.get("strictGrid"))
return _table_bool(value, "strict_grid", default=False)
def _table_bool(value: Any, field_name: str, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if value in (0, 1):
return bool(value)
key = _compact_key(value)
if key in {"1", "on", "true", "yes"}:
return True
if key in {"0", "false", "no", "off"}:
return False
raise RuntimeError(f"Native PPTX table {field_name} must be a boolean")
def _table_header_rows(payload: dict[str, Any], row_count: int) -> int:
default = 1 if payload.get("columns") else 0
value = _number(payload.get("header_rows", default), "table header_rows")
if not value.is_integer():
raise RuntimeError("Native PPTX table header_rows must be an integer")
header_rows = int(value)
if not 0 <= header_rows <= row_count:
raise RuntimeError(
"Native PPTX table header_rows must be between zero and the resolved row count"
)
return header_rows
def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]) -> int:
@@ -111,6 +151,8 @@ def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]
col_count = max(len(row) for row in table_rows)
if col_count <= 0:
raise RuntimeError("Native PPTX table requires at least one column")
if len(table_rows) > _TABLE_MAX_ROWS or col_count > _TABLE_MAX_COLUMNS:
raise RuntimeError("Native PPTX table supports at most 1000 rows and columns")
if _grid_is_strict(payload) and any(len(row) != col_count for row in table_rows):
raise RuntimeError("Native PPTX table strict_grid requires every row to have the same length")
@@ -118,27 +160,33 @@ def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]
if column_widths is not None:
if not isinstance(column_widths, list) or len(column_widths) != col_count:
raise RuntimeError("Native PPTX table column_widths must match the resolved column count")
for idx, width in enumerate(column_widths, start=1):
_number(width, f"column_widths[{idx}]")
_table_weights(column_widths, "column_widths")
row_heights = payload.get("row_heights")
if row_heights is not None:
if not isinstance(row_heights, list) or len(row_heights) != len(table_rows):
raise RuntimeError("Native PPTX table row_heights must match the resolved row count")
for idx, height in enumerate(row_heights, start=1):
_number(height, f"row_heights[{idx}]")
_table_weights(row_heights, "row_heights")
return col_count
def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
_table_bool(style.get("band_row"), "style.band_row", default=True)
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
if "bold" in cell_data:
_table_bool(cell_data["bold"], "cell bold", default=False)
for side in ("left", "right", "top", "bottom"):
_table_padding_value(cell_data, style, side)
_table_border_width(cell_data, style)
border_width = _table_border_width(cell_data, style)
if border_width > 0:
_powerpoint_line_width_emu(
border_width,
"table border_width",
)
_table_anchor(cell_data, style)
@@ -146,6 +194,7 @@ def _validate_table_payload(payload: dict[str, Any]) -> tuple[list[list[Any]], i
table_rows = _table_rows(payload)
_check_table_spans(payload, table_rows)
col_count = _validate_table_lengths(payload, table_rows)
_table_header_rows(payload, len(table_rows))
_validate_table_cell_formatting(payload, table_rows)
return table_rows, col_count
@@ -190,19 +239,46 @@ def _weighted_lengths(
*,
field_name: str,
) -> list[int]:
if total < count:
raise RuntimeError(
f"Native PPTX table {field_name} cannot fit {count} positive grid lengths"
)
if weights is None:
base = max(total // count, 1)
values = [base] * count
values[-1] += total - sum(values)
return values
base, remainder = divmod(total, count)
return [base + (1 if idx < remainder else 0) for idx in range(count)]
numeric = [max(_number(weight, field_name), 0.0) for weight in weights]
numeric_total = sum(numeric)
if numeric_total <= 0:
raise RuntimeError(f"Native PPTX table {field_name} values must sum to a positive number")
values = [max(round(total * weight / numeric_total), 1) for weight in numeric]
values[-1] += total - sum(values)
return values
numeric = _table_weights(weights, field_name)
largest = max(numeric)
normalized = [weight / largest for weight in numeric]
normalized_total = sum(normalized)
distributable = total - count
quotas = [distributable * weight / normalized_total for weight in normalized]
extras = [int(quota) for quota in quotas]
remainder = distributable - sum(extras)
if remainder < 0 or remainder > count:
raise RuntimeError(f"Native PPTX table {field_name} allocation overflowed")
order = sorted(
range(count),
key=lambda idx: (quotas[idx] - extras[idx], normalized[idx], -idx),
reverse=True,
)
for idx in order[:remainder]:
extras[idx] += 1
return [extra + 1 for extra in extras]
def _table_weights(weights: list[Any], field_name: str) -> list[float]:
numeric = [
_number(weight, f"{field_name}[{idx}]")
for idx, weight in enumerate(weights, start=1)
]
if any(weight < 0 for weight in numeric):
raise RuntimeError(f"Native PPTX table {field_name} values must be non-negative")
if max(numeric, default=0.0) <= 0:
raise RuntimeError(
f"Native PPTX table {field_name} values must sum to a positive number"
)
return numeric
def _table_padding_value(
@@ -235,7 +311,8 @@ def _table_padding_value(
value = from_source(style)
if value is None:
return None
return max(px_to_emu(max(_number(value, f"table {side} padding"), 0.0)), 0)
pixels = max(_number(value, f"table {side} padding"), 0.0)
return _powerpoint_emu(pixels, f"table {side} padding")
def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
@@ -283,17 +360,21 @@ def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> flo
return _number(1 if width_raw is None else width_raw, "table border_width")
def _table_border_xml(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
def _table_border_xml(
cell_data: dict[str, Any],
style: dict[str, Any],
theme_color_spec: ThemeColorSpec | None,
) -> str:
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
width = _table_border_width(cell_data, style)
if width <= 0:
return ""
color = _clean_hex(color_raw, "#D9DEE7")
line = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
f'<a:solidFill>{color_node_xml(color, theme_color_spec, "stroke")}</a:solidFill>'
'<a:prstDash val="solid"/>'
)
line_width = max(px_to_emu(width), 1)
line_width = _powerpoint_line_width_emu(width, "table border_width")
return "".join(
f'<a:{tag} w="{line_width}">{line}</a:{tag}>'
for tag in ("lnL", "lnR", "lnT", "lnB")
@@ -302,8 +383,8 @@ def _table_border_xml(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
table_rows, col_count = _validate_table_payload(payload)
has_columns = bool(payload.get("columns") or [])
header_rows = int(payload.get("header_rows", 1 if has_columns else 0))
header_rows = _table_header_rows(payload, len(table_rows))
preserve_source_style = elem.get("data-pptx-native-source") == "pptx"
for row in table_rows:
row.extend([""] * (col_count - len(row)))
@@ -345,44 +426,95 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
cells_xml: list[str] = []
for cell in row:
cell_data = _cell_payload(cell)
fill = _clean_hex(
cell_data.get("fill"),
header_fill if is_header else (band_fill if row_idx % 2 == 0 and row_idx else body_fill),
)
color = _clean_hex(cell_data.get("color"), header_text if is_header else body_text)
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
if preserve_source_style:
fill = (
_clean_hex(cell_data.get("fill"), "#FFFFFF")
if cell_data.get("fill") is not None else None
)
color = (
_clean_hex(cell_data.get("color"), "#000000")
if cell_data.get("color") is not None else None
)
align = str(cell_data.get("align") or "l")
else:
fill = _clean_hex(
cell_data.get("fill"),
header_fill if is_header else (
band_fill if row_idx % 2 == 0 and row_idx else body_fill
),
)
color = _clean_hex(
cell_data.get("color"),
header_text if is_header else body_text,
)
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
if align not in {"l", "ctr", "r"}:
align = "l"
text = "" if cell_data.get("text") is None else str(cell_data.get("text"))
bold = bool(cell_data.get("bold", is_header))
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data
else body_font_size
)
if is_header and "font_size" not in cell_data:
cell_font_size = header_font_size
if preserve_source_style:
bold = (
_table_bool(cell_data["bold"], "cell bold", default=False)
if "bold" in cell_data else None
)
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data else None
)
else:
bold = _table_bool(cell_data.get("bold"), "cell bold", default=is_header)
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data
else body_font_size
)
if is_header and "font_size" not in cell_data:
cell_font_size = header_font_size
paragraph_props = f'<a:pPr algn="{align}"/>' if align != "l" else "<a:pPr/>"
tc_pr_attrs = (
f' anchor="{_table_anchor(cell_data, style)}"'
f'{_table_padding_attrs(cell_data, style)}'
anchor_keys = {"valign", "vertical_align"}
anchor_attr = ""
if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style):
anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
tc_pr_attrs = f'{anchor_attr}{_table_padding_attrs(cell_data, style)}'
border_xml = _table_border_xml(
cell_data,
style,
ctx.theme_color_spec,
)
fill_xml = (
'<a:solidFill>'
f'{color_node_xml(fill, ctx.theme_color_spec, "fill")}'
'</a:solidFill>'
if fill else ""
)
text_run_xml = _table_text_run(
text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
theme_color_spec=ctx.theme_color_spec,
)
border_xml = _table_border_xml(cell_data, style)
cells_xml.append(
"<a:tc>"
"<a:txBody><a:bodyPr/><a:lstStyle/>"
f"<a:p>{paragraph_props}"
f"{_table_text_run(text, color=color, bold=bold, font_size=cell_font_size, font_face=font_face)}"
f"{text_run_xml}"
"</a:p></a:txBody>"
f'<a:tcPr{tc_pr_attrs}>{border_xml}<a:solidFill><a:srgbClr val="{fill}"/></a:solidFill></a:tcPr>'
f'<a:tcPr{tc_pr_attrs}>{border_xml}{fill_xml}</a:tcPr>'
"</a:tc>"
)
rows_xml.append(f'<a:tr h="{row_heights[row_idx]}">{"".join(cells_xml)}</a:tr>')
shape_id = ctx.next_id()
first_row = _bool_attr(header_rows > 0)
band_row = _bool_attr(bool(style.get("band_row", True)))
table_style_id = str(style.get("table_style_id") or "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")
band_row = _bool_attr(_table_bool(style.get("band_row"), "style.band_row", default=True))
table_style_id = style.get("table_style_id")
if table_style_id is None and not preserve_source_style:
table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
table_style_xml = (
f'<a:tableStyleId>{_xml_escape(str(table_style_id))}</a:tableStyleId>'
if table_style_id else ""
)
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Table {shape_id}"))
xml = f'''<p:graphicFrame>
<p:nvGraphicFramePr>
@@ -395,7 +527,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
<a:graphicData uri="{TABLE_URI}">
<a:tbl>
<a:tblPr firstRow="{first_row}" bandRow="{band_row}">
<a:tableStyleId>{_xml_escape(table_style_id)}</a:tableStyleId>
{table_style_xml}
</a:tblPr>
<a:tblGrid>{grid_xml}</a:tblGrid>
{''.join(rows_xml)}
@@ -38,7 +38,11 @@ def _xlsx_cell(value: Any, row: int, col: int) -> str:
def _minimal_workbook(rows: list[list[Any]]) -> bytes:
if XlsxWriterWorkbook is not None:
buffer = io.BytesIO()
workbook = XlsxWriterWorkbook(buffer, {"in_memory": True})
workbook = XlsxWriterWorkbook(buffer, {
"in_memory": True,
"strings_to_formulas": False,
"strings_to_urls": False,
})
worksheet = workbook.add_worksheet("Sheet1")
for row_index, row in enumerate(rows):
for col_index, value in enumerate(row):
@@ -50,8 +54,11 @@ def _minimal_workbook(rows: list[list[Any]]) -> bytes:
workbook = OpenpyxlWorkbook()
worksheet = workbook.active
worksheet.title = "Sheet1"
for row in rows:
worksheet.append(row)
for row_index, row in enumerate(rows, start=1):
for col_index, value in enumerate(row, start=1):
cell = worksheet.cell(row=row_index, column=col_index, value=value)
if isinstance(value, str):
cell.data_type = "s"
buffer = io.BytesIO()
workbook.save(buffer)
workbook.close()
@@ -8,6 +8,7 @@ import shutil
import argparse
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
@@ -28,8 +29,19 @@ if __package__ in {None, ''}:
from .dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
from .discovery import find_svg_files, find_notes_files
from .builder import create_pptx_with_native_svg
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
from ..drawingml.theme_fonts import ThemeFontError, load_theme_font_spec
from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
from .slide_xml import TRANSITIONS
from .template_structure import (
TemplateStructureError,
load_native_structure_contract,
load_pptx_structure_lock,
native_structure_lock_errors,
parse_preserve_slides,
parse_template_slides,
template_lock_errors,
)
from ..animation_config import load_animation_config, validate_animation_config
try:
@@ -42,6 +54,23 @@ def _as_dict(value: object) -> dict:
return value if isinstance(value, dict) else {}
def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]]:
"""Return fallback-only native object statuses from SVG inputs."""
fallbacks: list[tuple[str, str, str]] = []
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError):
continue
for elem in root.iter():
status = elem.get('data-pptx-native-status')
if not status or elem.tag.rsplit('}', 1)[-1] == 'metadata':
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
fallbacks.append((svg_path.name, marker_id, status))
return fallbacks
def _recorded_narration_on_click_slides(
ref_files: list[Path],
animation_config: dict | None,
@@ -193,6 +222,22 @@ Recorded narration:
'groups export through their SVG fallback children. When set, '
'the default-flow export is named <project>_<ts>_native_charts.pptx '
'to tell it apart from a plain shape export.')
parser.add_argument(
'--pptx-structure',
choices=['baseline', 'template', 'preserve', 'flat'],
default=None,
help=(
'PPTX structure strategy for native export. When omitted, read '
'spec_lock.md pptx_structure.mode, falling back to baseline. baseline '
'promotes safe repeated background/chrome and extracts conservative '
'semantic page-role layout families plus exact family-wide '
'structurally marked chrome (legacy filenames/ids remain fallbacks); '
'template consumes explicit '
'data-pptx-layout/layer/placeholder metadata to build reusable layouts; '
'preserve is legacy compatibility for imported source packages; '
'flat leaves generated structure slide-local for debugging/comparison.'
),
)
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 '
@@ -289,6 +334,47 @@ Recorded narration:
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
return 1
structure_lock = None
native_structure_contract = None
pptx_structure = args.pptx_structure
if pptx_structure is None:
try:
structure_lock = load_pptx_structure_lock(project_path)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
pptx_structure = structure_lock.mode if structure_lock else 'baseline'
elif pptx_structure == 'preserve':
try:
structure_lock = load_pptx_structure_lock(project_path)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if structure_lock is None or structure_lock.mode != 'preserve':
print(
"Error: --pptx-structure preserve requires a preserve-mode "
"spec_lock.md with source_template/native_structure rows",
file=sys.stderr,
)
return 1
if pptx_structure == 'preserve':
if structure_lock is None:
print("Error: preserve mode requires spec_lock.md", file=sys.stderr)
return 1
try:
native_structure_contract = load_native_structure_contract(structure_lock)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
theme_font_spec = None
theme_color_spec = None
if pptx_structure in {'baseline', 'template'}:
try:
theme_font_spec = load_theme_font_spec(project_path)
theme_color_spec = load_theme_color_spec(project_path)
except (ThemeFontError, ThemeColorError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if args.image_max_dimension < 1:
print("Error: --image-max-dimension must be >= 1", file=sys.stderr)
return 1
@@ -351,6 +437,57 @@ Recorded narration:
print("Error: No SVG files found")
return 1
if (
gen_native
and pptx_structure in {'template', 'preserve'}
and structure_lock is not None
):
try:
template_specs = (
parse_preserve_slides(native_files)
if pptx_structure == 'preserve'
else parse_template_slides(native_files)
)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
lock_errors = template_lock_errors(template_specs, structure_lock)
if lock_errors:
print("Error: PPTX structure does not match spec_lock.md:", file=sys.stderr)
for message in lock_errors:
print(f" {message}", file=sys.stderr)
return 1
if pptx_structure == 'preserve':
if native_structure_contract is None:
print("Error: preserve mode contract is unavailable", file=sys.stderr)
return 1
preserve_errors = native_structure_lock_errors(
template_specs,
structure_lock,
native_structure_contract,
)
if preserve_errors:
print(
"Error: PPTX structure does not match native_structure.json:",
file=sys.stderr,
)
for message in preserve_errors:
print(f" {message}", file=sys.stderr)
return 1
if args.native_objects:
fallbacks = _native_object_fallbacks(native_files)
if fallbacks:
print(
"Warning: --native-objects found fallback-only PPTX objects; "
"they will export through their SVG preview instead of editable objects.",
file=sys.stderr,
)
for filename, marker_id, status in fallbacks[:20]:
print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
if len(fallbacks) > 20:
print(f" ... and {len(fallbacks) - 20} more", file=sys.stderr)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir: Path | None = None
@@ -618,6 +755,10 @@ Recorded narration:
image_scale=args.image_scale,
image_quality=args.image_quality,
native_objects=args.native_objects,
pptx_structure=pptx_structure,
native_structure_contract=native_structure_contract,
theme_font_spec=theme_font_spec,
theme_color_spec=theme_color_spec,
)
success = True
@@ -632,16 +773,20 @@ Recorded narration:
print(f" Output file: {native_path}")
print()
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,
)
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 ---
@@ -682,6 +827,26 @@ Recorded narration:
print(f" [warn] svg_output backup skipped: {exc}")
elif verbose:
print(f" [info] svg_output/ not found, backup skipped")
if pptx_structure == 'preserve' and structure_lock is not None:
try:
preserve_sources = [
project_path / 'spec_lock.md',
structure_lock.source_template,
structure_lock.native_structure,
]
for source in preserve_sources:
if source is None or not source.is_file():
continue
source_path = source.resolve()
relative = source_path.relative_to(project_path.resolve())
destination = backup_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, destination)
if verbose:
print(f" preserve contract backup: {backup_dir}")
except (OSError, ValueError) as exc:
if verbose:
print(f" [warn] preserve contract backup skipped: {exc}")
if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
try:
@@ -114,7 +114,7 @@ def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
<a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="sldImg"/>
<p:ph type="sldImg" idx="2"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
@@ -126,7 +126,7 @@ def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
<a:spLocks noGrp="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="body" idx="1"/>
<p:ph type="body" sz="quarter" idx="3"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
@@ -187,7 +187,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="hdr" sz="quarter"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="2971800" cy="457200"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
<p:sp>
@@ -196,7 +202,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="dt" sz="half" idx="1"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="3884613" y="0"/>
<a:ext cx="2971800" cy="457200"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
<p:sp>
@@ -205,7 +217,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="sldImg" idx="2"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="1143000" y="685800"/>
<a:ext cx="4572000" cy="3429000"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
</p:sp>
<p:sp>
<p:nvSpPr>
@@ -213,7 +231,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="body" sz="quarter" idx="3"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="685800" y="4343400"/>
<a:ext cx="5486400" cy="4114800"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
<p:sp>
@@ -222,7 +246,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="ftr" sz="quarter" idx="4"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="0" y="8685213"/>
<a:ext cx="2971800" cy="457200"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
<p:sp>
@@ -231,7 +261,13 @@ def create_notes_master_xml() -> str:
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="sldNum" sz="quarter" idx="5"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:spPr>
<a:xfrm>
<a:off x="3884613" y="8685213"/>
<a:ext cx="2971800" cy="457200"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
</p:spTree>
@@ -111,6 +111,7 @@ def create_slide_rels_xml(
svg_rid: str,
svg_filename: str,
use_compat_mode: bool = True,
slide_layout_target: str = "../slideLayouts/slideLayout1.xml",
) -> str:
"""Create slide relationship file XML.
@@ -120,17 +121,18 @@ def create_slide_rels_xml(
svg_rid: SVG relationship ID.
svg_filename: SVG filename.
use_compat_mode: Whether to use compatibility mode.
slide_layout_target: Target for the slide's actual layout part.
"""
if use_compat_mode:
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
<Relationship Id="{png_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{png_filename}"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''
else:
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
PPT Master - Semantic SVG Markers
Owns the minimal page and structural-role marker vocabulary used by SVG
authoring, validation, conversion traces, and native PPTX structure
reconstruction. These markers are compiler hints, not a parallel content model.
Usage:
Import from svg_quality_checker.py or svg_to_pptx internals.
Examples:
validate_semantic_markers(root, require_page_role=True)
Dependencies:
None (standard library only)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET
PAGE_ROLE_TO_LAYOUT = {
"cover": "Cover",
"toc": "Agenda",
"section": "Section",
"content": "Content",
"ending": "Closing",
}
STRUCTURAL_ROLES = frozenset({
"background",
"chrome",
"decoration",
"footer",
"header",
"logo",
"page-number",
"watermark",
})
CHROME_ROLE_TO_TOKEN = {
"chrome": "chrome",
"footer": "footer",
"header": "header",
"logo": "logo",
"page-number": "pagenumber",
"watermark": "watermark",
}
ANIMATION_CHROME_ROLES = STRUCTURAL_ROLES
SEMANTIC_ATTRS = frozenset({
"data-pptx-page-role",
"data-pptx-role",
})
_NON_VISUAL_TAGS = frozenset({"defs", "desc", "metadata", "style", "title"})
_MARKER_TOKEN_RE = re.compile(r"^[a-z][a-z0-9-]*$")
@dataclass(frozen=True)
class SemanticMarkerIssue:
"""One semantic-marker validation issue."""
severity: str
message: str
def _local_tag(elem: ET.Element) -> str:
"""Return one element's local tag name."""
return elem.tag.rsplit("}", 1)[-1] if "}" in str(elem.tag) else str(elem.tag)
def _normalized_marker(raw: str | None) -> str | None:
"""Normalize one marker value, preserving absence."""
if raw is None:
return None
return raw.strip().lower()
def chrome_token_from_role(role: str | None) -> str | None:
"""Return the baseline chrome token represented by a semantic role."""
normalized = _normalized_marker(role)
return CHROME_ROLE_TO_TOKEN.get(normalized or "")
def chrome_token_from_markers(
role: str | None,
placeholder: str | None,
) -> str | None:
"""Return chrome behavior from specialized markers before generic roles."""
if _normalized_marker(placeholder) == "slide-number":
return "pagenumber"
return chrome_token_from_role(role)
def is_chrome_role(role: str | None) -> bool:
"""Return whether a semantic role represents non-content page chrome."""
normalized = _normalized_marker(role)
return normalized in ANIMATION_CHROME_ROLES
def is_static_page_frame(
role: str | None,
placeholder: str | None,
) -> bool:
"""Return whether explicit markers identify a static page-frame object."""
return (
_normalized_marker(placeholder) == "slide-number"
or is_chrome_role(role)
)
def page_layout_name_from_svg(svg_path: Path) -> str | None:
"""Return the native baseline Layout name declared by the SVG page role."""
root = ET.parse(svg_path).getroot()
role = _normalized_marker(root.get("data-pptx-page-role"))
return PAGE_ROLE_TO_LAYOUT.get(role or "")
def validate_semantic_markers(
root: ET.Element,
*,
require_page_role: bool = False,
) -> list[SemanticMarkerIssue]:
"""Validate semantic markers without changing SVG rendering semantics."""
issues: list[SemanticMarkerIssue] = []
page_role_raw = root.get("data-pptx-page-role")
page_role = _normalized_marker(page_role_raw)
if page_role_raw is None:
if require_page_role:
issues.append(SemanticMarkerIssue(
"warning",
"page SVG is missing root data-pptx-page-role",
))
elif not page_role:
issues.append(SemanticMarkerIssue(
"error",
"root data-pptx-page-role must not be empty",
))
elif not _MARKER_TOKEN_RE.fullmatch(page_role):
issues.append(SemanticMarkerIssue(
"error",
f"invalid data-pptx-page-role={page_role_raw!r}; use lowercase kebab-case",
))
elif page_role not in PAGE_ROLE_TO_LAYOUT:
issues.append(SemanticMarkerIssue(
"warning",
f"unknown data-pptx-page-role={page_role_raw!r}",
))
elif page_role_raw != page_role:
issues.append(SemanticMarkerIssue(
"warning",
f"data-pptx-page-role should use canonical lowercase value {page_role!r}",
))
id_counts: dict[str, int] = {}
for elem in root.iter():
elem_id = (elem.get("id") or "").strip()
if elem_id:
id_counts[elem_id] = id_counts.get(elem_id, 0) + 1
marked_ids: set[str] = set()
for elem in root.iter():
tag = _local_tag(elem)
elem_id = (elem.get("id") or "").strip()
if elem is not root and elem.get("data-pptx-page-role") is not None:
issues.append(SemanticMarkerIssue(
"error",
f"{elem_id or tag}: data-pptx-page-role belongs on the root <svg> only",
))
role_raw = elem.get("data-pptx-role")
role = _normalized_marker(role_raw)
if role_raw is not None:
if elem is root or tag in _NON_VISUAL_TAGS:
issues.append(SemanticMarkerIssue(
"error",
f"{elem_id or tag}: data-pptx-role belongs on a visual SVG element",
))
if not role:
issues.append(SemanticMarkerIssue(
"error",
f"{elem_id or tag}: data-pptx-role must not be empty",
))
elif not _MARKER_TOKEN_RE.fullmatch(role):
issues.append(SemanticMarkerIssue(
"error",
f"{elem_id or tag}: invalid data-pptx-role={role_raw!r}; "
"use lowercase kebab-case",
))
elif role not in STRUCTURAL_ROLES:
issues.append(SemanticMarkerIssue(
"warning",
f"{elem_id or tag}: unknown data-pptx-role={role_raw!r}",
))
elif role_raw != role:
issues.append(SemanticMarkerIssue(
"warning",
f"{elem_id or tag}: data-pptx-role should use canonical "
f"lowercase value {role!r}",
))
if not elem_id:
issues.append(SemanticMarkerIssue(
"error",
f"<{tag}> with data-pptx-role requires a stable id",
))
else:
marked_ids.add(elem_id)
layer = _normalized_marker(elem.get("data-pptx-layer"))
placeholder = _normalized_marker(elem.get("data-pptx-placeholder"))
if layer:
issues.append(SemanticMarkerIssue(
"warning",
f"{elem_id or tag}: data-pptx-role is redundant when "
"data-pptx-layer already owns structure/animation behavior",
))
if role == "page-number" and placeholder == "slide-number":
issues.append(SemanticMarkerIssue(
"warning",
f"{elem_id or tag}: data-pptx-role='page-number' is redundant; "
"data-pptx-placeholder='slide-number' already owns the behavior",
))
duplicates = sorted(
elem_id for elem_id in marked_ids
if id_counts.get(elem_id, 0) > 1
)
if duplicates:
issues.append(SemanticMarkerIssue(
"error",
"semantic markers require unique ids; duplicate: " + ", ".join(duplicates),
))
return issues
@@ -65,6 +65,7 @@ class SlideRecord:
text_samples: list[str]
text_count: int
shape_count: int
placeholders: list[dict[str, Any]]
page_type: str
svg_file: str
flat_svg_file: str
@@ -87,9 +88,13 @@ def summarize_part_record(
bg_asset = detect_background_asset(root, rels)
image_targets = extract_image_targets(root, rels)
display_name = part_display_name(root, part_path)
layout_type = root.attrib.get("type") if root is not None else None
return {
"path": part_path,
"name": PurePosixPath(part_path).name,
"displayName": display_name,
"layoutType": layout_type,
"svgFile": svg_file,
"parentPath": parent_path,
"themePath": theme_path,
@@ -100,6 +105,7 @@ def summarize_part_record(
"textSamples": extract_text_samples(root),
"textCount": len(root.findall(".//a:t", NS)) if root is not None else 0,
"shapeCount": count_slide_shapes(root),
"drawableShapeCount": count_drawable_shapes(root),
"usedBySlides": used_by_slides,
}
@@ -201,6 +207,40 @@ def parse_xfrm_record(sp: ET.Element) -> dict[str, int] | None:
}
def part_display_name(root: ET.Element | None, part_path: str) -> str:
"""Return the PowerPoint picker name, falling back to the package stem."""
if root is not None:
common_slide = root.find("p:cSld", NS)
if common_slide is not None:
name = (common_slide.attrib.get("name") or "").strip()
if name:
return name
matching_name = (root.attrib.get("matchingName") or "").strip()
if matching_name:
return matching_name
return PurePosixPath(part_path).stem
def placeholder_semantic_role(placeholder_type: str | None) -> str:
"""Map an OOXML placeholder type to the exporter role vocabulary."""
normalized = placeholder_type or "obj"
role_by_type = {
"title": "title",
"ctrTitle": "title",
"body": "body",
"subTitle": "subtitle",
"obj": "object",
"pic": "picture",
"chart": "chart",
"tbl": "table",
"media": "media",
"dt": "date",
"ftr": "footer",
"sldNum": "slide-number",
}
return role_by_type.get(normalized, "other")
def extract_placeholders(root: ET.Element | None) -> list[dict[str, Any]]:
if root is None:
return []
@@ -209,11 +249,16 @@ def extract_placeholders(root: ET.Element | None) -> list[dict[str, Any]]:
ph = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
if ph is None:
continue
non_visual = sp.find("p:nvSpPr/p:cNvPr", NS)
placeholder_type = ph.attrib.get("type")
record: dict[str, Any] = {
"type": ph.attrib.get("type"),
"type": placeholder_type,
"idx": ph.attrib.get("idx"),
"size": ph.attrib.get("sz"),
"orient": ph.attrib.get("orient"),
"semanticRole": placeholder_semantic_role(placeholder_type),
"shapeId": non_visual.attrib.get("id") if non_visual is not None else None,
"shapeName": non_visual.attrib.get("name") if non_visual is not None else None,
"geometry": parse_xfrm_record(sp),
"textSamples": extract_text_samples(sp, limit=2),
}
@@ -224,6 +269,30 @@ def extract_placeholders(root: ET.Element | None) -> list[dict[str, Any]]:
return placeholders
def count_drawable_shapes(root: ET.Element | None) -> int:
"""Count top-level visual shapes that are not placeholder definitions."""
if root is None:
return 0
sp_tree = root.find("p:cSld/p:spTree", NS)
if sp_tree is None:
return 0
visual_tags = {
f"{{{NS['p']}}}sp",
f"{{{NS['p']}}}grpSp",
f"{{{NS['p']}}}graphicFrame",
f"{{{NS['p']}}}pic",
f"{{{NS['p']}}}cxnSp",
}
count = 0
for child in sp_tree:
if child.tag not in visual_tags:
continue
if child.find("p:nvSpPr/p:nvPr/p:ph", NS) is not None:
continue
count += 1
return count
def extract_placeholder_text_style(sp: ET.Element) -> dict[str, Any]:
style: dict[str, Any] = {}
rpr = sp.find(".//a:rPr", NS) or sp.find(".//a:endParaRPr", NS)
@@ -572,6 +641,7 @@ def build_manifest(pptx_path: Path, output_dir: Path) -> dict[str, Any]:
image_targets = extract_image_targets(slide_root, slide_rels)
texts = extract_text_samples(slide_root)
shape_count = count_slide_shapes(slide_root)
placeholders = extract_placeholders(slide_root)
page_type = classify_slide(index, len(slide_parts), texts, len(image_targets), shape_count)
resolved_bg = copied_assets.get(bg_asset, PurePosixPath(bg_asset).name if bg_asset else None)
@@ -623,6 +693,7 @@ def build_manifest(pptx_path: Path, output_dir: Path) -> dict[str, Any]:
text_samples=texts,
text_count=len(texts),
shape_count=shape_count,
placeholders=placeholders,
page_type=page_type,
svg_file=slide_svg_filename(index),
flat_svg_file=slide_svg_filename(index),
@@ -740,6 +811,7 @@ def build_manifest(pptx_path: Path, output_dir: Path) -> dict[str, Any]:
"textSamples": slide.text_samples,
"textCount": slide.text_count,
"shapeCount": slide.shape_count,
"placeholders": slide.placeholders,
"pageType": slide.page_type,
}
for slide in slide_records
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
PPT Master - Native Template Structure Contract
Build a portable master/layout contract from the PPTX template import manifest.
Usage:
Imported by pptx_template_import.py.
Examples:
write_native_structure_bundle(source_pptx, output_dir, manifest)
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
from typing import Any
SCHEMA = "ppt-master.native-structure.v1"
SOURCE_TEMPLATE_NAME = "source_template.pptx"
CONTRACT_NAME = "native_structure.json"
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _copy_source_template(source: Path, destination: Path) -> None:
if source.resolve() == destination.resolve():
return
shutil.copy2(source, destination)
def _has_reusable_structure(manifest: dict[str, Any]) -> bool:
layouts = manifest.get("layouts", [])
masters = manifest.get("masters", [])
slides = manifest.get("slides", [])
used_layouts = [layout for layout in layouts if layout.get("usedBySlides")]
candidate_layouts = used_layouts or layouts
if any(
layout.get("backgroundAsset")
or int(layout.get("drawableShapeCount") or 0) > 0
or any(
placeholder.get("semanticRole")
not in {"date", "footer", "slide-number", "other"}
for placeholder in layout.get("placeholders", [])
)
for layout in candidate_layouts
):
return True
if any(
master.get("backgroundAsset")
or int(master.get("drawableShapeCount") or 0) > 0
or master.get("imageAssets")
for master in masters
):
return True
return any(slide.get("placeholders") for slide in slides)
def build_native_structure(
source_pptx: Path,
manifest: dict[str, Any],
) -> dict[str, Any]:
"""Build the portable source-package structure contract."""
masters = manifest.get("masters", [])
layouts = manifest.get("layouts", [])
slides = manifest.get("slides", [])
master_key_by_path = {
master["path"]: f"master_{index:02d}"
for index, master in enumerate(masters, start=1)
}
layout_key_by_path = {
layout["path"]: f"layout_{index:02d}"
for index, layout in enumerate(layouts, start=1)
}
complete_graph = (
bool(masters and layouts)
and all(
layout.get("parentPath") in master_key_by_path
for layout in layouts
)
and all(
slide.get("layoutPath") in layout_key_by_path
and slide.get("masterPath") in master_key_by_path
for slide in slides
)
)
reusable_structure = _has_reusable_structure(manifest)
recommended_mode = "preserve" if complete_graph and reusable_structure else "template"
reasons: list[str] = []
if not complete_graph:
reasons.append("incomplete-master-layout-graph")
if not reusable_structure:
reasons.append("source-structure-is-minimal")
if complete_graph and reusable_structure:
reasons.append("source-master-layout-contract-is-reusable")
if len(masters) > 1:
reasons.append("source-uses-multiple-masters")
master_contracts = []
for master in masters:
key = master_key_by_path[master["path"]]
master_contracts.append({
"key": key,
"name": master.get("displayName") or master.get("name") or key,
"packagePart": master["path"],
"themePart": master.get("themePath"),
"theme": master.get("theme") or {"colors": {}, "fonts": {}},
"backgroundAsset": master.get("backgroundAsset"),
"imageAssets": master.get("imageAssets", []),
"drawableShapeCount": master.get("drawableShapeCount", 0),
"layoutKeys": [
layout_key_by_path[layout["path"]]
for layout in layouts
if layout.get("parentPath") == master["path"]
],
})
layout_contracts = []
for layout in layouts:
key = layout_key_by_path[layout["path"]]
layout_contracts.append({
"key": key,
"name": layout.get("displayName") or layout.get("name") or key,
"type": layout.get("layoutType"),
"packagePart": layout["path"],
"masterKey": master_key_by_path.get(layout.get("parentPath")),
"backgroundAsset": layout.get("backgroundAsset"),
"imageAssets": layout.get("imageAssets", []),
"drawableShapeCount": layout.get("drawableShapeCount", 0),
"placeholders": layout.get("placeholders", []),
"usedBySlides": layout.get("usedBySlides", []),
"svgFile": layout.get("svgFile"),
})
slide_contracts = []
for slide in slides:
slide_contracts.append({
"index": slide["index"],
"pageType": slide.get("pageType"),
"layoutKey": layout_key_by_path.get(slide.get("layoutPath")),
"masterKey": master_key_by_path.get(slide.get("masterPath")),
"placeholders": slide.get("placeholders", []),
"layeredSvgFile": slide.get("svgFile"),
"flatSvgFile": slide.get("flatSvgFile"),
})
return {
"schema": SCHEMA,
"source": {
"name": source_pptx.name,
"templateFile": SOURCE_TEMPLATE_NAME,
"sha256": _sha256(source_pptx),
},
"slideSize": manifest.get("slideSize", {}),
"strategy": {
"preservationEligible": complete_graph,
"reusableStructureDetected": reusable_structure,
"recommendedMode": recommended_mode,
"recommendationScope": "source-structure-assessment",
"templateOutputMode": "template",
"downstreamTemplateAdherence": "strategist-confirmed-explicit-structure",
"hasMultipleMasters": len(masters) > 1,
"reasonCodes": reasons,
},
"masters": master_contracts,
"layouts": layout_contracts,
"slides": slide_contracts,
}
def write_native_structure_bundle(
source_pptx: Path,
output_dir: Path,
manifest: dict[str, Any],
) -> dict[str, Any]:
"""Copy the source template and write its portable structure contract."""
source_copy = output_dir / SOURCE_TEMPLATE_NAME
_copy_source_template(source_pptx, source_copy)
contract = build_native_structure(source_pptx, manifest)
(output_dir / CONTRACT_NAME).write_text(
json.dumps(contract, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return contract
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-01_cover-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="0" y="0" width="1280" height="8" fill="#C00000" />
<rect x="72" y="118" width="6" height="266" rx="3" fill="#C00000" />
<rect x="72" y="600" width="1136" height="1" fill="#D1D5DB" />
@@ -13,8 +13,8 @@
<image x="862" y="246" width="292" height="102" xlink:href="slogan_red.png" preserveAspectRatio="xMidYMid meet" />
<image x="848" y="360" width="324" height="92" xlink:href="skyline_bg.png" preserveAspectRatio="xMidYMid meet" />
<text x="108" y="202" fill="#111827" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="42" font-weight="700">{{TITLE}}</text>
<text x="108" y="248" fill="#6B7280" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="21">{{SUBTITLE}}</text>
<text x="108" y="202" fill="#111827" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="42" font-weight="700" id="slot-01_cover-title-1" data-pptx-placeholder="title">{{TITLE}}</text>
<text x="108" y="248" fill="#6B7280" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="21" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">{{SUBTITLE}}</text>
<rect x="108" y="282" width="144" height="6" rx="3" fill="#C00000" />
<text x="108" y="352" fill="#2B2F33" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="18">{{AUTHOR}}</text>
<text x="108" y="386" fill="#6B7280" font-family="Arial, sans-serif" font-size="15">China Telecom Digital Transformation Template</text>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="72" y="84" width="112" height="8" rx="4" fill="#C00000" />
<image x="1016" y="44" width="192" height="52" xlink:href="logo.png" preserveAspectRatio="xMaxYMid meet" />
<text x="96" y="244" fill="#C00000" fill-opacity="0.14" font-family="Arial, sans-serif" font-size="176" font-weight="700">{{CHAPTER_NUM}}</text>
<text x="96" y="316" fill="#111827" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="42" font-weight="700">{{CHAPTER_TITLE}}</text>
<text x="96" y="316" fill="#111827" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="42" font-weight="700" id="slot-02_chapter-title-1" data-pptx-placeholder="title">{{CHAPTER_TITLE}}</text>
<text x="96" y="364" fill="#6B7280" font-family="Arial, sans-serif" font-size="18">DIGITAL TRANSFORMATION / CHINA TELECOM</text>
<rect x="96" y="398" width="164" height="6" rx="3" fill="#C00000" />

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="0" y="32" width="158" height="64" rx="24" fill="#C00000" />
<rect x="114" y="32" width="1166" height="64" fill="#C00000" />

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -1,22 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="0" y="32" width="158" height="64" rx="24" fill="#C00000" />
<rect x="114" y="32" width="1166" height="64" fill="#C00000" />
<rect x="206" y="39" width="1074" height="50" rx="22" fill="#D9D9D9" />
<text x="40" y="73" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="22" font-weight="700">{{SECTION_NAME}}</text>
<text x="228" y="72" fill="#2B2F33" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="700">{{PAGE_TITLE}}</text>
<text x="228" y="72" fill="#2B2F33" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="700" id="slot-03_content-title-1" data-pptx-placeholder="title">{{PAGE_TITLE}}</text>
<image x="1032" y="38" width="176" height="48" xlink:href="logo.png" preserveAspectRatio="xMidYMid meet" />
<rect x="72" y="132" width="1136" height="486" rx="24" fill="#FFFFFF" stroke="#D1D5DB" stroke-width="2" stroke-dasharray="8 8" />
<text x="640" y="386" text-anchor="middle" fill="#9CA3AF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="20">{{CONTENT_AREA}}</text>
<text x="640" y="386" text-anchor="middle" fill="#9CA3AF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="20" id="slot-03_content-body-2" data-pptx-placeholder="body">{{CONTENT_AREA}}</text>
<rect x="1078" y="552" width="72" height="4" rx="2" fill="#C00000" />
<rect x="1042" y="572" width="132" height="4" rx="2" fill="#C00000" fill-opacity="0.24" />
<rect x="1072" y="590" width="102" height="4" rx="2" fill="#E55B5B" fill-opacity="0.36" />
<line x1="72" y1="650" x2="1208" y2="650" stroke="#D1D5DB" stroke-width="1" />
<text x="72" y="676" fill="#6B7280" font-family="Arial, sans-serif" font-size="13">{{SOURCE}}</text>
<text x="1208" y="676" text-anchor="end" fill="#6B7280" font-family="Arial, sans-serif" font-size="13">{{PAGE_NUM}}</text>
<text x="1208" y="676" text-anchor="end" fill="#6B7280" font-family="Arial, sans-serif" font-size="13" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<image x="72" y="34" width="214" height="58" xlink:href="logo.png" />
<text x="96" y="274" fill="#111827" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="40" font-weight="700">{{THANK_YOU}}</text>
@@ -14,5 +14,5 @@
<image x="872" y="356" width="304" height="84" xlink:href="skyline_bg.png" preserveAspectRatio="xMidYMid meet" />
<image x="0" y="554" width="1280" height="166" xlink:href="footer_ribbon.png" preserveAspectRatio="xMidYMid slice" />
<text x="1208" y="676" text-anchor="end" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13">{{PAGE_NUM}}</text>
<text x="1208" y="676" text-anchor="end" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13" id="slot-04_ending-slide_number-1" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,6 +1,7 @@
---
deck_id: 中国电信
kind: deck
native_structure_mode: template
summary: China Telecom related briefings, 政企数字化方案, 转型规划, 内部汇报.
canvas_format: ppt169
page_count: 5
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<!-- 模板: 封面页 - 中国电建经典风格 -->
<defs>
@@ -27,10 +27,10 @@
</defs>
<!-- 背景 -->
<rect x="0" y="0" width="1280" height="720" fill="url(#coverBgGrad)" />
<rect x="0" y="0" width="1280" height="720" fill="url(#coverBgGrad)" id="layout-01_cover-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 工程感斜线纹理 -->
<rect x="0" y="0" width="1280" height="720" fill="url(#diagLines)" />
<rect x="0" y="0" width="1280" height="720" fill="url(#diagLines)" id="full-canvas-01_cover-1"/>
<!-- 装饰元素 - 几何图案 -->
<circle cx="1150" cy="150" r="250" fill="#FFFFFF" fill-opacity="0.02" />
@@ -60,12 +60,12 @@
<rect x="0" y="280" width="10" height="160" fill="#C41E3A" />
<!-- 主标题 -->
<text x="60" y="350" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="48" font-weight="bold" fill="#FFFFFF">
<text x="60" y="350" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="48" font-weight="bold" fill="#FFFFFF" id="slot-01_cover-title-1" data-pptx-placeholder="title">
{{TITLE}}
</text>
<!-- 副标题 -->
<text x="60" y="410" font-family="Microsoft YaHei, Arial, sans-serif" font-size="24" fill="#4A90D9">
<text x="60" y="410" font-family="Microsoft YaHei, Arial, sans-serif" font-size="24" fill="#4A90D9" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">
{{SUBTITLE}}
</text>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<!-- 模板: 章节页 - 中国电建经典风格 -->
<defs>
@@ -21,10 +21,10 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="url(#gradChapter)"/>
<rect width="1280" height="720" fill="url(#gradChapter)" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 工程感斜线纹理 -->
<rect width="1280" height="720" fill="url(#diagLines)"/>
<rect width="1280" height="720" fill="url(#diagLines)" id="full-canvas-02_chapter-1"/>
<!-- 装饰元素 - 几何图案 -->
<circle cx="1200" cy="100" r="300" fill="#FFFFFF" fill-opacity="0.02"/>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<!-- 模板: 目录页 - 中国电建经典风格 -->
<defs>
@@ -16,7 +16,7 @@
</defs>
<!-- 白色背景 -->
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部渐变装饰线 -->
<rect x="0" y="0" width="1280" height="6" fill="url(#topGrad)"/>
@@ -104,7 +104,7 @@
<rect x="0" y="716" width="1280" height="4" fill="#00418D"/>
<!-- 页码 -->
<text x="640" y="695" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#718096">{{PAGE_NUM}}</text>
<text x="640" y="695" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#718096" id="slot-02_toc-slide_number-1" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
<!-- 企业标识 -->
<text x="1220" y="695" text-anchor="end" font-family="Arial, sans-serif" font-size="12" fill="#718096">POWERCHINA</text>

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<!-- 模板: 内容页 - 中国电建经典风格 -->
<defs>
@@ -16,7 +16,7 @@
</defs>
<!-- 白色背景 -->
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部渐变装饰线 -->
<rect x="0" y="0" width="1280" height="6" fill="url(#topGrad)" />
@@ -27,7 +27,7 @@
<text x="85" y="65" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#FFFFFF">{{CHAPTER_NUM}}</text>
<!-- 页面标题 -->
<text x="130" y="65" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="28" font-weight="bold" fill="#1A1A1A">{{PAGE_TITLE}}</text>
<text x="130" y="65" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="28" font-weight="bold" fill="#1A1A1A" id="slot-03_content-title-1" data-pptx-placeholder="title">{{PAGE_TITLE}}</text>
<!-- 右上角企业标识 -->
<text x="1220" y="50" text-anchor="end" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#00418D" fill-opacity="0.6">POWERCHINA</text>
@@ -38,7 +38,7 @@
<!-- 内容区域占位 -->
<rect x="60" y="110" width="1160" height="550" fill="none" stroke="#E4E7EB" stroke-width="2" stroke-dasharray="8,8" />
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="18" font-family="Microsoft YaHei, sans-serif">
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="18" font-family="Microsoft YaHei, sans-serif" id="slot-03_content-body-2" data-pptx-placeholder="body">
{{CONTENT_AREA}}
</text>
<text x="640" y="400" text-anchor="middle" fill="#CBD5E1" font-size="14" font-family="Microsoft YaHei, sans-serif">
@@ -49,7 +49,7 @@
<rect x="0" y="716" width="1280" height="4" fill="#00418D" />
<!-- 页码 -->
<text x="640" y="695" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#718096">{{PAGE_NUM}}</text>
<text x="640" y="695" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#718096" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
<!-- 底部企业名称 -->
<text x="1220" y="695" text-anchor="end" font-family="Arial, sans-serif" font-size="12" fill="#718096" fill-opacity="0.6">POWERCHINA</text>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<!-- 模板: 结束页 - 中国电建经典风格 -->
<defs>
@@ -21,10 +21,10 @@
</defs>
<!-- 背景 -->
<rect x="0" y="0" width="1280" height="720" fill="url(#endBgGrad)" />
<rect x="0" y="0" width="1280" height="720" fill="url(#endBgGrad)" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 工程感斜线纹理 -->
<rect x="0" y="0" width="1280" height="720" fill="url(#diagLines)" />
<rect x="0" y="0" width="1280" height="720" fill="url(#diagLines)" id="full-canvas-04_ending-1"/>
<!-- 装饰元素 - 几何图案 -->
<circle cx="100" cy="120" r="200" fill="#FFFFFF" fill-opacity="0.02" />

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -1,6 +1,7 @@
---
deck_id: 中国电建
kind: deck
native_structure_mode: template
summary: Engineering project reports, technical proposal presentations, business negotiations, corporate promotion, annual summaries.
canvas_format: ppt169
page_count: 5
@@ -1,30 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<!-- 模板: 封面页 - 中汽研风格 -->
<rect id="layout-01_cover-marker" data-pptx-layer="layout"
data-pptx-editable="false" x="0" y="0" width="1" height="1"
fill="#FFFFFF" fill-opacity="0" />
<!-- 背景层 - 使用封面背景图(AI生成或用户提供) -->
<image href="../images/{{COVER_BG_IMAGE}}" x="0" y="0" width="1280" height="720"
preserveAspectRatio="xMidYMid slice" />
preserveAspectRatio="xMidYMid slice" id="semantic-01_cover-background-1" data-pptx-role="background" />
<!-- 半透明遮罩层 - 增强文字可读性 -->
<rect x="0" y="0" width="1280" height="720" fill="#001F4D" fill-opacity="0.4" />
<rect id="cover-overlay" x="0" y="0" width="1280" height="720"
fill="#001F4D" fill-opacity="0.4" transform="translate(0 0)" />
<!-- 顶部装饰线 -->
<rect x="0" y="0" width="1280" height="4" fill="#FFFFFF" fill-opacity="0.3" />
<!-- 大型Logo - 居中偏上 -->
<image href="../images/{{LOGO_LARGE}}" x="344" y="120" width="592" height="238"
preserveAspectRatio="xMidYMid meet" />
preserveAspectRatio="xMidYMid meet" id="semantic-01_cover-logo-1" data-pptx-role="logo" />
<!-- 主标题区域 -->
<text x="640" y="440" text-anchor="middle" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="48"
font-weight="bold" fill="#FFFFFF">
font-weight="bold" fill="#FFFFFF" id="slot-01_cover-title-1" data-pptx-placeholder="title">
{{TITLE}}
</text>
<!-- 副标题 -->
<text x="640" y="500" text-anchor="middle" font-family="Microsoft YaHei, Arial, sans-serif" font-size="26"
fill="#FFFFFF" fill-opacity="0.85">
fill="#FFFFFF" fill-opacity="0.85" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">
{{SUBTITLE}}
</text>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<!-- 模板: 章节页 - 中汽研风格 -->
<defs>
@@ -11,7 +11,7 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="url(#gradChapter)"/>
<rect width="1280" height="720" fill="url(#gradChapter)" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 装饰元素 - 几何图案 -->
<circle cx="1200" cy="100" r="200" fill="#FFFFFF" fill-opacity="0.03"/>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<!-- 模板: 目录页 - 中汽研风格 -->
<!-- 白色背景 -->
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部深蓝色装饰线 -->
<rect x="0" y="0" width="1280" height="4" fill="#004098"/>
@@ -12,7 +12,7 @@
<rect x="60" y="30" width="50" height="50" fill="#004098"/>
<text x="85" y="65" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#FFFFFF"></text>
<text x="130" y="65" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="28" font-weight="bold" fill="#333333">目 录</text>
<image href="../images/{{LOGO_HEADER}}" x="1107" y="30" width="113" height="50" preserveAspectRatio="xMidYMid meet"/>
<image href="../images/{{LOGO_HEADER}}" x="1107" y="30" width="113" height="50" preserveAspectRatio="xMidYMid meet" id="semantic-02_toc-logo-1" data-pptx-role="logo"/>
<!-- 左侧装饰竖线 -->
<rect x="300" y="140" width="4" height="480" fill="#004098"/>
@@ -68,6 +68,6 @@
<rect x="0" y="716" width="1280" height="4" fill="#004098"/>
<!-- 页码 -->
<text x="1220" y="705" text-anchor="end" font-family="Arial, sans-serif" font-size="14" fill="#999999">{{PAGE_NUM}}</text>
<text x="1220" y="705" text-anchor="end" font-family="Arial, sans-serif" font-size="14" fill="#999999" id="slot-02_toc-slide_number-1" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<!-- 模板: 内容页 - 中汽研风格 -->
<!-- 白色背景 -->
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" />
<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部深蓝色装饰线 -->
<rect x="0" y="0" width="1280" height="4" fill="#004098" />
@@ -16,11 +16,11 @@
<!-- 页面标题 -->
<text x="130" y="65" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="28" font-weight="bold"
fill="#333333">{{PAGE_TITLE}}</text>
fill="#333333" id="slot-03_content-title-1" data-pptx-placeholder="title">{{PAGE_TITLE}}</text>
<!-- 右上角Logo -->
<image href="../images/{{LOGO_HEADER}}" x="1107" y="30" width="113" height="50"
preserveAspectRatio="xMidYMid meet" />
preserveAspectRatio="xMidYMid meet" id="semantic-03_content-logo-1" data-pptx-role="logo" />
<!-- 内容区域 - 灵活布局,由 Executor 根据实际内容自由设计 -->
<!-- 可使用的布局模式:
@@ -34,7 +34,7 @@
-->
<rect x="60" y="100" width="1160" height="570" fill="none" stroke="#E0E0E0" stroke-width="2"
stroke-dasharray="8,8" />
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="18" font-family="Microsoft YaHei, sans-serif">
<text x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="18" font-family="Microsoft YaHei, sans-serif" id="slot-03_content-body-2" data-pptx-placeholder="body">
{{CONTENT_AREA}}
</text>
<text x="640" y="400" text-anchor="middle" fill="#CBD5E1" font-size="14" font-family="Microsoft YaHei, sans-serif">
@@ -52,6 +52,6 @@
<!-- 页码 -->
<text x="1220" y="705" text-anchor="end" font-family="Arial, sans-serif" font-size="14"
fill="#999999">{{PAGE_NUM}}</text>
fill="#999999" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<!-- 模板: 结束页 - 中汽研风格 -->
<!-- 深蓝色背景 -->
<rect x="0" y="0" width="1280" height="720" fill="#004098" />
<rect x="0" y="0" width="1280" height="720" fill="#004098" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 装饰元素 - 几何图案(低透明度圆形) -->
<circle cx="100" cy="100" r="200" fill="#FFFFFF" fill-opacity="0.03" />
@@ -23,7 +23,7 @@
<!-- 大型Logo - 居中 -->
<image href="../images/{{LOGO_LARGE}}" x="344" y="160" width="592" height="238"
preserveAspectRatio="xMidYMid meet" />
preserveAspectRatio="xMidYMid meet" id="semantic-04_ending-logo-1" data-pptx-role="logo" />
<!-- 感谢语(中文) -->
<text x="640" y="480" text-anchor="middle" font-family="Microsoft YaHei, SimHei, sans-serif" font-size="56"

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,6 +1,7 @@
---
deck_id: 中汽研
kind: deck
native_structure_mode: template
summary: Product certification display, evaluation presentations, technology promotion, business visits.
canvas_format: ppt169
page_count: 5
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#8F0F1B"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<rect width="1280" height="720" fill="#8F0F1B" id="layout-01_cover-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<image href="cover_bg.png" x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
<rect width="1280" height="720" fill="#8F0F1B" fill-opacity="0.18"/>
<rect width="1280" height="720" fill="#8F0F1B" fill-opacity="0.18"
id="cover-overlay" transform="translate(0 0)"/>
<rect x="196" y="182" width="888" height="238" rx="10" fill="#5E0913" fill-opacity="0.18" stroke="#FFFFFF" stroke-width="1.2" stroke-opacity="0.16"/>
@@ -11,10 +12,10 @@
<line x1="210" y1="408" x2="380" y2="408" stroke="#E26A74" stroke-width="2" stroke-opacity="0.72"/>
<line x1="900" y1="408" x2="1070" y2="408" stroke="#E26A74" stroke-width="2" stroke-opacity="0.72"/>
<text x="640" y="254" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="54" font-weight="700">
<text x="640" y="254" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="54" font-weight="700" id="slot-01_cover-title-1" data-pptx-placeholder="title">
{{TITLE}}
</text>
<text x="640" y="334" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="700" letter-spacing="2">
<text x="640" y="334" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="700" letter-spacing="2" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">
{{SUBTITLE}}
</text>
<text x="640" y="468" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="18">

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#A61120"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<rect width="1280" height="720" fill="#A61120" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<circle cx="1010" cy="344" r="286" fill="none" stroke="#FFFFFF" stroke-width="18" stroke-opacity="0.06"/>
<circle cx="1042" cy="344" r="184" fill="none" stroke="#FFFFFF" stroke-width="10" stroke-opacity="0.05"/>
@@ -16,7 +16,7 @@
<text x="154" y="258" fill="#FFFFFF" fill-opacity="0.12" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="220" font-weight="700">
{{CHAPTER_NUM}}
</text>
<text x="154" y="334" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="46" font-weight="700">
<text x="154" y="334" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="46" font-weight="700" id="slot-02_chapter-title-1" data-pptx-placeholder="title">
{{CHAPTER_TITLE}}
</text>
<text x="154" y="392" fill="#FFFFFF" fill-opacity="0.82" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="20">

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFFFFF"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<rect width="1280" height="720" fill="#FFFFFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="0" y="0" width="1280" height="30" fill="#C8152D"/>
<defs>
<linearGradient id="accentFade" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -36,7 +36,7 @@
分隔线: <line stroke="#ECECEC" stroke-width="1.5"/>
列分隔: <line stroke="#EFEFEF" stroke-width="2"/> (纵向)
-->
<text x="641" y="400" text-anchor="middle" fill="#D8D8D8" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="28" font-weight="700" letter-spacing="3">
<text x="641" y="400" text-anchor="middle" fill="#D8D8D8" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="28" font-weight="700" letter-spacing="3" id="slot-02_toc-body-1" data-pptx-placeholder="body">
{{CONTENT_AREA}}
</text>
<text x="641" y="436" text-anchor="middle" fill="#E6E6E6" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="14">

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFFFFF"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<rect width="1280" height="720" fill="#FFFFFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<rect x="0" y="0" width="1280" height="30" fill="#C8152D"/>
<defs>
<linearGradient id="accentFade" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -12,7 +12,7 @@
<image href="logo_dark.png" x="940" y="42" width="280" height="48" preserveAspectRatio="xMidYMid meet"/>
<text x="72" y="84" fill="#1F1F1F" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="26" font-weight="700">
<text x="72" y="84" fill="#1F1F1F" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="26" font-weight="700" id="slot-03_content-title-1" data-pptx-placeholder="title">
{{PAGE_TITLE}}
</text>
<line x1="72" y1="98" x2="286" y2="98" stroke="#C8152D" stroke-width="3"/>
@@ -23,7 +23,7 @@
</text>
<rect x="72" y="158" width="4" height="26" fill="#C8152D"/>
<text x="90" y="177" fill="#666666" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="16">
<text x="90" y="177" fill="#666666" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="16" id="slot-03_content-body-2" data-pptx-placeholder="body">
{{KEY_MESSAGE}}
</text>
<line x1="72" y1="204" x2="1210" y2="204" stroke="#E9E9E9" stroke-width="2"/>
@@ -65,7 +65,7 @@
<text x="72" y="698" fill="#8A8A8A" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="12">
数据来源:{{SOURCE}}
</text>
<text x="1210" y="698" text-anchor="end" fill="#777777" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="14">
<text x="1210" y="698" text-anchor="end" fill="#777777" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="14" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">
{{PAGE_NUM}}
</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#8F0F1B"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<rect width="1280" height="720" fill="#8F0F1B" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<image href="cover_bg.png" x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
<rect width="1280" height="720" fill="#7F0B17" fill-opacity="0.22"/>
<rect width="1280" height="720" fill="#7F0B17" fill-opacity="0.22"
id="ending-overlay" transform="translate(0 0)"/>
<text x="640" y="260" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="54" font-weight="700">

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,6 +1,7 @@
---
deck_id: 招商银行
kind: deck
native_structure_mode: template
summary: 交易银行产品介绍、销售收款方案汇报、客户案例拆解、分行培训材料.
canvas_format: ppt169
page_count: 5
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<defs>
<!-- 左侧主渐变 -->
<linearGradient id="leftGradient" x1="0%" y1="0%" x2="100%" y2="100%">
@@ -14,7 +14,7 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-01_cover-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 左侧色块(占据左边约 35% -->
<rect x="0" y="0" width="450" height="720" fill="url(#leftGradient)"/>
@@ -36,10 +36,10 @@
<!-- 右侧内容区 -->
<!-- 主标题 -->
<text x="860" y="280" text-anchor="middle" fill="#1A2E44" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="44" font-weight="bold">{{TITLE}}</text>
<text x="860" y="280" text-anchor="middle" fill="#1A2E44" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="44" font-weight="bold" id="slot-01_cover-title-1" data-pptx-placeholder="title">{{TITLE}}</text>
<!-- 副标题 -->
<text x="860" y="340" text-anchor="middle" fill="#6B7B8C" font-family="Microsoft YaHei, Arial, sans-serif" font-size="20">{{SUBTITLE}}</text>
<text x="860" y="340" text-anchor="middle" fill="#6B7B8C" font-family="Microsoft YaHei, Arial, sans-serif" font-size="20" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">{{SUBTITLE}}</text>
<!-- 金色装饰分隔线 -->
<rect x="710" y="380" width="300" height="2" fill="url(#goldLine)"/>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<defs>
<!-- 天际渐变 -->
<linearGradient id="skyGradient" x1="0%" y1="0%" x2="100%" y2="100%">
@@ -8,7 +8,7 @@
</defs>
<!-- 深邃蓝全屏背景 -->
<rect width="1280" height="720" fill="#004A82"/>
<rect width="1280" height="720" fill="#004A82" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 右上角斜切浅色区域(天际) -->
<polygon points="700,0 1280,0 1280,400 1000,0" fill="url(#skyGradient)" fill-opacity="0.3"/>
@@ -34,7 +34,7 @@
<rect x="80" y="330" width="100" height="3" fill="#D4A84B"/>
<!-- 章节主标题 -->
<text x="80" y="420" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="44" font-weight="bold">{{CHAPTER_TITLE}}</text>
<text x="80" y="420" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="44" font-weight="bold" id="slot-02_chapter-title-1" data-pptx-placeholder="title">{{CHAPTER_TITLE}}</text>
<!-- 章节描述 -->
<text x="80" y="480" fill="#FFFFFF" fill-opacity="0.7" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18">{{CHAPTER_DESC}}</text>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<defs>
<!-- 顶部斜切渐变 -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -13,14 +13,14 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FAFCFF"/>
<rect width="1280" height="720" fill="#FAFCFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部斜切蓝色装饰带 -->
<polygon points="0,0 1280,0 1280,80 0,100" fill="url(#headerGradient)"/>
<polygon points="0,100 1280,80 1280,95 0,115" fill="#E3F2FD"/>
<!-- 页面标题 -->
<text x="60" y="60" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="26" font-weight="bold">{{TOC_TITLE}}</text>
<text x="60" y="60" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="26" font-weight="bold" id="slot-02_toc-title-1" data-pptx-placeholder="title">{{TOC_TITLE}}</text>
<!-- Logo -->
<image x="1040" y="15" width="200" height="50" xlink:href="重庆大学logo2.png" preserveAspectRatio="xMidYMid meet"/>
@@ -72,7 +72,7 @@
<!-- 页码 -->
<rect x="1180" y="655" width="40" height="28" fill="#006BB7" rx="4"/>
<text x="1200" y="675" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13" font-weight="bold">{{PAGE_NUM}}</text>
<text x="1200" y="675" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13" font-weight="bold" id="slot-02_toc-slide_number-2" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
<!-- 左下角金色光点 -->
<circle cx="50" cy="670" r="3" fill="#D4A84B" fill-opacity="0.5"/>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<defs>
<!-- 顶部斜切渐变 -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -8,7 +8,7 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FAFCFF"/>
<rect width="1280" height="720" fill="#FAFCFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部斜切蓝色装饰带(左高右低) -->
<polygon points="0,0 1280,0 1280,70 0,90" fill="url(#headerGradient)"/>
@@ -17,7 +17,7 @@
<polygon points="0,90 1280,70 1280,85 0,105" fill="#E3F2FD"/>
<!-- 页面标题 -->
<text x="60" y="55" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="bold">{{PAGE_TITLE}}</text>
<text x="60" y="55" fill="#FFFFFF" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="24" font-weight="bold" id="slot-03_content-title-1" data-pptx-placeholder="title">{{PAGE_TITLE}}</text>
<!-- Logo(右上角,在斜切带上) -->
<image x="1040" y="12" width="200" height="50" xlink:href="重庆大学logo2.png" preserveAspectRatio="xMidYMid meet"/>
@@ -29,7 +29,7 @@
<rect x="60" y="120" width="1160" height="50" fill="#E3F2FD" rx="6"/>
<rect x="60" y="120" width="4" height="50" fill="#006BB7"/>
<text x="80" y="152" fill="#006BB7" font-family="Microsoft YaHei, Arial, sans-serif" font-size="15" font-weight="bold">核心观点</text>
<text x="170" y="152" fill="#333D4A" font-family="Microsoft YaHei, Arial, sans-serif" font-size="15">{{KEY_MESSAGE}}</text>
<text x="170" y="152" fill="#333D4A" font-family="Microsoft YaHei, Arial, sans-serif" font-size="15" id="slot-03_content-body-2" data-pptx-placeholder="body">{{KEY_MESSAGE}}</text>
<!-- 内容区域占位 -->
<rect x="60" y="190" width="1160" height="440" fill="#FFFFFF" stroke="#E3F2FD" stroke-width="2" rx="8"/>
@@ -49,7 +49,7 @@
<!-- 页脚右侧:页码(金色装饰) -->
<rect x="1180" y="665" width="40" height="28" fill="#006BB7" rx="4"/>
<text x="1200" y="685" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13" font-weight="bold">{{PAGE_NUM}}</text>
<text x="1200" y="685" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="13" font-weight="bold" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
<!-- 左下角金色光点 -->
<circle cx="50" cy="670" r="3" fill="#D4A84B" fill-opacity="0.5"/>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<defs>
<!-- 底部渐变 -->
<linearGradient id="bottomBar" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -14,7 +14,7 @@
</defs>
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 顶部细线装饰 -->
<rect x="0" y="0" width="1280" height="6" fill="#006BB7"/>
@@ -52,7 +52,7 @@
<text x="640" y="695" text-anchor="middle" fill="#FFFFFF" fill-opacity="0.5" font-family="Microsoft YaHei, Arial, sans-serif" font-size="11">{{COPYRIGHT}}</text>
<!-- 页码 -->
<text x="1220" y="695" text-anchor="middle" fill="#FFFFFF" fill-opacity="0.5" font-family="Arial, sans-serif" font-size="11">{{PAGE_NUM}}</text>
<text x="1220" y="695" text-anchor="middle" fill="#FFFFFF" fill-opacity="0.5" font-family="Arial, sans-serif" font-size="11" id="slot-04_ending-slide_number-1" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
<!-- 左侧 CQU 水印 -->
<text x="80" y="660" fill="#FFFFFF" fill-opacity="0.08" font-family="Arial, sans-serif" font-size="80" font-weight="bold">CQU</text>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -1,6 +1,7 @@
---
deck_id: 重庆大学
kind: deck
native_structure_mode: template
summary: Academic defense, research reports, teaching presentations, scholarly exchange.
canvas_format: ppt169
page_count: 5
@@ -16,6 +16,7 @@
| **Use Case** | [Filled by Strategist] |
| **Delivery Purpose** | [`text` read-close / `balanced` business / `presentation` — confirmed at c; a deck-wide consumption mode that drives per-page density, page-count recommendation, page_rhythm lean, and the body baseline (px). See strategist.md §6.1.] |
| **Content Strategy** | [Material divergence — the user's free-text intent on how closely to follow the source vs how freely to reshape it (or "balanced default"); facts stay sourced however free. Confirmed at c; consumed when authoring §IX. Not in spec_lock.] |
| **Template Adherence** | [`adaptive` / `strict` — include only when Step 3 loaded a deck/layout template; omit for free design and brand-only templates. Also written under `spec_lock.md pptx_structure`.] |
| **Created Date** | {date_str} |
---
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="01_cover" data-pptx-layout-name="Cover">
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-01_cover-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- ==================== 顶部装饰区域 ==================== -->
<!-- 深蓝色主横条 -->
@@ -11,18 +11,18 @@
<!-- 右上角 Logo 区域占位 -->
<rect x="1080" y="25" width="160" height="50" fill="none" stroke="#FFFFFF" stroke-width="1" stroke-opacity="0.3" rx="4"/>
<text x="1160" y="58" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold">
<text x="1160" y="58" text-anchor="middle" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold" id="semantic-01_cover-logo-1" data-pptx-role="logo">
{{LOGO}}
</text>
<!-- ==================== 主内容区域 ==================== -->
<!-- 主标题 -->
<text x="640" y="340" text-anchor="middle" fill="#003366" font-family="Microsoft YaHei, Arial, sans-serif" font-size="56" font-weight="bold">
<text x="640" y="340" text-anchor="middle" fill="#003366" font-family="Microsoft YaHei, Arial, sans-serif" font-size="56" font-weight="bold" id="slot-01_cover-title-1" data-pptx-placeholder="title">
{{TITLE}}
</text>
<!-- 副标题 -->
<text x="640" y="410" text-anchor="middle" fill="#666666" font-family="Microsoft YaHei, Arial, sans-serif" font-size="28">
<text x="640" y="410" text-anchor="middle" fill="#666666" font-family="Microsoft YaHei, Arial, sans-serif" font-size="28" id="slot-01_cover-subtitle-2" data-pptx-placeholder="subtitle">
{{SUBTITLE}}
</text>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="02_chapter" data-pptx-layout-name="Section">
<!-- 背景:全屏深蓝,营造过渡感和沉浸感 -->
<rect width="1280" height="720" fill="#003366"/>
<rect width="1280" height="720" fill="#003366" id="layout-02_chapter-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- 背景装饰:右侧几何图形,增加层次 -->
<path d="M800 0 L1280 0 L1280 720 L600 720 Z" fill="#004080" fill-opacity="0.3"/>
@@ -17,7 +17,7 @@
</text>
<!-- 章节标题:醒目白色 -->
<text x="380" y="340" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="56" font-weight="bold">
<text x="380" y="340" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="56" font-weight="bold" id="slot-02_chapter-title-1" data-pptx-placeholder="title">
{{CHAPTER_TITLE}}
</text>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="02_toc" data-pptx-layout-name="Agenda">
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-02_toc-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- ==================== 页眉区域 ==================== -->
<rect x="0" y="0" width="1280" height="70" fill="#003366"/>
@@ -13,7 +13,7 @@
</text>
<!-- 右上角 Logo -->
<text x="1200" y="46" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold">
<text x="1200" y="46" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold" id="semantic-02_toc-logo-1" data-pptx-role="logo">
{{LOGO}}
</text>
@@ -59,5 +59,5 @@
<!-- ==================== 页脚区域 ==================== -->
<line x1="40" y1="665" x2="1240" y2="665" stroke="#E8F4FC" stroke-width="1"/>
<text x="1220" y="698" text-anchor="end" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14">{{PAGE_NUM}}</text>
<text x="1220" y="698" text-anchor="end" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14" id="slot-02_toc-slide_number-1" data-pptx-placeholder="slide-number">{{PAGE_NUM}}</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="03_content" data-pptx-layout-name="Content">
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-03_content-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- ==================== 页眉区域 ==================== -->
<!-- 深蓝色页眉背景 -->
@@ -10,20 +10,20 @@
<rect x="0" y="0" width="6" height="70" fill="#CC0000"/>
<!-- 章节编号 + 页面标题 -->
<text x="40" y="46" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="28" font-weight="bold">
<text x="40" y="46" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="28" font-weight="bold" id="slot-03_content-title-1" data-pptx-placeholder="title">
{{SECTION_NUM}} {{PAGE_TITLE}}
</text>
<!-- 右上角 Logo -->
<rect x="1100" y="20" width="140" height="30" fill="none"/>
<text x="1220" y="46" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold">
<text x="1220" y="46" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold" id="semantic-03_content-logo-1" data-pptx-role="logo">
{{LOGO}}
</text>
<!-- ==================== 核心观点条(可选) ==================== -->
<rect x="0" y="70" width="1280" height="50" fill="#E8F4FC"/>
<rect x="0" y="70" width="6" height="50" fill="#0066CC"/>
<text x="40" y="102" fill="#333333" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18">
<text x="40" y="102" fill="#333333" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" id="slot-03_content-body-2" data-pptx-placeholder="body">
{{KEY_MESSAGE}}
</text>
@@ -52,7 +52,7 @@
<text x="640" y="695" text-anchor="middle" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="12">
{{SECTION_NAME}}
</text>
<text x="1220" y="695" text-anchor="end" fill="#666666" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14">
<text x="1220" y="695" text-anchor="end" fill="#666666" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14" id="slot-03_content-slide_number-3" data-pptx-placeholder="slide-number">
{{PAGE_NUM}}
</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" data-pptx-layout="04_ending" data-pptx-layout-name="Closing">
<!-- 背景 -->
<rect width="1280" height="720" fill="#FFFFFF"/>
<rect width="1280" height="720" fill="#FFFFFF" id="layout-04_ending-background" data-pptx-layer="layout" data-pptx-editable="false"/>
<!-- ==================== 顶部装饰区域 ==================== -->
<rect x="0" y="0" width="1280" height="100" fill="#003366"/>
<rect x="0" y="0" width="6" height="100" fill="#CC0000"/>
<!-- 右上角 Logo -->
<text x="1200" y="58" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold">
<text x="1200" y="58" text-anchor="end" fill="#FFFFFF" font-family="Microsoft YaHei, Arial, sans-serif" font-size="18" font-weight="bold" id="semantic-04_ending-logo-1" data-pptx-role="logo">
{{LOGO}}
</text>
@@ -46,7 +46,7 @@
<text x="640" y="698" text-anchor="middle" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14">
{{COPYRIGHT}}
</text>
<text x="1220" y="698" text-anchor="end" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14">
<text x="1220" y="698" text-anchor="end" fill="#999999" font-family="Microsoft YaHei, Arial, sans-serif" font-size="14" id="slot-04_ending-slide_number-1" data-pptx-placeholder="slide-number">
{{PAGE_NUM}}
</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,6 +1,7 @@
---
layout_id: academic_defense
kind: layout
native_structure_mode: template
summary: Thesis defense, academic presentations, research progress reports, grant applications.
canvas_format: ppt169
page_count: 5

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