-This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors.
+Click to collapse
@@ -422,6 +428,8 @@ PPT Master is currently built and maintained primarily by me. Every new template
+**[Want to appear here? →](SPONSORING.md)** — placements, audience and rates.
+
**Individual support**
If PPT Master has been helpful to you, individual support of any amount helps keep the project moving and free.
diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
index e667b105..82140efa 100644
--- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
- "commit": "e2b4e6a7c43594a66fb477d00e18267ce50bbd93",
+ "commit": "ebd74d1f1d61a686f0f80e10abde5029fc4beeca",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
- "syncedAt": "2026-08-24T16:00:00Z"
+ "syncedAt": "2026-08-25T16:00:00Z"
}
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md
index baea843d..f2e00bae 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md
@@ -27,7 +27,7 @@ python3 scripts/source_to_md/pdf_to_md.py
# or
python3 scripts/source_to_md/ppt_to_md.py
python3 scripts/source_to_md/excel_to_md.py
-python3 scripts/project_manager.py init --format ppt169
+python3 scripts/project_manager.py init
python3 scripts/project_manager.py import-sources
python3 scripts/total_md_split.py
python3 scripts/finalize_svg.py
@@ -79,7 +79,7 @@ python3 scripts/pptx_to_svg.py -o # reconstruction/ref
Project setup:
```bash
-python3 scripts/project_manager.py init --format ppt169
+python3 scripts/project_manager.py init [--format ]
python3 scripts/project_manager.py import-sources
python3 scripts/project_manager.py scaffold-spec # optional manual helper
python3 scripts/project_manager.py scaffold-lock # optional manual helper
@@ -88,6 +88,11 @@ python3 scripts/project_manager.py page-context P07 --record-usag
python3 scripts/project_manager.py page-context-report
```
+`--format` is optional and accepts registered canvas keys only. Pass it when
+the actual canvas exactly matches one of those keys; otherwise omit it. Without
+the flag, `init` creates `_`, and authoring records the canvas
+in `spec_lock.md` for Default Generate or the first SVG for Quick Generate.
+
`page-context` is an on-demand read-only current-page projection for diagnostics,
routing checks, or context measurement; normal generation retains the complete
Design Spec and lock once per valid execution context. Each invocation includes
@@ -172,7 +177,7 @@ The destination must be empty, and the command does not write
Template fill (direct PPTX, no SVG conversion):
```bash
-python3 scripts/project_manager.py init --format ppt169
+python3 scripts/project_manager.py init
python3 scripts/project_manager.py import-sources
# Manual fallback when import-sources did not produce analysis/.slide_library.json:
python3 scripts/template_fill_pptx.py analyze /sources/ -o /analysis/.slide_library.json
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md
index ddd91105..e61c7532 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md
@@ -11,7 +11,7 @@ Project tools create, validate, and inspect the standard PPT Master workspace.
Main entry point for project setup and validation.
```bash
-python3 scripts/project_manager.py init --format ppt169
+python3 scripts/project_manager.py init [--format ]
python3 scripts/project_manager.py import-sources [ ...]
python3 scripts/project_manager.py scaffold-spec # optional manual helper
python3 scripts/project_manager.py scaffold-lock # optional manual helper
@@ -22,6 +22,13 @@ python3 scripts/project_manager.py page-context-report
```
Notes:
+- `--format` is optional and accepts registered canvas keys only. Pass it only
+ when the actual canvas exactly matches a registered format.
+- Without `--format`, `init` creates `_`; authoring records the
+ canvas in `spec_lock.md` for Default Generate or the first SVG for Quick
+ Generate.
+- With `--format`, `init` preserves the registered form
+ `__` and normalizes aliases such as `xhs`.
- `init --quick-generate`: `svg_output/` plus
`validation/workflow.log`; no README
- Files outside `projects/` are always copied into `sources/`
@@ -45,7 +52,8 @@ Notes:
- Optional `scaffold-spec` creates `design_spec.md` from
`templates/scaffolds/design_spec.md`; `scaffold-lock` creates `spec_lock.md`
from `templates/scaffolds/spec_lock.md`. Both substitute project/canvas
- metadata deterministically and refuse to overwrite an existing artifact.
+ metadata deterministically, require a registered format in the project
+ directory name, and refuse to overwrite an existing artifact.
- `validate` parses the existing Markdown artifacts against
`templates/schemas/design_spec.schema.json` and
`templates/schemas/spec_lock.schema.json`. It reports missing sections and
@@ -138,13 +146,14 @@ Common formats:
Examples:
```bash
-python3 scripts/project_manager.py init my_presentation --format ppt169
-python3 scripts/project_manager.py scaffold-spec projects/my_presentation_ppt169_20251116 # optional
-python3 scripts/project_manager.py scaffold-lock projects/my_presentation_ppt169_20251116 # optional
-python3 scripts/project_manager.py validate projects/my_presentation_ppt169_20251116
-python3 scripts/project_manager.py info projects/my_presentation_ppt169_20251116
-python3 scripts/project_manager.py page-context projects/my_presentation_ppt169_20251116 P07 --record-usage
-python3 scripts/project_manager.py page-context-report projects/my_presentation_ppt169_20251116
+python3 scripts/project_manager.py init my_presentation
+python3 scripts/project_manager.py validate projects/my_presentation_20251116
+python3 scripts/project_manager.py info projects/my_presentation_20251116
+python3 scripts/project_manager.py init my_widescreen --format ppt169
+python3 scripts/project_manager.py scaffold-spec projects/my_widescreen_ppt169_20251116 # optional
+python3 scripts/project_manager.py scaffold-lock projects/my_widescreen_ppt169_20251116 # optional
+python3 scripts/project_manager.py page-context projects/my_widescreen_ppt169_20251116 P07 --record-usage
+python3 scripts/project_manager.py page-context-report projects/my_widescreen_ppt169_20251116
```
## `workflow_transcript.py` and `workflow_log.py`
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
index 03ef7533..ec554389 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
@@ -553,6 +553,8 @@ python3 scripts/svg_to_pptx.py --no-image-optimize
python3 scripts/svg_to_pptx.py --native-charts-and-tables
python3 scripts/svg_to_pptx.py --pptx-structure structured # deck/layout template override
python3 scripts/svg_to_pptx.py --pptx-structure flat # free-design/brand-only override
+# Exact source-structure diagnostic emitted by pptx_to_svg.py --roundtrip:
+python3 scripts/svg_to_pptx.py -s svg --roundtrip
# Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py -s svg-flat
# Post-processed-source comparison diagnostic only (never a release export):
@@ -634,6 +636,7 @@ Behavior:
- `--no-merge`: each dy-stacked line becomes an independent frame with its own placement.
- Detection is conservative: mixed-layout `` falls back to per-line frames. Use `--reflow-text` only for resizable body copy and `--no-merge` only for independent line objects or absolute line positions.
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
+- `-s svg --roundtrip` consumes only the validated source package, structure sidecar, and layered `slide_*.svg` files emitted by `pptx_to_svg.py --roundtrip`. An unchanged imported chart with a closed validated source package automatically restores its original chart XML, style/color parts, workbook, and theme override without enabling ordinary semantic Chart/Table replacement. Its visible-fallback fingerprint remains authoritative: editing the SVG fallback disables stale exact replacement instead of discarding that edit.
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
page-design source. Templates and locks may guide authoring, but finalize/export
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/error_helper.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/error_helper.py
index 14a8af83..be7c1f57 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/error_helper.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/error_helper.py
@@ -68,9 +68,10 @@ class ErrorHelper:
'message': 'Project directory missing date suffix',
'solutions': [
'Rename the project directory to add a date suffix: _YYYYMMDD',
- 'Format: {project_name}_{format}_{YYYYMMDD}',
- 'Example: my_project_ppt169_20251116',
- 'Command: mv old_name new_name_ppt169_20251116'
+ 'Format: {project_name}_{YYYYMMDD}',
+ 'An explicit registered format may use: {project_name}_{format}_{YYYYMMDD}',
+ 'Example: my_project_20251116 or my_project_ppt169_20251116',
+ 'Command: mv old_name new_name_20251116'
],
'severity': 'warning'
},
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/extract_svg_assets.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/extract_svg_assets.py
index 00221568..f5e6fc20 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/extract_svg_assets.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/extract_svg_assets.py
@@ -38,10 +38,12 @@ import argparse
import copy
import hashlib
import json
+import os
import re
import sys
from pathlib import Path
from typing import Optional
+from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
@@ -55,6 +57,7 @@ configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg"
DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"}
SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"}
+DEFINITION_CONTAINERS = {"defs"}
DEFAULT_MIN_DRAWABLES = 20
DEFAULT_MIN_BYTES = 3000
DEFAULT_MIN_DECORATION_BYTES = 3000
@@ -99,7 +102,12 @@ def _has_icon_placeholder(elem: ET.Element) -> bool:
def _is_extractable_subtree(elem: ET.Element) -> bool:
"""Pure vector subtrees can be moved; semantic content must stay inline."""
- if _has_icon_placeholder(elem) or _is_chart_group(elem) or _has_semantic_content(elem):
+ if (
+ _local(elem.tag) in DEFINITION_CONTAINERS
+ or _has_icon_placeholder(elem)
+ or _is_chart_group(elem)
+ or _has_semantic_content(elem)
+ ):
return False
return _drawable_count(elem) > 0
@@ -189,16 +197,48 @@ def _collect_id_mapping(asset_id: str, group: ET.Element, dependencies: list[ET.
def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None:
- def rewrite_url(match: re.Match[str]) -> str:
- quote, ref_id = match.group(1), match.group(2)
- new_id = id_mapping.get(ref_id, ref_id)
- return f"url({quote}#{new_id}{quote})"
+ _rewrite_reference_values(elem, id_mapping)
for item in elem.iter():
elem_id = item.get("id")
if elem_id in id_mapping:
item.set("id", id_mapping[elem_id])
+
+def _rebase_external_hrefs(
+ elem: ET.Element,
+ source_dir: Path,
+ target_dir: Path,
+) -> int:
+ """Keep relative image/use references valid after moving a subtree."""
+ rewritten_count = 0
+ for item in elem.iter():
+ for attr_name, value in list(item.attrib.items()):
+ if _local(attr_name) != "href" or value.startswith(("#", "/")):
+ continue
+ parsed = urlsplit(value)
+ if parsed.scheme or parsed.netloc or not parsed.path:
+ continue
+ source_target = (source_dir / parsed.path).resolve()
+ relative = Path(os.path.relpath(source_target, target_dir)).as_posix()
+ rewritten = urlunsplit(("", "", relative, parsed.query, parsed.fragment))
+ if rewritten != value:
+ item.set(attr_name, rewritten)
+ rewritten_count += 1
+ return rewritten_count
+
+
+def _rewrite_reference_values(
+ elem: ET.Element,
+ id_mapping: dict[str, str],
+) -> None:
+ """Rewrite local URL/href references without renaming definition ids."""
+ def rewrite_url(match: re.Match[str]) -> str:
+ quote, ref_id = match.group(1), match.group(2)
+ new_id = id_mapping.get(ref_id, ref_id)
+ return f"url({quote}#{new_id}{quote})"
+
+ for item in elem.iter():
for attr_name, value in list(item.attrib.items()):
rewritten = URL_REF_RE.sub(rewrite_url, value)
if _local(attr_name) == "href" and value.startswith("#") and value[1:] in id_mapping:
@@ -207,11 +247,92 @@ def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None:
item.set(attr_name, rewritten)
+def _definition_signature(elem: ET.Element) -> bytes:
+ """Return definition semantics without its document-local id."""
+ normalized = copy.deepcopy(elem)
+ normalized.attrib.pop("id", None)
+ return ET.tostring(normalized, encoding="utf-8")
+
+
+def _optimize_definitions(root: ET.Element) -> tuple[int, int]:
+ """Deduplicate equivalent defs and remove definitions with no live refs."""
+ deduplicated = 0
+ pruned = 0
+ definition_containers = [
+ child for child in root
+ if _local(child.tag) in DEFINITION_CONTAINERS
+ ]
+
+ for definitions in definition_containers:
+ canonical_by_signature: dict[bytes, str] = {}
+ duplicate_ids: dict[str, str] = {}
+ duplicate_elements: list[ET.Element] = []
+ for definition in list(definitions):
+ definition_id = definition.get("id")
+ if not definition_id:
+ continue
+ signature = _definition_signature(definition)
+ canonical_id = canonical_by_signature.get(signature)
+ if canonical_id is None:
+ canonical_by_signature[signature] = definition_id
+ continue
+ duplicate_ids[definition_id] = canonical_id
+ duplicate_elements.append(definition)
+
+ if duplicate_ids:
+ _rewrite_reference_values(root, duplicate_ids)
+ for definition in duplicate_elements:
+ definitions.remove(definition)
+ deduplicated += len(duplicate_elements)
+
+ owner_by_id: dict[str, ET.Element] = {}
+ for definition in definitions:
+ for item in definition.iter():
+ if item_id := item.get("id"):
+ owner_by_id[item_id] = definition
+
+ live_refs: set[str] = set()
+ for attr_name, value in root.attrib.items():
+ live_refs.update(match.group(2) for match in URL_REF_RE.finditer(value))
+ if _local(attr_name) == "href" and value.startswith("#"):
+ live_refs.add(value[1:])
+ for child in root:
+ if child is definitions:
+ continue
+ live_refs.update(_referenced_ids(child))
+
+ reachable: set[ET.Element] = set()
+ pending = sorted(live_refs)
+ while pending:
+ ref_id = pending.pop(0)
+ owner = owner_by_id.get(ref_id)
+ if owner is None or owner in reachable:
+ continue
+ reachable.add(owner)
+ for nested_ref in sorted(_referenced_ids(owner)):
+ if nested_ref not in live_refs:
+ live_refs.add(nested_ref)
+ pending.append(nested_ref)
+
+ for definition in list(definitions):
+ has_id = any(item.get("id") for item in definition.iter())
+ if has_id and definition not in reachable:
+ definitions.remove(definition)
+ pruned += 1
+
+ if not list(definitions):
+ root.remove(definitions)
+
+ return deduplicated, pruned
+
+
def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> list[ET.Element]:
"""Outermost groups whose drawable count clears the threshold (no nesting)."""
found: list[ET.Element] = []
def walk(elem: ET.Element) -> None:
+ if _local(elem.tag) in DEFINITION_CONTAINERS:
+ return
for child in list(elem):
if _local(child.tag) != "g":
walk(child)
@@ -254,6 +375,8 @@ def _find_extractable_runs(
found.append((parent, list(run)))
def walk(elem: ET.Element) -> None:
+ if _local(elem.tag) in DEFINITION_CONTAINERS:
+ return
run: list[ET.Element] = []
for child in list(elem):
if _is_extractable_subtree(child):
@@ -548,12 +671,13 @@ def extract_file(
view_box = root.get("viewBox")
width = root.get("width")
height = root.get("height")
+ definitions_changed = any(_optimize_definitions(root))
# A namespaced projection is an all-at-once readability pass. Once it owns
# an asset reference, reruns inventory the existing placeholders instead of
# progressively factoring their remaining parent/sibling geometry.
if _has_namespace_placeholder(root, icon_namespace):
- if not inplace:
+ if definitions_changed or not inplace:
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
@@ -587,7 +711,7 @@ def extract_file(
targets.append((parent, run))
if not targets:
- if not inplace:
+ if definitions_changed or not inplace:
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
@@ -648,8 +772,20 @@ def extract_file(
_rewrite_references(dependency, id_mapping)
# Asset keeps the group in original page coordinates and carries its defs.
+ asset_path = icons_dir / asset
+ external_hrefs_rewritten = _rebase_external_hrefs(
+ group,
+ svg_path.parent,
+ asset_path.parent,
+ )
+ for dependency in dependencies:
+ external_hrefs_rewritten += _rebase_external_hrefs(
+ dependency,
+ svg_path.parent,
+ asset_path.parent,
+ )
asset_bytes = _asset_svg(group, dependencies, view_box, width, height)
- (icons_dir / asset).write_bytes(asset_bytes)
+ asset_path.write_bytes(asset_bytes)
placeholder = ET.Element(f"{{{SVG_NS}}}use")
placeholder.set("data-icon", icon_reference)
@@ -670,9 +806,11 @@ def extract_file(
"byte_count": _xml_size(group),
"source_refs": source_refs,
"dependencies": [id_mapping.get(elem_id, elem_id) for elem_id in dependency_source_ids],
+ "external_hrefs_rewritten": external_hrefs_rewritten,
"elements": _tag_histogram(group),
})
+ _optimize_definitions(root)
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/generate_examples_index.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/generate_examples_index.py
index e25cb225..2012adcc 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/generate_examples_index.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/generate_examples_index.py
@@ -178,8 +178,12 @@ def generate_examples_index(examples_dir: str = 'examples') -> str:
content.append("Refer to existing project structures, or use the project management tool:\n")
content.append("```bash")
content.append(
- "python3 scripts/project_manager.py init my_project --format ppt169")
+ "python3 scripts/project_manager.py init my_project")
content.append("```\n")
+ content.append(
+ "Pass `--format ` only when the canvas exactly "
+ "matches a registered format.\n"
+ )
# Contribution guidelines
content.append("## [Contribute] Contributing Example Projects\n")
@@ -188,7 +192,11 @@ def generate_examples_index(examples_dir: str = 'examples') -> str:
content.append("1. Follow the standard project structure")
content.append("2. Include a complete README.md and design specification")
content.append("3. SVG files must comply with technical specifications")
- content.append("4. Directory naming format: `{project_name}_{format}_{YYYYMMDD}`\n")
+ content.append(
+ "4. Directory naming format: `{project_name}_{YYYYMMDD}`, or "
+ "`{project_name}_{format}_{YYYYMMDD}` when initialized with a "
+ "registered `--format`\n"
+ )
content.append("### Submission Process\n")
content.append("1. Create a project under the `examples/` directory")
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_effects.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_effects.py
index be6517cc..e9200e2e 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_effects.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_effects.py
@@ -3,12 +3,17 @@
from __future__ import annotations
+import base64
+import binascii
+import hashlib
import json
from xml.etree import ElementTree as ET
EFFECT_STATUS_ATTR = "data-pptx-effect-status"
EFFECT_REASON_ATTR = "data-pptx-effect-reason"
+NATIVE_EFFECT_ATTR = "data-pptx-effect-ooxml"
+NATIVE_EFFECT_SHA256_ATTR = "data-pptx-effect-ooxml-sha256"
UNSUPPORTED_EFFECT_STATUS = "unsupported"
_EFFECT_OBJECT_IDENTITY_ATTRS = (
"data-pptx-object",
@@ -25,6 +30,13 @@ _RUN_EFFECT_CONTAINER_TAGS = frozenset({
f"{{{_DML_NAMESPACE}}}effectLst",
f"{{{_DML_NAMESPACE}}}effectDag",
})
+_NATIVE_EFFECT_CONTAINER_TAGS = frozenset({
+ f"{{{_DML_NAMESPACE}}}effectLst",
+ f"{{{_DML_NAMESPACE}}}effectDag",
+})
+_RELATIONSHIPS_NAMESPACE = (
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+)
def project_effect_status_errors(root: ET.Element) -> list[str]:
@@ -38,7 +50,14 @@ def project_effect_status_errors(root: ET.Element) -> list[str]:
for elem in root.iter():
raw_status = elem.get(EFFECT_STATUS_ATTR)
raw_reason = elem.get(EFFECT_REASON_ATTR)
- if raw_status is None and raw_reason is None:
+ raw_native = elem.get(NATIVE_EFFECT_ATTR)
+ raw_native_sha256 = elem.get(NATIVE_EFFECT_SHA256_ATTR)
+ if (
+ raw_status is None
+ and raw_reason is None
+ and raw_native is None
+ and raw_native_sha256 is None
+ ):
continue
parent = parents.get(elem)
if (
@@ -64,10 +83,75 @@ def project_effect_status_errors(root: ET.Element) -> list[str]:
f'{label} {EFFECT_REASON_ATTR} requires a non-empty reason'
)
continue
+ if raw_native is not None or raw_native_sha256 is not None:
+ try:
+ preserved_native_effect_xml(elem)
+ except ValueError as exc:
+ errors.add(f"{label} has invalid preserved PPTX effect: {exc}")
+ else:
+ # The complete native effect container is the registered
+ # round-trip fallback for effects outside the SVG subset.
+ continue
errors.add(f'{label} has unsupported source PPTX effect: {reason}')
return sorted(errors)
+def native_effect_metadata(effect_container: ET.Element) -> dict[str, str]:
+ """Encode one relationship-free DrawingML effect container for round-trip."""
+ _validate_native_effect_container(effect_container)
+ raw = ET.tostring(effect_container, encoding="utf-8")
+ return {
+ NATIVE_EFFECT_ATTR: base64.b64encode(raw).decode("ascii"),
+ NATIVE_EFFECT_SHA256_ATTR: hashlib.sha256(raw).hexdigest(),
+ }
+
+
+def preserved_native_effect_xml(elem: ET.Element) -> str | None:
+ """Decode and validate one preserved DrawingML effect container."""
+ encoded = elem.get(NATIVE_EFFECT_ATTR)
+ expected_sha256 = elem.get(NATIVE_EFFECT_SHA256_ATTR)
+ if encoded is None and expected_sha256 is None:
+ return None
+ if not encoded or not expected_sha256:
+ raise ValueError(
+ f"{NATIVE_EFFECT_ATTR} and {NATIVE_EFFECT_SHA256_ATTR} must appear together"
+ )
+ try:
+ raw = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise ValueError(f"{NATIVE_EFFECT_ATTR} must be canonical base64") from exc
+ actual_sha256 = hashlib.sha256(raw).hexdigest()
+ if actual_sha256 != expected_sha256.strip().lower():
+ raise ValueError(
+ f"{NATIVE_EFFECT_SHA256_ATTR} does not match the preserved payload"
+ )
+ try:
+ effect_container = ET.fromstring(raw)
+ except ET.ParseError as exc:
+ raise ValueError(f"preserved effect OOXML is malformed: {exc}") from exc
+ _validate_native_effect_container(effect_container)
+ return raw.decode("utf-8")
+
+
+def _validate_native_effect_container(effect_container: ET.Element) -> None:
+ """Require a standalone, relationship-free DrawingML effect container."""
+ if effect_container.tag not in _NATIVE_EFFECT_CONTAINER_TAGS:
+ raise ValueError(
+ "preserved effect root must be a DrawingML effectLst or effectDag"
+ )
+ for node in effect_container.iter():
+ if not isinstance(node.tag, str) or not node.tag.startswith(
+ f"{{{_DML_NAMESPACE}}}"
+ ):
+ raise ValueError("preserved effect payload must contain only DrawingML")
+ if any(
+ isinstance(name, str)
+ and name.startswith(f"{{{_RELATIONSHIPS_NAMESPACE}}}")
+ for name in node.attrib
+ ):
+ raise ValueError("preserved effect payload cannot contain relationships")
+
+
def unsupported_effect_metadata(*reasons: str) -> dict[str, str]:
"""Build one canonical import marker without dropping compound reasons."""
normalized: set[str] = set()
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_embedded_fonts.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_embedded_fonts.py
new file mode 100644
index 00000000..fb48c238
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_embedded_fonts.py
@@ -0,0 +1,368 @@
+#!/usr/bin/env python3
+"""Validated sidecar contract for source PPTX embedded fonts.
+
+The SVG projection keeps text editable, so an imported deck's embedded font
+parts must travel beside the SVG workspace instead of being flattened into
+glyph outlines. This module owns the small manifest used by both conversion
+directions and rejects stale, incomplete, or path-escaping payloads.
+"""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import json
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from xml.etree import ElementTree as ET
+
+
+PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
+REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+FONT_REL_TYPE = (
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"
+)
+FONT_CONTENT_TYPE = "application/x-fontdata"
+FONT_BUNDLE_DIR = PurePosixPath("native-payloads/embedded-fonts")
+FONT_MANIFEST_PATH = FONT_BUNDLE_DIR / "manifest.json"
+_FONT_STYLE_TAGS = frozenset({"regular", "bold", "italic", "boldItalic"})
+
+
+class EmbeddedFontError(ValueError):
+ """Reject an unsafe or incomplete embedded-font sidecar."""
+
+
+@dataclass(frozen=True)
+class EmbeddedFontPart:
+ """One relationship-addressed source font payload."""
+
+ relationship_id: str
+ filename: str
+ payload: bytes
+
+
+@dataclass(frozen=True)
+class EmbeddedFontBundle:
+ """Presentation font-list XML plus all referenced font parts."""
+
+ font_list_xml: bytes
+ parts: tuple[EmbeddedFontPart, ...]
+
+
+def _local_name(tag: str) -> str:
+ return tag.rsplit("}", 1)[-1]
+
+
+def _font_relationship_ids(root: ET.Element) -> tuple[str, ...]:
+ """Return every embedded-font relationship id in document order."""
+ if root.tag != f"{{{PML_NS}}}embeddedFontLst":
+ raise EmbeddedFontError(
+ "Embedded font metadata root must be p:embeddedFontLst"
+ )
+ relationship_ids: list[str] = []
+ for font in list(root):
+ if font.tag != f"{{{PML_NS}}}embeddedFont":
+ raise EmbeddedFontError(
+ "Embedded font list may contain only p:embeddedFont entries"
+ )
+ descriptor = font.find(f"{{{PML_NS}}}font")
+ if descriptor is None or not descriptor.attrib.get("typeface", "").strip():
+ raise EmbeddedFontError(
+ "Each embedded font entry requires a non-empty p:font typeface"
+ )
+ for child in list(font):
+ name = _local_name(child.tag)
+ if name == "font":
+ continue
+ if name not in _FONT_STYLE_TAGS or child.tag != f"{{{PML_NS}}}{name}":
+ raise EmbeddedFontError(
+ f"Unsupported embedded font list child: {name}"
+ )
+ if list(child) or set(child.attrib) != {f"{{{REL_NS}}}id"}:
+ raise EmbeddedFontError(
+ f"Embedded font slot {name} must contain only one r:id"
+ )
+ relationship_id = child.attrib[f"{{{REL_NS}}}id"].strip()
+ if not relationship_id:
+ raise EmbeddedFontError(
+ f"Embedded font slot {name} has an empty r:id"
+ )
+ if relationship_id not in relationship_ids:
+ relationship_ids.append(relationship_id)
+ if not relationship_ids:
+ raise EmbeddedFontError("Embedded font list has no font payload slots")
+ return tuple(relationship_ids)
+
+
+def _validate_bundle(bundle: EmbeddedFontBundle) -> ET.Element:
+ """Validate XML/part correspondence and return the parsed font list."""
+ try:
+ root = ET.fromstring(bundle.font_list_xml)
+ except ET.ParseError as exc:
+ raise EmbeddedFontError(
+ f"Embedded font list XML is malformed: {exc}"
+ ) from exc
+ relationship_ids = _font_relationship_ids(root)
+ parts_by_id: dict[str, EmbeddedFontPart] = {}
+ filenames: set[str] = set()
+ for part in bundle.parts:
+ if part.relationship_id in parts_by_id:
+ raise EmbeddedFontError(
+ f"Duplicate embedded font relationship: {part.relationship_id}"
+ )
+ path = PurePosixPath(part.filename)
+ if (
+ not part.filename
+ or path.is_absolute()
+ or len(path.parts) != 1
+ or path.name != part.filename
+ or path.suffix.lower() != ".fntdata"
+ ):
+ raise EmbeddedFontError(
+ f"Embedded font payload filename must be one .fntdata basename: "
+ f"{part.filename!r}"
+ )
+ if part.filename in filenames:
+ raise EmbeddedFontError(
+ f"Duplicate embedded font payload filename: {part.filename}"
+ )
+ if not part.payload:
+ raise EmbeddedFontError(
+ f"Embedded font payload is empty: {part.filename}"
+ )
+ filenames.add(part.filename)
+ parts_by_id[part.relationship_id] = part
+ if set(relationship_ids) != set(parts_by_id):
+ missing = sorted(set(relationship_ids) - set(parts_by_id))
+ extra = sorted(set(parts_by_id) - set(relationship_ids))
+ raise EmbeddedFontError(
+ "Embedded font relationship roster mismatch: "
+ f"missing={missing}, extra={extra}"
+ )
+ return root
+
+
+def capture_embedded_fonts(
+ presentation_root: ET.Element,
+ relationships: Mapping[str, Mapping[str, str]],
+ read_part: Callable[[str], bytes],
+) -> EmbeddedFontBundle | None:
+ """Capture the exact font-list metadata and every referenced font part."""
+ font_list = presentation_root.find(f"{{{PML_NS}}}embeddedFontLst")
+ if font_list is None:
+ return None
+ font_list_xml = ET.tostring(font_list, encoding="utf-8")
+ relationship_ids = _font_relationship_ids(font_list)
+ parts: list[EmbeddedFontPart] = []
+ for index, relationship_id in enumerate(relationship_ids, start=1):
+ relationship = relationships.get(relationship_id)
+ if relationship is None:
+ raise EmbeddedFontError(
+ f"Embedded font relationship is missing: {relationship_id}"
+ )
+ if relationship.get("type") != FONT_REL_TYPE or relationship.get("external"):
+ raise EmbeddedFontError(
+ f"Embedded font relationship is not an internal font part: "
+ f"{relationship_id}"
+ )
+ target = relationship.get("target", "")
+ target_path = PurePosixPath(target)
+ if (
+ target_path.is_absolute()
+ or len(target_path.parts) != 3
+ or target_path.parts[:2] != ("ppt", "fonts")
+ or target_path.suffix.lower() != ".fntdata"
+ ):
+ raise EmbeddedFontError(
+ f"Embedded font relationship has an unsafe target: {target!r}"
+ )
+ try:
+ payload = read_part(target)
+ except (KeyError, OSError) as exc:
+ raise EmbeddedFontError(
+ f"Cannot read embedded font part {target}: {exc}"
+ ) from exc
+ parts.append(EmbeddedFontPart(
+ relationship_id=relationship_id,
+ filename=f"font{index}.fntdata",
+ payload=payload,
+ ))
+ bundle = EmbeddedFontBundle(
+ font_list_xml=font_list_xml,
+ parts=tuple(parts),
+ )
+ _validate_bundle(bundle)
+ return bundle
+
+
+def embedded_font_typefaces(bundle: EmbeddedFontBundle) -> tuple[str, ...]:
+ """Return the declared source typefaces in presentation order."""
+ root = _validate_bundle(bundle)
+ return tuple(
+ descriptor.attrib["typeface"].strip()
+ for descriptor in root.findall(
+ f"{{{PML_NS}}}embeddedFont/{{{PML_NS}}}font"
+ )
+ )
+
+
+def write_embedded_font_bundle(
+ output_root: Path,
+ bundle: EmbeddedFontBundle,
+) -> tuple[dict[str, object], tuple[str, ...]]:
+ """Write one converter-owned sidecar and return report metadata/paths."""
+ _validate_bundle(bundle)
+ bundle_dir = output_root.joinpath(*FONT_BUNDLE_DIR.parts)
+ bundle_dir.mkdir(parents=True, exist_ok=True)
+ part_entries: list[dict[str, str]] = []
+ managed_paths: list[str] = []
+ for part in bundle.parts:
+ relative_path = (FONT_BUNDLE_DIR / part.filename).as_posix()
+ (output_root / relative_path).write_bytes(part.payload)
+ managed_paths.append(relative_path)
+ part_entries.append({
+ "relationshipId": part.relationship_id,
+ "path": relative_path,
+ "sha256": hashlib.sha256(part.payload).hexdigest(),
+ })
+ manifest = {
+ "schemaVersion": 1,
+ "fontListOoxml": {
+ "encoding": "base64",
+ "sha256": hashlib.sha256(bundle.font_list_xml).hexdigest(),
+ "payload": base64.b64encode(bundle.font_list_xml).decode("ascii"),
+ },
+ "parts": part_entries,
+ }
+ manifest_relative = FONT_MANIFEST_PATH.as_posix()
+ (output_root / manifest_relative).write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ managed_paths.append(manifest_relative)
+ descriptor: dict[str, object] = {
+ "manifest": manifest_relative,
+ "partCount": len(bundle.parts),
+ "typefaces": list(embedded_font_typefaces(bundle)),
+ }
+ return descriptor, tuple(managed_paths)
+
+
+def _safe_workspace_path(project_root: Path, value: object) -> Path:
+ if not isinstance(value, str) or not value.strip():
+ raise EmbeddedFontError("Embedded font sidecar path must be a string")
+ path = PurePosixPath(value)
+ if path.is_absolute() or ".." in path.parts:
+ raise EmbeddedFontError(
+ f"Embedded font sidecar path escapes the project: {value!r}"
+ )
+ expected_prefix = FONT_BUNDLE_DIR.parts
+ if path.parts[:len(expected_prefix)] != expected_prefix:
+ raise EmbeddedFontError(
+ f"Embedded font sidecar path must stay under {FONT_BUNDLE_DIR}: "
+ f"{value!r}"
+ )
+ resolved_root = project_root.resolve()
+ resolved = (project_root / Path(*path.parts)).resolve()
+ try:
+ resolved.relative_to(resolved_root)
+ except ValueError as exc:
+ raise EmbeddedFontError(
+ f"Embedded font sidecar path escapes the project: {value!r}"
+ ) from exc
+ if not resolved.is_file():
+ raise EmbeddedFontError(
+ f"Embedded font sidecar file is missing: {value}"
+ )
+ return resolved
+
+
+def load_embedded_font_bundle(
+ project_root: Path,
+ descriptor: object,
+) -> EmbeddedFontBundle | None:
+ """Load and verify a font bundle referenced by conversion-report.json."""
+ if descriptor is None:
+ return None
+ if not isinstance(descriptor, dict):
+ raise EmbeddedFontError(
+ "sourceDocument.embeddedFonts must be an object"
+ )
+ manifest_path = _safe_workspace_path(
+ project_root,
+ descriptor.get("manifest"),
+ )
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
+ raise EmbeddedFontError(
+ f"Cannot read embedded font manifest {manifest_path}: {exc}"
+ ) from exc
+ if not isinstance(manifest, dict) or manifest.get("schemaVersion") != 1:
+ raise EmbeddedFontError(
+ "Embedded font manifest requires schemaVersion 1"
+ )
+ ooxml = manifest.get("fontListOoxml")
+ if not isinstance(ooxml, dict) or ooxml.get("encoding") != "base64":
+ raise EmbeddedFontError(
+ "Embedded font manifest requires base64 fontListOoxml"
+ )
+ payload_value = ooxml.get("payload")
+ digest = ooxml.get("sha256")
+ if not isinstance(payload_value, str) or not isinstance(digest, str):
+ raise EmbeddedFontError(
+ "Embedded font fontListOoxml requires payload and sha256"
+ )
+ try:
+ font_list_xml = base64.b64decode(payload_value, validate=True)
+ except (ValueError, binascii.Error) as exc:
+ raise EmbeddedFontError(
+ "Embedded font fontListOoxml payload is invalid base64"
+ ) from exc
+ if hashlib.sha256(font_list_xml).hexdigest() != digest.lower():
+ raise EmbeddedFontError(
+ "Embedded font fontListOoxml checksum does not match"
+ )
+ raw_parts = manifest.get("parts")
+ if not isinstance(raw_parts, list) or not raw_parts:
+ raise EmbeddedFontError("Embedded font manifest has no parts")
+ parts: list[EmbeddedFontPart] = []
+ for entry in raw_parts:
+ if not isinstance(entry, dict):
+ raise EmbeddedFontError(
+ "Embedded font manifest part entries must be objects"
+ )
+ relationship_id = entry.get("relationshipId")
+ expected_digest = entry.get("sha256")
+ if not isinstance(relationship_id, str) or not relationship_id:
+ raise EmbeddedFontError(
+ "Embedded font manifest part requires relationshipId"
+ )
+ if not isinstance(expected_digest, str):
+ raise EmbeddedFontError(
+ "Embedded font manifest part requires sha256"
+ )
+ part_path = _safe_workspace_path(project_root, entry.get("path"))
+ payload = part_path.read_bytes()
+ if hashlib.sha256(payload).hexdigest() != expected_digest.lower():
+ raise EmbeddedFontError(
+ f"Embedded font payload checksum does not match: {part_path}"
+ )
+ parts.append(EmbeddedFontPart(
+ relationship_id=relationship_id,
+ filename=part_path.name,
+ payload=payload,
+ ))
+ bundle = EmbeddedFontBundle(
+ font_list_xml=font_list_xml,
+ parts=tuple(parts),
+ )
+ _validate_bundle(bundle)
+ expected_count = descriptor.get("partCount")
+ if expected_count is not None and expected_count != len(parts):
+ raise EmbeddedFontError(
+ "Embedded font report partCount does not match the manifest"
+ )
+ return bundle
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_gradients.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_gradients.py
new file mode 100644
index 00000000..abe29a5e
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_gradients.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Validated native payload contract for imported DrawingML gradients."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import json
+from xml.etree import ElementTree as ET
+
+
+NATIVE_GRADIENT_ATTR = "data-pptx-gradient-ooxml"
+NATIVE_GRADIENT_SHA256_ATTR = "data-pptx-gradient-ooxml-sha256"
+NATIVE_GRADIENT_PREVIEW_SHA256_ATTR = "data-pptx-gradient-preview-sha256"
+_DML_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/main"
+_RELATIONSHIPS_NAMESPACE = (
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+)
+_TRANSPORT_ATTRIBUTES = frozenset({
+ "id",
+ NATIVE_GRADIENT_ATTR,
+ NATIVE_GRADIENT_SHA256_ATTR,
+ NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
+})
+
+
+def native_gradient_metadata(
+ grad_fill: ET.Element,
+ preview_gradient: ET.Element,
+) -> dict[str, str]:
+ """Encode a relationship-free gradFill and bind it to its SVG preview."""
+ _validate_native_gradient(grad_fill)
+ raw = ET.tostring(grad_fill, encoding="utf-8")
+ return {
+ NATIVE_GRADIENT_ATTR: base64.b64encode(raw).decode("ascii"),
+ NATIVE_GRADIENT_SHA256_ATTR: hashlib.sha256(raw).hexdigest(),
+ NATIVE_GRADIENT_PREVIEW_SHA256_ATTR: gradient_preview_fingerprint(
+ preview_gradient
+ ),
+ }
+
+
+def preserved_native_gradient_xml(gradient: ET.Element) -> str | None:
+ """Return unchanged imported gradFill OOXML or defer to SVG authoring."""
+ encoded = gradient.get(NATIVE_GRADIENT_ATTR)
+ expected_sha256 = gradient.get(NATIVE_GRADIENT_SHA256_ATTR)
+ expected_preview = gradient.get(NATIVE_GRADIENT_PREVIEW_SHA256_ATTR)
+ if encoded is None and expected_sha256 is None and expected_preview is None:
+ return None
+ if not encoded or not expected_sha256 or not expected_preview:
+ raise ValueError(
+ "Imported gradient payload, payload hash, and preview hash must "
+ "appear together"
+ )
+ try:
+ raw = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise ValueError("Imported gradient payload must be canonical base64") from exc
+ if hashlib.sha256(raw).hexdigest() != expected_sha256.strip().lower():
+ raise ValueError("Imported gradient payload hash does not match")
+ try:
+ grad_fill = ET.fromstring(raw)
+ except ET.ParseError as exc:
+ raise ValueError(f"Imported gradient OOXML is malformed: {exc}") from exc
+ _validate_native_gradient(grad_fill)
+ if gradient_preview_fingerprint(gradient) != expected_preview.strip().lower():
+ return None
+ return raw.decode("utf-8")
+
+
+def gradient_preview_fingerprint(gradient: ET.Element) -> str:
+ """Hash visible SVG gradient semantics while excluding transport fields."""
+ payload = {
+ "tag": _local_name(gradient.tag),
+ "attributes": sorted(
+ (name, value)
+ for name, value in gradient.attrib.items()
+ if _local_name(name) not in _TRANSPORT_ATTRIBUTES
+ ),
+ "stops": [
+ {
+ "attributes": sorted(child.attrib.items()),
+ "text": (child.text or "").strip(),
+ }
+ for child in gradient
+ if _local_name(child.tag) == "stop"
+ ],
+ }
+ canonical = json.dumps(
+ payload,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(canonical).hexdigest()
+
+
+def _validate_native_gradient(grad_fill: ET.Element) -> None:
+ """Require one standalone, relationship-free DrawingML gradFill."""
+ if grad_fill.tag != f"{{{_DML_NAMESPACE}}}gradFill":
+ raise ValueError("Imported gradient root must be a DrawingML gradFill")
+ for node in grad_fill.iter():
+ if not isinstance(node.tag, str) or not node.tag.startswith(
+ f"{{{_DML_NAMESPACE}}}"
+ ):
+ raise ValueError("Imported gradient payload must contain only DrawingML")
+ if any(
+ isinstance(name, str)
+ and name.startswith(f"{{{_RELATIONSHIPS_NAMESPACE}}}")
+ for name in node.attrib
+ ):
+ raise ValueError("Imported gradient payload cannot contain relationships")
+
+
+def _local_name(name: object) -> str:
+ return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py
index 8668854f..cf69dddc 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg.py
@@ -5,6 +5,7 @@ Usage:
python3 pptx_to_svg.py [-o ] [--embed-images]
[--media-subdir ] [--keep-hidden]
[--inheritance-mode {both,layered,flat}]
+ [--roundtrip]
[--strict]
Output structure (default --inheritance-mode both):
@@ -115,6 +116,15 @@ def parse_args() -> argparse.Namespace:
"of the default tolerant conversion with diagnostics"
),
)
+ parser.add_argument(
+ "--roundtrip",
+ action="store_true",
+ help=(
+ "Also preserve a validated source package/Layout sidecar for the "
+ "diagnostic SVG-to-PPTX --roundtrip path. Requires layered or both "
+ "inheritance output."
+ ),
+ )
return parser.parse_args()
@@ -140,6 +150,7 @@ def main() -> int:
keep_hidden=args.keep_hidden,
inheritance_mode=args.inheritance_mode,
strict=args.strict,
+ roundtrip=args.roundtrip,
)
try:
@@ -201,6 +212,9 @@ def main() -> int:
print(f"Output: {output_dir}")
print(f"Animation config: {output_dir / 'animations.json'}")
print(f"Conversion report: {output_dir / 'conversion-report.json'}")
+ if result.native_structure is not None:
+ print(f"Round-trip source: {output_dir / 'source_template.pptx'}")
+ print(f"Round-trip structure: {output_dir / 'native_structure.json'}")
return 0
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
index e2568b1f..9f8ec91b 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
@@ -7,6 +7,8 @@ payload when the chart XML cache can be mapped to the current chart schema.
from __future__ import annotations
+import base64
+import hashlib
import math
import re
from dataclasses import dataclass
@@ -249,7 +251,13 @@ def _payload_from_chart_xml(
has_date_axis or chart_tag in {"bubbleChart", "scatterChart"}
),
)
- _apply_chart_metadata(payload, chart_root, plot_area, chart)
+ _apply_chart_metadata(
+ payload,
+ chart_root,
+ plot_area,
+ chart,
+ palette=palette,
+ )
return payload, visual_styles
@@ -261,16 +269,46 @@ def _combo_payload(
*,
palette: ColorPalette | None,
) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
- series_indices_by_plot = [
- _plot_series_indices(chart, "unsupported-combo-series-order")
+ series_identifiers_by_plot = [
+ _plot_series_identifiers(chart, "unsupported-combo-series-order")
for chart in chart_nodes
]
- flat_series_indices = [
- index
- for series_indices in series_indices_by_plot
- for index in series_indices
+ source_indices_by_plot = [
+ identifiers[0] for identifiers in series_identifiers_by_plot
]
- if sorted(flat_series_indices) != list(range(len(flat_series_indices))):
+ source_orders_by_plot = [
+ identifiers[1] for identifiers in series_identifiers_by_plot
+ ]
+ flat_source_indices = [
+ index
+ for indices in source_indices_by_plot
+ for index in indices
+ ]
+ flat_source_orders = [
+ order
+ for orders in source_orders_by_plot
+ for order in orders
+ ]
+ expected_series_order = list(range(len(flat_source_orders)))
+ canonical_indices = (
+ flat_source_indices == flat_source_orders
+ and sorted(flat_source_indices) == expected_series_order
+ )
+ normalizable_indices = (
+ len(set(flat_source_indices)) == len(flat_source_indices)
+ and sorted(flat_source_orders) == expected_series_order
+ )
+ if canonical_indices:
+ series_indices_by_plot = source_indices_by_plot
+ elif normalizable_indices and palette is not None and not palette.strict:
+ palette._diagnose(
+ "combo-series-indices-normalized",
+ "Combo chart series idx values do not match their contiguous "
+ "display order",
+ "reindex series by their unique contiguous order values",
+ )
+ series_indices_by_plot = source_orders_by_plot
+ else:
raise _UnsupportedChart("unsupported-combo-series-order")
axes_by_id = _axis_nodes_by_id(plot_area)
if not axes_by_id:
@@ -378,7 +416,7 @@ def _combo_payload(
str(color)
for color in plot_payload.get("style", {}).get("colors", [])
)
- _apply_plot_data_labels(plot_payload, chart)
+ _apply_plot_data_labels(plot_payload, chart, palette=palette)
plot_entry: dict[str, Any] = {
"axis": axis_name,
"categories": list(plot_payload["categories"]),
@@ -424,6 +462,7 @@ def _combo_payload(
chart_root,
plot_area,
chart_nodes[0],
+ palette=palette,
include_plot_labels=False,
)
return payload, visual_styles
@@ -574,7 +613,13 @@ def _stock_payload(
"type": "stock",
}
visual_styles = _chart_visual_styles(payload, chart, palette)
- _apply_chart_metadata(payload, chart_root, plot_area, chart)
+ _apply_chart_metadata(
+ payload,
+ chart_root,
+ plot_area,
+ chart,
+ palette=palette,
+ )
return payload, visual_styles
@@ -955,6 +1000,213 @@ def _chart_visual_styles(
return styles
+def _representative_gradient_color(
+ gradient: ET.Element,
+ palette: ColorPalette | None,
+) -> tuple[str, float] | None:
+ """Collapse a chart gradient to one representative tolerant-mode color."""
+ resolved: list[tuple[str, float]] = []
+ for stop in gradient.findall("a:gsLst/a:gs", C_NS):
+ try:
+ color, opacity = resolve_color(
+ find_color_elem(stop),
+ palette,
+ strict=False,
+ )
+ except (TypeError, ValueError, OverflowError):
+ continue
+ if color is not None:
+ resolved.append((color, opacity))
+ if not resolved:
+ return None
+ channels = [
+ tuple(int(color[offset:offset + 2], 16) for offset in (1, 3, 5))
+ for color, _opacity in resolved
+ ]
+ averaged = tuple(
+ round(sum(channel[index] for channel in channels) / len(channels))
+ for index in range(3)
+ )
+ color = "#" + "".join(f"{value:02X}" for value in averaged)
+ opacity = sum(value for _color, value in resolved) / len(resolved)
+ return color, max(0.0, min(1.0, opacity))
+
+
+def _representative_fill(
+ container: ET.Element | None,
+ palette: ColorPalette | None,
+) -> tuple[str | None, float, bool]:
+ """Return a flat color approximation for one chart fill container."""
+ if container is None:
+ return None, 1.0, False
+ if container.find("a:noFill", C_NS) is not None:
+ return None, 1.0, True
+ solid = container.find("a:solidFill", C_NS)
+ if solid is not None:
+ try:
+ color, opacity = resolve_color(
+ find_color_elem(solid),
+ palette,
+ strict=False,
+ )
+ except (TypeError, ValueError, OverflowError):
+ color = None
+ opacity = 1.0
+ if color is not None:
+ return color.upper(), max(0.0, min(1.0, opacity)), True
+ gradient = container.find("a:gradFill", C_NS)
+ if gradient is not None:
+ representative = _representative_gradient_color(gradient, palette)
+ if representative is not None:
+ return representative[0].upper(), representative[1], True
+ return None, 1.0, False
+
+
+def _normalized_line_paint(
+ line: ET.Element | None,
+ palette: ColorPalette | None,
+) -> _LinePaint | None:
+ if line is None:
+ return None
+ color, opacity, explicit = _representative_fill(line, palette)
+ raw_width = line.attrib.get("w", "")
+ width = int(raw_width) / 9525.0 if raw_width.isdigit() else 1.5
+ width = max(0.0, min(1000.0, width))
+ cap = {
+ "rnd": "round",
+ "sq": "square",
+ "flat": "butt",
+ }.get(line.attrib.get("cap"), "round")
+ return _LinePaint(
+ color,
+ opacity,
+ width,
+ cap,
+ not (explicit and color is None),
+ not explicit,
+ )
+
+
+def _normalized_shape_paint(
+ sp_pr: ET.Element | None,
+ palette: ColorPalette | None,
+) -> _ShapePaint:
+ fill, opacity, explicit = _representative_fill(sp_pr, palette)
+ line = (
+ _normalized_line_paint(sp_pr.find("a:ln", C_NS), palette)
+ if sp_pr is not None
+ else None
+ )
+ return _ShapePaint(fill, opacity, explicit, line)
+
+
+def _normalized_marker_paint(
+ marker: ET.Element | None,
+ palette: ColorPalette | None,
+) -> _MarkerPaint:
+ if marker is None:
+ return _MarkerPaint(None, 5.0, _normalized_shape_paint(None, palette))
+ symbol = _element_val(marker.find("c:symbol", C_NS))
+ if symbol not in {None, "circle", "none"}:
+ symbol = "circle"
+ raw_size = _element_val(marker.find("c:size", C_NS))
+ try:
+ size = float(raw_size) if raw_size is not None else 5.0
+ except (TypeError, ValueError, OverflowError):
+ size = 5.0
+ return _MarkerPaint(
+ symbol,
+ max(2.0, min(72.0, size)),
+ _normalized_shape_paint(marker.find("c:spPr", C_NS), palette),
+ )
+
+
+def _normalized_chart_visual_styles(
+ payload: dict[str, Any],
+ plot: ET.Element,
+ palette: ColorPalette | None,
+) -> list[SeriesVisualStyle]:
+ """Keep chart data visible when source-only styling is not portable."""
+ chart_type = payload["type"]
+ series_nodes = plot.findall("c:ser", C_NS)
+ styles: list[SeriesVisualStyle] = []
+ if chart_type in {"pie", "doughnut", "of_pie"} and series_nodes:
+ expected_count = len(payload["categories"])
+ if chart_type == "of_pie":
+ expected_count += 1
+ series = series_nodes[0]
+ base_shape = _normalized_shape_paint(
+ series.find("c:spPr", C_NS),
+ palette,
+ )
+ points: dict[int, _ShapePaint] = {}
+ for point in series.findall("c:dPt", C_NS):
+ raw_index = _element_val(point.find("c:idx", C_NS))
+ if raw_index is None or not raw_index.isdigit():
+ continue
+ point_index = int(raw_index)
+ if 0 <= point_index < expected_count:
+ points[point_index] = _normalized_shape_paint(
+ point.find("c:spPr", C_NS),
+ palette,
+ )
+ for index in range(expected_count):
+ auto = _automatic_color(palette, index)
+ shape = points.get(index, base_shape)
+ fill = shape.fill if shape.fill_explicit else auto
+ line = shape.line
+ stroke = _line_color(line, "#FFFFFF")
+ styles.append(
+ SeriesVisualStyle(
+ fill=fill,
+ fill_opacity=shape.fill_opacity,
+ stroke=stroke,
+ stroke_opacity=line.opacity if line is not None else 1.0,
+ stroke_width=line.width if line is not None else 1.0,
+ line_cap=line.cap if line is not None else "round",
+ marker_fill=fill,
+ marker_stroke=stroke,
+ )
+ )
+ else:
+ for index, series in enumerate(series_nodes):
+ shape = _normalized_shape_paint(
+ series.find("c:spPr", C_NS),
+ palette,
+ )
+ if not shape.fill_explicit:
+ first_point = series.find("c:dPt/c:spPr", C_NS)
+ point_shape = _normalized_shape_paint(first_point, palette)
+ if point_shape.fill_explicit:
+ shape = _ShapePaint(
+ point_shape.fill,
+ point_shape.fill_opacity,
+ True,
+ shape.line,
+ )
+ styles.append(
+ _series_visual_style(
+ shape,
+ _normalized_marker_paint(
+ series.find("c:marker", C_NS),
+ palette,
+ ),
+ chart_type=chart_type,
+ auto_color=_automatic_color(palette, index),
+ )
+ )
+ colors = [
+ style.fill
+ or style.stroke
+ or style.marker_fill
+ or _automatic_color(palette, index)
+ for index, style in enumerate(styles)
+ ]
+ if colors:
+ payload["style"] = {"colors": colors}
+ return styles
+
+
def _strict_axis_bool(elem: ET.Element | None, default: bool) -> bool:
if elem is None:
return default
@@ -989,8 +1241,12 @@ def _validate_canonical_series_order(
expected_index += 1
-def _plot_series_indices(plot: ET.Element, status: str) -> list[int]:
+def _plot_series_identifiers(
+ plot: ET.Element,
+ status: str,
+) -> tuple[list[int], list[int]]:
indices: list[int] = []
+ orders: list[int] = []
for series in plot.findall("c:ser", C_NS):
values: list[int] = []
for child_name in ("idx", "order"):
@@ -1005,12 +1261,15 @@ def _plot_series_indices(plot: ET.Element, status: str) -> list[int]:
):
raise _UnsupportedChart(status)
values.append(int(raw_value))
- if values[0] != values[1]:
- raise _UnsupportedChart(status)
indices.append(values[0])
- if not indices or len(set(indices)) != len(indices):
+ orders.append(values[1])
+ if (
+ not indices
+ or len(set(indices)) != len(indices)
+ or len(set(orders)) != len(orders)
+ ):
raise _UnsupportedChart(status)
- return indices
+ return indices, orders
def _axis_number(elem: ET.Element | None) -> int | float | None:
@@ -1605,7 +1864,26 @@ def _validate_chart_semantics(
"""Reject valid chart features the compact marker cannot reproduce."""
chart_type = payload["type"]
grouping = payload.get("grouping")
- visual_styles = _chart_visual_styles(payload, plot, palette)
+ try:
+ visual_styles = _chart_visual_styles(payload, plot, palette)
+ except _UnsupportedChart as exc:
+ if (
+ exc.status != "unsupported-chart-series-style"
+ or palette is None
+ or palette.strict
+ ):
+ raise
+ palette._diagnose(
+ "chart-series-style-normalized",
+ "Chart series styling uses DrawingML features outside the native "
+ "flat-color chart contract",
+ "preserve chart data and use representative flat series colors",
+ )
+ visual_styles = _normalized_chart_visual_styles(
+ payload,
+ plot,
+ palette,
+ )
for tag in (
"trendline", "errBars", "dropLines", "hiLowLines", "upDownBars",
):
@@ -1700,9 +1978,13 @@ def _validate_chart_semantics(
if first_slice not in {None, "0"}:
raise _UnsupportedChart("unsupported-chart-pie-options")
if chart_type == "doughnut":
- hole_size = _element_val(plot.find("c:holeSize", C_NS))
- if hole_size != "75":
+ raw_hole_size = _element_val(plot.find("c:holeSize", C_NS))
+ if raw_hole_size is None or not raw_hole_size.isdigit():
raise _UnsupportedChart("unsupported-chart-doughnut-options")
+ hole_size = int(raw_hole_size)
+ if not 10 <= hole_size <= 90:
+ raise _UnsupportedChart("unsupported-chart-doughnut-options")
+ payload["hole_size"] = hole_size
if chart_type == "of_pie":
for tag in ("splitType", "splitPos", "custSplit"):
if plot.find(f"c:{tag}", C_NS) is not None:
@@ -1716,14 +1998,74 @@ def _validate_chart_semantics(
return visual_styles
-def _apply_plot_data_labels(payload: dict[str, Any], plot: ET.Element) -> None:
- if plot.find("c:ser/c:dLbls", C_NS) is not None:
+def _apply_plot_data_labels(
+ payload: dict[str, Any],
+ plot: ET.Element,
+ *,
+ palette: ColorPalette | None = None,
+) -> None:
+ chart_type = str(payload.get("type") or "")
+ series_nodes = plot.findall("c:ser", C_NS)
+ series_payloads = payload.get("series")
+ if not isinstance(series_payloads, list) or len(series_payloads) != len(series_nodes):
raise _UnsupportedChart("unsupported-chart-series-data-labels")
- data_labels = _data_labels_payload(plot.find("c:dLbls", C_NS))
+
+ for series_node, series_payload in zip(series_nodes, series_payloads):
+ dlabels = series_node.find("c:dLbls", C_NS)
+ if dlabels is None:
+ continue
+ if chart_type not in {"area", "bar", "column", "line"}:
+ if palette is None or palette.strict:
+ raise _UnsupportedChart("unsupported-chart-series-data-labels")
+ palette._diagnose(
+ "chart-series-data-labels-omitted",
+ "Per-series chart data labels are outside the native contract "
+ f"for {chart_type} charts",
+ "keep the chart data and any supported plot-level labels",
+ )
+ continue
+ point_count = len(series_payload.get("values") or [])
+ try:
+ series_labels = _data_labels_payload(
+ dlabels,
+ palette=palette,
+ point_count=point_count,
+ preserve_source=True,
+ )
+ if not series_labels:
+ continue
+ validate_data_label_position(
+ series_labels.get("position"),
+ chart_type,
+ payload.get("grouping"),
+ )
+ except (_UnsupportedChart, RuntimeError):
+ if palette is None or palette.strict:
+ raise _UnsupportedChart("unsupported-chart-series-data-labels") from None
+ palette._diagnose(
+ "chart-series-data-labels-omitted",
+ "Source per-series chart data labels could not be normalized",
+ "keep the chart data and any supported plot-level labels",
+ )
+ continue
+ series_payload["data_labels"] = series_labels
+
+ data_labels = _data_labels_payload(
+ plot.find("c:dLbls", C_NS),
+ palette=palette,
+ )
if not data_labels:
return
if payload["type"] not in {"area", "bar", "column", "line"}:
- raise _UnsupportedChart("unsupported-chart-data-labels")
+ if palette is None or palette.strict:
+ raise _UnsupportedChart("unsupported-chart-data-labels")
+ palette._diagnose(
+ "chart-data-labels-normalized",
+ "Source data-label options are outside the native contract for "
+ f'{payload["type"]} charts',
+ "use the normalized SVG chart label layout",
+ )
+ return
try:
validate_data_label_position(
data_labels.get("position"),
@@ -1741,6 +2083,7 @@ def _apply_chart_metadata(
plot_area: ET.Element,
plot: ET.Element,
*,
+ palette: ColorPalette | None = None,
include_plot_labels: bool = True,
) -> None:
"""Copy visible classic-chart chrome supported by the native schema."""
@@ -1770,7 +2113,7 @@ def _apply_chart_metadata(
payload["legend_position"] = position
if include_plot_labels:
- _apply_plot_data_labels(payload, plot)
+ _apply_plot_data_labels(payload, plot, palette=palette)
axis_titles: dict[str, str] = {}
category_axis_nodes = (
@@ -2059,13 +2402,181 @@ def _data_label_text_style(tx_pr: ET.Element) -> dict[str, Any]:
return style
-def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None:
+_DATA_LABEL_POSITION_ALIASES = {
+ "bestFit": "best_fit",
+ "ctr": "center",
+ "inBase": "inside_base",
+ "inEnd": "inside_end",
+ "outEnd": "outside_end",
+ "t": "above",
+}
+
+
+def _data_label_position_payload(owner: ET.Element) -> str | None:
+ position = _element_val(owner.find("c:dLblPos", C_NS))
+ if not position:
+ return None
+ normalized = _DATA_LABEL_POSITION_ALIASES.get(position)
+ if normalized is None:
+ raise _UnsupportedChart("unsupported-chart-data-labels")
+ return normalized
+
+
+def _data_label_flag_payload(
+ owner: ET.Element,
+ *,
+ include_missing: bool,
+) -> dict[str, bool]:
+ config: dict[str, bool] = {}
+ for tag, field in (
+ ("showVal", "show_value"),
+ ("showCatName", "show_category"),
+ ("showSerName", "show_series"),
+ ("showPercent", "show_percent"),
+ ):
+ elem = owner.find(f"c:{tag}", C_NS)
+ if elem is not None:
+ config[field] = ooxml_bool(elem.attrib.get("val"), True)
+ elif include_missing:
+ config[field] = False
+ return config
+
+
+def _source_data_label_text_style(
+ owner: ET.Element,
+ palette: ColorPalette | None,
+) -> dict[str, Any]:
+ """Read visible label run properties without requiring canonical OOXML."""
+ r_pr = owner.find(".//a:defRPr", C_NS)
+ if r_pr is None:
+ r_pr = owner.find(".//a:rPr", C_NS)
+ if r_pr is None:
+ return {}
+
+ style: dict[str, Any] = {}
+ raw_size = r_pr.attrib.get("sz")
+ if raw_size is not None:
+ try:
+ size_px = float(raw_size) / 75.0
+ except ValueError:
+ size_px = 0.0
+ if size_px > 0 and math.isfinite(size_px):
+ style["font_size"] = (
+ int(size_px) if size_px.is_integer() else round(size_px, 3)
+ )
+ if r_pr.attrib.get("b") is not None:
+ style["bold"] = ooxml_bool(r_pr.attrib.get("b"), True)
+
+ solid_fill = r_pr.find("a:solidFill", C_NS)
+ color_elem = find_color_elem(solid_fill)
+ if color_elem is not None:
+ color, alpha = resolve_color(color_elem, palette)
+ if alpha > 0:
+ style["color"] = color
+
+ latin = r_pr.find("a:latin", C_NS)
+ east_asian = r_pr.find("a:ea", C_NS)
+ latin_face = latin.attrib.get("typeface", "").strip() if latin is not None else ""
+ east_asian_face = (
+ east_asian.attrib.get("typeface", "").strip()
+ if east_asian is not None else ""
+ )
+ font_face = (
+ f"{latin_face}, {east_asian_face}"
+ if latin_face and east_asian_face and latin_face != east_asian_face
+ else latin_face or east_asian_face
+ )
+ if font_face:
+ style["font_family"] = font_face
+ return style
+
+
+def _point_data_label_payload(
+ dlabel: ET.Element,
+ *,
+ palette: ColorPalette | None,
+ point_count: int,
+) -> dict[str, Any]:
+ allowed_children = {
+ "dLblPos", "delete", "extLst", "idx", "numFmt", "showBubbleSize",
+ "showCatName", "showLegendKey", "showPercent", "showSerName",
+ "showVal", "spPr", "tx", "txPr",
+ }
+ if any(_local_name(child.tag) not in allowed_children for child in dlabel):
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ idx = _element_val(dlabel.find("c:idx", C_NS))
+ if idx is None or not idx.isdigit():
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ point_index = int(idx)
+ if point_index < 0 or point_index >= point_count:
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+
+ for tag in ("showLegendKey", "showBubbleSize"):
+ elem = dlabel.find(f"c:{tag}", C_NS)
+ if elem is not None and ooxml_bool(elem.attrib.get("val"), True):
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+
+ item: dict[str, Any] = {"idx": point_index}
+ delete = dlabel.find("c:delete", C_NS)
+ if delete is not None:
+ item["delete"] = ooxml_bool(delete.attrib.get("val"), True)
+ item.update(_data_label_flag_payload(dlabel, include_missing=False))
+ position = _data_label_position_payload(dlabel)
+ if position:
+ item["position"] = position
+ num_fmt = dlabel.find("c:numFmt", C_NS)
+ if num_fmt is not None and num_fmt.attrib.get("formatCode"):
+ item["number_format"] = num_fmt.attrib["formatCode"]
+ tx_pr = dlabel.find("c:txPr", C_NS)
+ if tx_pr is not None:
+ item.update(_source_data_label_text_style(tx_pr, palette))
+
+ tx = dlabel.find("c:tx", C_NS)
+ if tx is not None:
+ rich = tx.find("c:rich", C_NS)
+ if rich is None or len(list(tx)) != 1:
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ paragraphs = rich.findall("a:p", C_NS)
+ if not paragraphs:
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ lines = ["".join(node.text or "" for node in p.findall(".//a:t", C_NS)) for p in paragraphs]
+ text = "\n".join(lines)
+ if not text:
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ item["text"] = text
+ item.update(_source_data_label_text_style(rich, palette))
+ return item
+
+
+def _source_data_labels_ooxml(dlabels: ET.Element) -> dict[str, str]:
+ if any(
+ isinstance(name, str) and name.startswith(f"{{{NS['r']}}}")
+ for node in dlabels.iter()
+ for name in node.attrib
+ ):
+ raise _UnsupportedChart("unsupported-chart-series-data-labels")
+ payload = ET.tostring(dlabels, encoding="utf-8")
+ return {
+ "encoding": "base64",
+ "payload": base64.b64encode(payload).decode("ascii"),
+ "sha256": hashlib.sha256(payload).hexdigest(),
+ }
+
+
+def _data_labels_payload(
+ dlabels: ET.Element | None,
+ *,
+ palette: ColorPalette | None = None,
+ point_count: int | None = None,
+ preserve_source: bool = False,
+) -> dict[str, Any] | None:
if dlabels is None:
return None
- if dlabels.find("c:dLbl", C_NS) is not None:
+ point_nodes = dlabels.findall("c:dLbl", C_NS)
+ if point_nodes and point_count is None:
raise _UnsupportedChart("unsupported-chart-point-labels")
allowed_children = {
- "numFmt", "txPr", "dLblPos", "showLegendKey", "showVal",
+ "dLbl", "extLst", "numFmt", "spPr", "txPr", "dLblPos", "showLegendKey", "showVal",
"showCatName", "showSerName", "showPercent", "showBubbleSize",
"showLeaderLines",
}
@@ -2079,20 +2590,10 @@ def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None:
if elem is not None and ooxml_bool(elem.attrib.get("val"), True):
raise _UnsupportedChart("unsupported-chart-data-labels")
- config: dict[str, Any] = {}
- for tag, field in (
- ("showVal", "show_value"),
- ("showCatName", "show_category"),
- ("showSerName", "show_series"),
- ("showPercent", "show_percent"),
- ):
- elem = dlabels.find(f"c:{tag}", C_NS)
- config[field] = (
- ooxml_bool(elem.attrib.get("val"), True)
- if elem is not None else False
- )
- if not any(config.values()):
- return None
+ config: dict[str, Any] = _data_label_flag_payload(
+ dlabels,
+ include_missing=True,
+ )
leader_lines = dlabels.find("c:showLeaderLines", C_NS)
if leader_lines is not None:
@@ -2101,26 +2602,40 @@ def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None:
True,
)
- position = _element_val(dlabels.find("c:dLblPos", C_NS))
+ position = _data_label_position_payload(dlabels)
if position:
- position_aliases = {
- "bestFit": "best_fit",
- "ctr": "center",
- "inBase": "inside_base",
- "inEnd": "inside_end",
- "outEnd": "outside_end",
- "t": "above",
- }
- normalized_position = position_aliases.get(position)
- if normalized_position is None:
- raise _UnsupportedChart("unsupported-chart-data-labels")
- config["position"] = normalized_position
+ config["position"] = position
num_fmt = dlabels.find("c:numFmt", C_NS)
if num_fmt is not None and num_fmt.attrib.get("formatCode"):
config["number_format"] = num_fmt.attrib["formatCode"]
tx_pr = dlabels.find("c:txPr", C_NS)
if tx_pr is not None:
- config.update(_data_label_text_style(tx_pr))
+ try:
+ config.update(_data_label_text_style(tx_pr))
+ except _UnsupportedChart:
+ config.update(_source_data_label_text_style(tx_pr, palette))
+ if point_nodes:
+ assert point_count is not None
+ points = [
+ _point_data_label_payload(
+ point,
+ palette=palette,
+ point_count=point_count,
+ )
+ for point in point_nodes
+ ]
+ if len({point["idx"] for point in points}) != len(points):
+ raise _UnsupportedChart("unsupported-chart-point-labels")
+ config["points"] = points
+ if preserve_source:
+ config["source_ooxml"] = _source_data_labels_ooxml(dlabels)
+ if not any(
+ config.get(field)
+ for field in (
+ "points", "show_category", "show_percent", "show_series", "show_value",
+ )
+ ):
+ return None
return config
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py
index 71a7314d..60b12391 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py
@@ -13,6 +13,8 @@ loads the package and reports basic per-slide structure to verify wiring.
from __future__ import annotations
+import base64
+import hashlib
import json
import os
import re
@@ -23,11 +25,30 @@ from dataclasses import dataclass, field
from html import unescape
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit
+from xml.etree import ElementTree as ET
+from xml.sax.saxutils import quoteattr
+from pptx_embedded_fonts import (
+ FONT_BUNDLE_DIR,
+ EmbeddedFontBundle,
+ EmbeddedFontError,
+ capture_embedded_fonts,
+ write_embedded_font_bundle,
+)
from svg_to_pptx.animation_config import (
validate_animation_config_errors,
validate_transition_config,
)
+from template_import.manifest import (
+ count_drawable_shapes,
+ extract_placeholders,
+ part_display_name,
+)
+from template_import.native_structure import (
+ CONTRACT_NAME as NATIVE_STRUCTURE_NAME,
+ SOURCE_TEMPLATE_NAME,
+ build_native_structure,
+)
from .animation_import import (
AnimationImportError,
@@ -164,6 +185,10 @@ class ConvertOptions:
that wants self-contained slides (preview pages, screenshot pipelines).
strict: stop on the first unsupported or malformed source construct.
Default False keeps usable content and records structured diagnostics.
+ roundtrip: preserve a validated source-package structure sidecar and mark
+ layered slide SVG roots with their exact source Layout identities.
+ This is an opt-in diagnostic path for reconstructing the imported deck;
+ it does not make SVG a lossless container for arbitrary PPTX semantics.
"""
media_subdir: str = "assets"
@@ -172,6 +197,7 @@ class ConvertOptions:
inheritance_mode: str = "both"
asset_name_map: dict[str, str] = field(default_factory=dict)
strict: bool = False
+ roundtrip: bool = False
@dataclass
@@ -214,6 +240,10 @@ class ConvertResult:
canvas_px: tuple[float, float] = (1280.0, 720.0)
theme_colors: dict[str, str] = field(default_factory=dict)
theme_fonts: dict[str, str] = field(default_factory=dict)
+ theme_xml: bytes | None = None
+ embedded_fonts: EmbeddedFontBundle | None = None
+ native_structure: dict[str, object] | None = None
+ source_pptx_path: Path | None = None
layouts: list[PartArtifact] = field(default_factory=list)
masters: list[PartArtifact] = field(default_factory=list)
flat_slides: list[SlideArtifact] = field(default_factory=list)
@@ -258,6 +288,137 @@ def _palette_diagnostic_sink(
return _record
+def _roundtrip_native_structure(
+ pkg: OoxmlPackage,
+ pptx_path: Path,
+) -> dict[str, object]:
+ """Build the existing validated source-structure contract without assets."""
+ masters = list(pkg.iter_all_masters())
+ layouts_with_parents = list(pkg.iter_all_layouts_with_parent())
+ slides = list(pkg.iter_slides())
+ used_layouts: dict[str, list[int]] = {}
+ used_masters: dict[str, list[int]] = {}
+ for slide in slides:
+ if slide.layout is not None:
+ used_layouts.setdefault(slide.layout.path, []).append(slide.index)
+ if slide.master is not None:
+ used_masters.setdefault(slide.master.path, []).append(slide.index)
+
+ manifest: dict[str, object] = {
+ "slideSize": {
+ "width_emu": pkg.slide_size_emu[0],
+ "height_emu": pkg.slide_size_emu[1],
+ "width_px": pkg.slide_size_px[0],
+ "height_px": pkg.slide_size_px[1],
+ },
+ "masters": [
+ {
+ "path": master.path,
+ "displayName": part_display_name(master.xml, master.path),
+ "drawableShapeCount": count_drawable_shapes(master.xml),
+ "usedBySlides": used_masters.get(master.path, []),
+ }
+ for master in masters
+ ],
+ "layouts": [
+ {
+ "path": layout.path,
+ "displayName": part_display_name(layout.xml, layout.path),
+ "parentPath": master.path,
+ "showMasterShapes": part_show_master_sp(layout),
+ "drawableShapeCount": count_drawable_shapes(layout.xml),
+ "placeholders": extract_placeholders(layout.xml),
+ "usedBySlides": used_layouts.get(layout.path, []),
+ }
+ for layout, master in layouts_with_parents
+ ],
+ "slides": [
+ {
+ "index": slide.index,
+ "layoutPath": slide.layout.path if slide.layout else None,
+ "masterPath": slide.master.path if slide.master else None,
+ "showInheritedShapes": part_show_master_sp(slide.part),
+ "placeholders": extract_placeholders(slide.part.xml),
+ "svgFile": f"slide_{slide.index:02d}.svg",
+ }
+ for slide in slides
+ ],
+ }
+ contract = build_native_structure(pptx_path, manifest)
+ if not contract["strategy"]["preservationEligible"]:
+ raise RuntimeError(
+ "Round-trip mode requires a complete source master/layout graph"
+ )
+ return contract
+
+
+def _annotate_roundtrip_slide_roots(
+ slides: list[SlideArtifact],
+ contract: dict[str, object],
+) -> None:
+ """Attach exact Layout identity to layered SVG roots for reverse export."""
+ raw_layouts = contract.get("layouts")
+ raw_masters = contract.get("masters")
+ raw_slides = contract.get("slides")
+ if not all(isinstance(value, list) for value in (
+ raw_layouts,
+ raw_masters,
+ raw_slides,
+ )):
+ raise RuntimeError("Generated round-trip source structure is incomplete")
+ layouts = {
+ str(item.get("key")): item
+ for item in raw_layouts
+ if isinstance(item, dict)
+ }
+ masters = {
+ str(item.get("key")): item
+ for item in raw_masters
+ if isinstance(item, dict)
+ }
+ slide_rows = {
+ int(item["index"]): item
+ for item in raw_slides
+ if isinstance(item, dict) and isinstance(item.get("index"), int)
+ }
+ for slide in slides:
+ row = slide_rows.get(slide.index)
+ if row is None:
+ raise RuntimeError(
+ f"Round-trip source structure has no slide {slide.index}"
+ )
+ layout_key = str(row.get("layoutKey") or "")
+ master_key = str(row.get("masterKey") or "")
+ layout = layouts.get(layout_key)
+ master = masters.get(master_key)
+ if layout is None or master is None:
+ raise RuntimeError(
+ f"Round-trip slide {slide.index} has an unresolved Layout/Master"
+ )
+ attrs = {
+ "data-pptx-layout": layout_key,
+ "data-pptx-layout-name": str(layout.get("name") or layout_key),
+ "data-pptx-master": master_key,
+ "data-pptx-master-name": str(master.get("name") or master_key),
+ "data-pptx-show-master-shapes": (
+ "true" if layout.get("showMasterShapes", True) else "false"
+ ),
+ "data-pptx-show-inherited-shapes": (
+ "true" if row.get("showInheritedShapes", True) else "false"
+ ),
+ }
+ marker = slide.svg.find(">")
+ if not slide.svg.startswith(""
)
return f"{shape_xml}\n{text_group}"
@@ -658,18 +693,21 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
if geom_xml:
inner_parts.append(geom_xml)
if (
- source_tx_body is not None
+ export_tx_body is not None
and geom is not None
and not text_result.contains_inline_formula
):
inner_parts.append(
_txbody_metadata(
- source_tx_body,
- text_result.svg,
+ export_tx_body,
+ visible_text_svg,
)
)
- if text_result.svg:
- inner_parts.append(text_result.svg)
+ placeholder_sp_pr = _placeholder_sp_pr_metadata(node, ctx)
+ if placeholder_sp_pr:
+ inner_parts.append(placeholder_sp_pr)
+ if visible_text_svg:
+ inner_parts.append(visible_text_svg)
inner = "\n".join(inner_parts) if inner_parts else ""
return _wrap_shape_group(
inner,
@@ -725,6 +763,114 @@ def _effective_placeholder_tx_body(
return effective
+def _materialize_inherited_list_styles(
+ tx_body: ET.Element | None,
+ inherited_lst_styles: tuple[ET.Element, ...],
+) -> tuple[ET.Element | None, bool]:
+ """Flatten placeholder list-style inheritance into the preserved txBody."""
+ if not inherited_lst_styles:
+ return tx_body, True
+ if tx_body is None:
+ return None, False
+
+ effective = copy.deepcopy(tx_body)
+ lst_style = effective.find("a:lstStyle", NS)
+ if lst_style is None:
+ lst_style = ET.Element(f"{{{NS['a']}}}lstStyle")
+ body_pr = effective.find("a:bodyPr", NS)
+ insert_at = list(effective).index(body_pr) + 1 if body_pr is not None else 0
+ effective.insert(insert_at, lst_style)
+
+ for level in range(1, 10):
+ local_level = lst_style.find(f"a:lvl{level}pPr", NS)
+ inherited_levels = [
+ level_pr
+ for inherited in inherited_lst_styles
+ if (level_pr := inherited.find(f"a:lvl{level}pPr", NS)) is not None
+ ]
+ if local_level is None and not inherited_levels:
+ continue
+
+ merged = ET.Element(f"{{{NS['a']}}}lvl{level}pPr")
+ for source in reversed(inherited_levels):
+ _merge_text_property_element(merged, source)
+ if local_level is not None:
+ _merge_text_property_element(merged, local_level)
+
+ if local_level is None:
+ lst_style.append(merged)
+ else:
+ index = list(lst_style).index(local_level)
+ lst_style.remove(local_level)
+ lst_style.insert(index, merged)
+
+ return effective, True
+
+
+def _merge_text_property_element(
+ target: ET.Element,
+ source: ET.Element,
+) -> None:
+ """Overlay one DrawingML paragraph/run property node by choice group."""
+ target.attrib.update(source.attrib)
+ for source_child in source:
+ key = _text_property_child_key(source_child)
+ target_child = next(
+ (
+ child
+ for child in target
+ if _text_property_child_key(child) == key
+ ),
+ None,
+ )
+ if (
+ source_child.tag == f"{{{NS['a']}}}defRPr"
+ and target_child is not None
+ ):
+ _merge_text_property_element(target_child, source_child)
+ continue
+ if target_child is not None:
+ index = list(target).index(target_child)
+ target.remove(target_child)
+ target.insert(index, copy.deepcopy(source_child))
+ else:
+ target.append(copy.deepcopy(source_child))
+
+
+def _text_property_child_key(child: ET.Element) -> str:
+ """Return the OOXML choice-group key for one text-property child."""
+ name = child.tag.rsplit("}", 1)[-1]
+ groups = (
+ ("fill", {
+ "noFill", "solidFill", "gradFill", "blipFill", "pattFill", "grpFill",
+ }),
+ ("effect", {"effectLst", "effectDag"}),
+ ("bullet-color", {"buClrTx", "buClr"}),
+ ("bullet-size", {"buSzTx", "buSzPct", "buSzPts"}),
+ ("bullet-font", {"buFontTx", "buFont"}),
+ ("bullet-kind", {"buNone", "buAutoNum", "buChar", "buBlip"}),
+ ("underline-line", {"uLnTx", "uLn"}),
+ ("underline-fill", {"uFillTx", "uFill"}),
+ )
+ for key, names in groups:
+ if name in names:
+ return key
+ return name
+
+
+def _counter_reflected_text_svg(text_svg: str, xfrm: Xfrm) -> str:
+ """Keep text upright when DrawingML flips its owning shape geometry."""
+ if not text_svg or xfrm.rot or not (xfrm.flip_h or xfrm.flip_v):
+ return text_svg
+ transform = xfrm.to_svg_transform()
+ if not transform:
+ return text_svg
+ return (
+ '\n{text_svg}\n'
+ )
+
+
def _block_formula_zone(tx_body: ET.Element | None) -> ET.Element | None:
"""Return the sole block-math zone from a canonical formula text body."""
if tx_body is None:
@@ -954,6 +1100,29 @@ def _txbody_metadata(
)
+def _placeholder_sp_pr_metadata(
+ node: ShapeNode,
+ ctx: AssemblyContext,
+) -> str:
+ """Preserve relationship-free local placeholder geometry for inheritance."""
+ if (
+ not ctx.preserve_placeholder_inheritance
+ or node.placeholder is None
+ or node.kind != SHAPE
+ ):
+ return ""
+ sp_pr = node.xml.find("p:spPr", NS)
+ if sp_pr is None or has_relationship_attributes(sp_pr):
+ return ""
+ raw = ET.tostring(sp_pr, encoding="utf-8")
+ return (
+ ''
+ f'{base64.b64encode(raw).decode("ascii")}'
+ )
+
+
def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult | None:
"""Resolve a DrawingML shape geometry into an absolute SVG geometry model."""
prst_geom = sp_pr.find("a:prstGeom", NS) if sp_pr is not None else None
@@ -1027,6 +1196,7 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
ctx.palette,
id_prefix="g",
id_seq=ctx.grad_seq,
+ group_fill=ctx.group_fills[-1] if ctx.group_fills else None,
)
except ValueError as exc:
if ctx.strict:
@@ -1044,6 +1214,12 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
id_prefix="m",
id_seq=ctx.marker_seq,
style_stroke_default=style_defaults.get("stroke"),
+ gradient_frame=(
+ node.xfrm.x,
+ node.xfrm.y,
+ node.xfrm.w,
+ node.xfrm.h,
+ ),
)
except ValueError as exc:
if ctx.strict:
@@ -1133,15 +1309,17 @@ def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict
defaults: dict[str, str] = {}
fill_ref = style.find("a:fillRef", NS)
- fill_color = _resolve_ref_color(fill_ref, ctx)
- if fill_color:
- defaults["fill"] = fill_color
+ if fill_ref is not None and fill_ref.attrib.get("idx", "").strip() != "0":
+ fill_color = _resolve_ref_color(fill_ref, ctx)
+ if fill_color:
+ defaults["fill"] = fill_color
ln_ref = style.find("a:lnRef", NS)
- line_color = _resolve_ref_color(ln_ref, ctx)
- if line_color:
- defaults["stroke"] = line_color
- defaults.setdefault("stroke-width", "1")
+ if ln_ref is not None and ln_ref.attrib.get("idx", "").strip() != "0":
+ line_color = _resolve_ref_color(ln_ref, ctx)
+ if line_color:
+ defaults["stroke"] = line_color
+ defaults.setdefault("stroke-width", "1")
return defaults
@@ -1206,9 +1384,40 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None,
if geom.tag == "rect" and not geom.attrs.get("rx") and not geom.attrs.get("ry"):
return image_xml
+ clip_geom = geom
+ if image_xml.startswith("