Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-08-26 00:01:55 +08:00
parent 0ce7b1ea65
commit 31e4a7ce5e
48 changed files with 4260 additions and 322 deletions
+10 -10
View File
@@ -33,8 +33,8 @@
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "13179471f97162b3297558621a76682438caf017",
"syncedAt": "2026-08-24T16:00:00Z"
"commit": "e353a508767c6d39f0e7698b084dbfc8699fffd3",
"syncedAt": "2026-08-25T16:00:00Z"
},
{
"id": "caveman",
@@ -42,8 +42,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "7bb71309e8749a4f112aacd3a54b3941d8689905",
"syncedAt": "2026-08-23T16:00:00Z"
"commit": "81536f57b3303b7de7f5bc5b564cc344f9112d68",
"syncedAt": "2026-08-25T16:00:00Z"
},
{
"id": "taste-skill",
@@ -60,8 +60,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "b9938d94635fca7a4560449713b0b1ba87d77bc6",
"syncedAt": "2026-08-24T16:00:00Z"
"commit": "ee628d75dea87325735fafa7c54f5d7d7edb8774",
"syncedAt": "2026-08-25T16:00:00Z"
},
{
"id": "frontend-slides",
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "e2b4e6a7c43594a66fb477d00e18267ce50bbd93",
"syncedAt": "2026-08-24T16:00:00Z"
"commit": "ebd74d1f1d61a686f0f80e10abde5029fc4beeca",
"syncedAt": "2026-08-25T16:00:00Z"
},
{
"id": "grill-me",
@@ -114,8 +114,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "30e73d2d14803ff252638caa3d67f047a89e4bb5",
"syncedAt": "2026-08-24T16:00:00Z"
"commit": "d79b0d09cbed2a6d6a5f28680ab621e651326c30",
"syncedAt": "2026-08-25T16:00:00Z"
}
]
}
@@ -2,8 +2,8 @@
"sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"commit": "7bb71309e8749a4f112aacd3a54b3941d8689905",
"commit": "81536f57b3303b7de7f5bc5b564cc344f9112d68",
"adapter": "codex-plugin",
"sourcePath": "plugins/caveman",
"syncedAt": "2026-08-23T16:00:00Z"
"syncedAt": "2026-08-25T16:00:00Z"
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-08-24T16:01:54Z"
"syncedAt": "2026-08-25T16:01:54Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "30e73d2d14803ff252638caa3d67f047a89e4bb5",
"commit": "d79b0d09cbed2a6d6a5f28680ab621e651326c30",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-08-24T16:00:00Z"
"syncedAt": "2026-08-25T16:00:00Z"
}
+9 -1
View File
@@ -12,8 +12,14 @@
English | [中文](./README_CN.md)
## ❤️ Sponsors
This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.host/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.
> **[Want to appear here?](SPONSORING.md)**
<details open>
<summary>This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.host/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.</summary>
<summary>Click to collapse</summary>
<p align="center">
<a href="https://www.kimi.com/code/?aff=ppt-master"><img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" alt="Kimi" width="100%"></a>
@@ -422,6 +428,8 @@ PPT Master is currently built and maintained primarily by me. Every new template
&nbsp;
<a href="https://m.do.co/c/547f129aabe1"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" height="40" /></a>
**[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.
@@ -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"
}
@@ -27,7 +27,7 @@ python3 scripts/source_to_md/pdf_to_md.py <file.pdf>
# or
python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
python3 scripts/source_to_md/excel_to_md.py <workbook.xlsx>
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py init <project_name>
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>
python3 scripts/total_md_split.py <project_path>
python3 scripts/finalize_svg.py <project_path>
@@ -79,7 +79,7 @@ python3 scripts/pptx_to_svg.py <deck.pptx> -o <output_dir> # reconstruction/ref
Project setup:
```bash
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...>
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
@@ -88,6 +88,11 @@ python3 scripts/project_manager.py page-context <project_path> P07 --record-usag
python3 scripts/project_manager.py page-context-report <project_path>
```
`--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 `<name>_<YYYYMMDD>`, 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 <project_name> --format ppt169
python3 scripts/project_manager.py init <project_name>
python3 scripts/project_manager.py import-sources <project_path> <source.pptx> <material...>
# Manual fallback when import-sources did not produce analysis/<stem>.slide_library.json:
python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
@@ -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 <project_name> --format ppt169
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...]
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
@@ -22,6 +22,13 @@ python3 scripts/project_manager.py page-context-report <project_path>
```
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 `<name>_<YYYYMMDD>`; 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
`<name>_<format>_<YYYYMMDD>` 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`
@@ -553,6 +553,8 @@ python3 scripts/svg_to_pptx.py <project_path> --no-image-optimize
python3 scripts/svg_to_pptx.py <project_path> --native-charts-and-tables
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure structured # deck/layout template override
python3 scripts/svg_to_pptx.py <project_path> --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 <pptx_import_output> -s svg --roundtrip
# Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py <template_import_output> -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 `<text>` 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
@@ -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'
},
@@ -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 <g> 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)
@@ -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 <registered_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")
@@ -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()
@@ -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
@@ -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 ""
@@ -5,6 +5,7 @@ Usage:
python3 pptx_to_svg.py <pptx_file> [-o <output_dir>] [--embed-images]
[--media-subdir <name>] [--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
@@ -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")
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"}:
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
config: dict[str, Any] = _data_label_flag_payload(
dlabels,
include_missing=True,
)
if not any(config.values()):
return None
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:
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
@@ -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("<svg ") or marker < 0:
raise RuntimeError(
f"Round-trip slide {slide.index} does not have a canonical SVG root"
)
serialized = "".join(
f" {name}={quoteattr(value)}"
for name, value in attrs.items()
)
slide.svg = slide.svg[:marker] + serialized + slide.svg[marker:]
def _make_palette(
master: PartRef | None,
theme: PartRef | None,
@@ -306,6 +467,11 @@ def convert_pptx_to_svg(
f"inheritance_mode must be 'flat', 'layered', or 'both', "
f"got {options.inheritance_mode!r}"
)
if options.roundtrip and options.inheritance_mode == "flat":
raise ValueError(
"roundtrip requires inheritance_mode 'layered' or 'both' so source "
"Master/Layout visuals are not duplicated on regenerated slides"
)
if not options.embed_images:
_validate_media_subdir(options.media_subdir)
emit_layered = options.inheritance_mode in {"layered", "both"}
@@ -332,6 +498,29 @@ def convert_pptx_to_svg(
)
if default_theme is not None:
result.theme_colors, result.theme_fonts = _extract_theme_info(default_theme, palette)
result.theme_xml = ET.tostring(default_theme.xml, encoding="utf-8")
if pkg.presentation is not None and pkg.zip is not None:
try:
result.embedded_fonts = capture_embedded_fonts(
pkg.presentation.xml,
pkg.presentation.rels,
pkg.zip.read,
)
except EmbeddedFontError as exc:
if options.strict:
raise
append_diagnostic(
result.diagnostics,
ImportDiagnostic(
code="embedded-fonts-omitted",
message=str(exc),
fallback=(
"keep editable text and rely on an installed or "
"substitute font"
),
part_path=pkg.presentation.path,
),
)
for master in pkg.iter_all_masters():
theme = pkg.resolve_theme(master) or default_theme
@@ -369,7 +558,11 @@ def convert_pptx_to_svg(
part_path=slide.part.path,
slide_index=slide.index,
)
_colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
_colors, slide_fonts = (
_extract_theme_info(slide_theme, slide_palette)
if slide_theme is not None
else ({}, result.theme_fonts)
)
artifact = _convert_slide(
pkg,
slide,
@@ -398,7 +591,11 @@ def convert_pptx_to_svg(
part_path=slide.part.path,
slide_index=slide.index,
)
_colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
_colors, slide_fonts = (
_extract_theme_info(slide_theme, slide_palette)
if slide_theme is not None
else ({}, result.theme_fonts)
)
artifact = _convert_slide(
pkg,
slide,
@@ -413,6 +610,13 @@ def convert_pptx_to_svg(
# Layered mode: also render each master / layout once.
if emit_layered:
_convert_inheritance_parts(pkg, default_theme, options, result)
if options.roundtrip:
result.native_structure = _roundtrip_native_structure(pkg, pptx_path)
result.source_pptx_path = pptx_path
_annotate_roundtrip_slide_roots(
result.slides,
result.native_structure,
)
if output_dir is not None:
_write_artifacts(output_dir, result, options)
@@ -545,6 +749,9 @@ def _convert_slide(
asset_name_map=options.asset_name_map,
strict=options.strict,
diagnostics=diagnostics,
preserve_placeholder_inheritance=(
options.roundtrip and mode == "layered"
),
)
return SlideArtifact(
index=slide.index,
@@ -704,6 +911,29 @@ def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
return set()
managed = {Path("animations.json")}
if artifacts.get("sourceTemplate") == SOURCE_TEMPLATE_NAME:
managed.add(Path(SOURCE_TEMPLATE_NAME))
if artifacts.get("nativeStructure") == NATIVE_STRUCTURE_NAME:
managed.add(Path(NATIVE_STRUCTURE_NAME))
embedded_font_paths = [artifacts.get("embeddedFontManifest")]
raw_font_parts = artifacts.get("embeddedFontParts")
if isinstance(raw_font_parts, list):
embedded_font_paths.extend(raw_font_parts)
font_prefix = FONT_BUNDLE_DIR.parts
for value in embedded_font_paths:
if not isinstance(value, str):
continue
path = Path(value)
if (
path.drive
or path.anchor
or path.is_absolute()
or ".." in path.parts
or path.parts[:len(font_prefix)] != font_prefix
or path.suffix.lower() not in {".json", ".fntdata"}
):
continue
managed.add(path)
animation_media = artifacts.get("animationMedia")
if not isinstance(animation_media, list):
return managed
@@ -1011,7 +1241,34 @@ def _write_artifact_tree(
_collect_media(art.media_files)
_write_animation_config(output_dir, result)
_write_conversion_report(output_dir, result, options)
if result.native_structure is not None:
if result.source_pptx_path is None:
raise RuntimeError(
"Round-trip source structure is missing its source PPTX path"
)
shutil.copy2(result.source_pptx_path, output_dir / SOURCE_TEMPLATE_NAME)
(output_dir / NATIVE_STRUCTURE_NAME).write_text(
json.dumps(
result.native_structure,
ensure_ascii=False,
indent=2,
) + "\n",
encoding="utf-8",
)
embedded_fonts_descriptor: dict[str, object] | None = None
embedded_font_paths: tuple[str, ...] = ()
if result.embedded_fonts is not None:
(
embedded_fonts_descriptor,
embedded_font_paths,
) = write_embedded_font_bundle(output_dir, result.embedded_fonts)
_write_conversion_report(
output_dir,
result,
options,
embedded_fonts_descriptor=embedded_fonts_descriptor,
embedded_font_paths=embedded_font_paths,
)
if media_written:
media_dir.mkdir(parents=True, exist_ok=True)
for filename, blob in media_written.items():
@@ -1053,12 +1310,44 @@ def _write_conversion_report(
output_dir: Path,
result: ConvertResult,
options: ConvertOptions,
*,
embedded_fonts_descriptor: dict[str, object] | None = None,
embedded_font_paths: tuple[str, ...] = (),
) -> None:
"""Write the user-visible tolerant-import report."""
animation_media = [
(PurePosixPath(options.media_subdir) / filename).as_posix()
for filename in sorted(result.animation_media_files)
]
source_theme: dict[str, object] = {
"colors": result.theme_colors,
"fonts": result.theme_fonts,
}
if result.theme_xml is not None:
source_theme["ooxml"] = {
"encoding": "base64",
"sha256": hashlib.sha256(result.theme_xml).hexdigest(),
"payload": base64.b64encode(result.theme_xml).decode("ascii"),
}
source_document: dict[str, object] = {
"canvasPx": {
"width": result.canvas_px[0],
"height": result.canvas_px[1],
},
"theme": source_theme,
}
if embedded_fonts_descriptor is not None:
source_document["embeddedFonts"] = embedded_fonts_descriptor
artifacts: dict[str, object] = {
"animationConfig": "animations.json",
"animationMedia": animation_media,
}
if embedded_font_paths:
artifacts["embeddedFontManifest"] = embedded_font_paths[-1]
artifacts["embeddedFontParts"] = list(embedded_font_paths[:-1])
if result.native_structure is not None:
artifacts["sourceTemplate"] = SOURCE_TEMPLATE_NAME
artifacts["nativeStructure"] = NATIVE_STRUCTURE_NAME
report = {
"schemaVersion": 1,
"source": result.source_file,
@@ -1067,10 +1356,8 @@ def _write_conversion_report(
"slides": len(result.slides),
"warnings": len(result.diagnostics),
},
"artifacts": {
"animationConfig": "animations.json",
"animationMedia": animation_media,
},
"artifacts": artifacts,
"sourceDocument": source_document,
"diagnostics": [item.to_dict() for item in result.diagnostics],
}
(output_dir / "conversion-report.json").write_text(
@@ -12,7 +12,7 @@ import re
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from pptx_effects import unsupported_effect_metadata
from pptx_effects import native_effect_metadata, unsupported_effect_metadata
from pptx_shapes.formula import OOXML_COORDINATE_MAX
from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color
@@ -57,8 +57,15 @@ class EffectResult:
metadata: tuple[tuple[str, str], ...] = ()
@classmethod
def unsupported(cls, reason: str) -> "EffectResult":
return cls(metadata=tuple(unsupported_effect_metadata(reason).items()))
def unsupported(
cls,
reason: str,
effect_container: ET.Element | None = None,
) -> "EffectResult":
metadata = unsupported_effect_metadata(reason)
if effect_container is not None:
metadata.update(native_effect_metadata(effect_container))
return cls(metadata=tuple(metadata.items()))
def unsupported_target_effect_metadata(
@@ -86,9 +93,20 @@ def unsupported_target_effect_metadata(
)
if not effect_names:
return {}
return unsupported_effect_metadata(
metadata = unsupported_effect_metadata(
f"unsupported-effect-target:{target}:" + ",".join(effect_names)
)
containers = [
child
for child in sp_pr
if isinstance(child.tag, str)
and _local_name(child) in _EFFECT_CONTAINER_NAMES
]
if len(containers) == 1 and containers[0].tag.startswith(
_DRAWINGML_TAG_PREFIX
):
metadata.update(native_effect_metadata(containers[0]))
return metadata
def convert_effects(
@@ -122,7 +140,10 @@ def convert_effects(
f"invalid-effect-container-namespace:{container_name}"
)
if container_name == "effectDag":
return EffectResult.unsupported("unsupported-effect-container:effectDag")
return EffectResult.unsupported(
"unsupported-effect-container:effectDag",
container,
)
effects = [
child
for child in container
@@ -133,7 +154,8 @@ def convert_effects(
names = [child.tag.split("}", 1)[-1] for child in effects]
if len(effects) != 1:
return EffectResult.unsupported(
"multiple-effects:" + ",".join(names)
"multiple-effects:" + ",".join(names),
container,
)
effect = effects[0]
@@ -151,18 +173,21 @@ def convert_effects(
if unsupported_attributes:
return EffectResult.unsupported(
"unsupported-effect-attributes:outerShdw:"
+ ",".join(unsupported_attributes)
+ ",".join(unsupported_attributes),
container,
)
primitives = _outer_shadow(effect, palette)
elif effect_name == "glow":
primitives = _glow(effect, palette)
else:
return EffectResult.unsupported(
f"unsupported-effect:{effect_name}"
f"unsupported-effect:{effect_name}",
container,
)
except (OverflowError, TypeError, ValueError) as exc:
return EffectResult.unsupported(
f"invalid-effect:{effect_name}:{exc}"
f"invalid-effect:{effect_name}:{exc}",
container,
)
if id_seq is None:
@@ -18,6 +18,8 @@ from dataclasses import dataclass, field
from decimal import Decimal
from xml.etree import ElementTree as ET
from pptx_gradients import native_gradient_metadata
from .color_resolver import (
COLOR_TAGS,
ColorPalette,
@@ -81,6 +83,7 @@ def resolve_fill(
id_prefix: str = "g",
id_seq: list[int] | None = None,
placeholder_hex: str | None = None,
group_fill: FillResult | None = None,
) -> FillResult:
"""Inspect <p:spPr>'s fill children and emit an SVG fill descriptor.
@@ -124,7 +127,11 @@ def resolve_fill(
fill_elem, fill_name = fill_children[0]
if fill_name == "grpFill":
raise ValueError("Unsupported DrawingML fill: grpFill")
if group_fill is None:
raise ValueError(
"DrawingML grpFill requires a resolved ancestor group fill"
)
return FillResult(attrs=dict(group_fill.attrs))
return handlers[fill_name](
fill_elem,
palette,
@@ -177,9 +184,9 @@ def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None,
prefix: str, seq, placeholder_hex: str | None) -> FillResult:
"""Convert <a:gradFill> to an SVG linearGradient or radialGradient."""
_validate_gradient_attributes(elem)
_validate_gradient_rotation(elem)
_validate_gradient_rotation(elem, palette)
_validate_gradient_flip(elem)
_validate_gradient_tile_rect(elem)
_validate_or_normalize_gradient_tile_rect(elem, palette)
if seq is None:
seq = [0]
seq[0] += 1
@@ -248,7 +255,7 @@ def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None,
# ang is 1/60000 deg. 0° = horizontal left-to-right.
_validate_linear_gradient_structure(lin)
angle = _linear_gradient_angle(lin)
_validate_linear_gradient_scaling(lin, angle)
_validate_or_normalize_linear_gradient_scaling(lin, angle, palette)
angle_deg = angle / ANGLE_UNIT
x1, y1, x2, y2 = _angle_to_unit_endpoints(angle_deg)
defs_xml = (
@@ -312,12 +319,23 @@ def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None,
+ "</linearGradient>"
)
defs_xml = _attach_native_gradient_metadata(defs_xml, elem)
return FillResult(
attrs={"fill": f"url(#{grad_id})"},
defs=[defs_xml],
)
def _attach_native_gradient_metadata(
svg_gradient_xml: str,
grad_fill: ET.Element,
) -> str:
"""Bind the normalized SVG preview to its exact source gradFill."""
preview = ET.fromstring(svg_gradient_xml)
preview.attrib.update(native_gradient_metadata(grad_fill, preview))
return ET.tostring(preview, encoding="unicode")
def _gradient_stop_position(gs: ET.Element) -> float:
"""Parse one required DrawingML fixed-percentage stop position."""
raw = gs.get("pos")
@@ -399,11 +417,12 @@ def _validate_linear_gradient_structure(lin: ET.Element) -> None:
)
def _validate_linear_gradient_scaling(
def _validate_or_normalize_linear_gradient_scaling(
lin: ET.Element,
angle: int,
palette: ColorPalette | None,
) -> None:
"""Require a scaling mode representable by unit-box SVG geometry."""
"""Normalize unscaled oblique gradients only in tolerant import mode."""
scaled = _parse_ooxml_boolean(
lin.get("scaled"),
default=False,
@@ -411,24 +430,43 @@ def _validate_linear_gradient_scaling(
)
quarter_turn = 90 * ANGLE_UNIT
if not scaled and angle % quarter_turn:
raise ValueError(
message = (
"Unscaled non-cardinal DrawingML linear gradients are not "
"representable by the normalized SVG mapping"
)
if palette is None or palette.strict:
raise ValueError(message)
palette._diagnose(
"gradient-scaling-normalized",
message,
"map the angle to the normalized SVG unit box while preserving "
"its stops",
)
def _validate_gradient_rotation(gradient: ET.Element) -> None:
"""Require a gradient that rotates with its containing shape."""
def _validate_gradient_rotation(
gradient: ET.Element,
palette: ColorPalette | None,
) -> None:
"""Normalize fixed-page gradient rotation only in tolerant import mode."""
rotates_with_shape = _parse_ooxml_boolean(
gradient.get("rotWithShape"),
default=True,
label="gradient rotWithShape value",
)
if not rotates_with_shape:
raise ValueError(
message = (
"DrawingML gradients that do not rotate with their shape are "
"not representable by the local SVG mapping"
)
if palette is None or palette.strict:
raise ValueError(message)
palette._diagnose(
"gradient-rotation-normalized",
message,
"render the gradient in the shape-local SVG box and preserve "
"the exact DrawingML gradient for unchanged round-trip",
)
def _validate_gradient_attributes(gradient: ET.Element) -> None:
@@ -474,8 +512,11 @@ def _validate_gradient_flip(gradient: ET.Element) -> None:
raise ValueError(f"Unsupported DrawingML gradient flip: {flip!r}")
def _validate_gradient_tile_rect(gradient: ET.Element) -> None:
"""Accept only the full-area gradient tile rectangle."""
def _validate_or_normalize_gradient_tile_rect(
gradient: ET.Element,
palette: ColorPalette | None,
) -> None:
"""Ignore a cropped gradient tile only in tolerant import mode."""
tile_rects = gradient.findall("a:tileRect", NS)
if len(tile_rects) > 1:
raise ValueError(
@@ -489,10 +530,19 @@ def _validate_gradient_tile_rect(gradient: ET.Element) -> None:
)
for value in values.values():
if value != 0:
raise ValueError(
message = (
"Non-zero DrawingML gradient tileRect is not representable "
"by the project SVG gradient mapping"
)
if palette is None or palette.strict:
raise ValueError(message)
palette._diagnose(
"gradient-tile-rect-normalized",
message,
"use the full object bounding box while preserving the "
"gradient stops and direction",
)
return
def _validate_path_gradient_focus(
@@ -12,6 +12,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from pptx_gradients import (
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
gradient_preview_fingerprint,
)
from pptx_shapes.formula import validate_ooxml_line_width
from .color_resolver import (
@@ -22,6 +26,7 @@ from .color_resolver import (
validate_no_fill,
)
from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha
from .fill_to_svg import resolve_fill
@dataclass
@@ -74,6 +79,7 @@ def resolve_stroke(
id_prefix: str = "m",
id_seq: list[int] | None = None,
style_stroke_default: str | None = None,
gradient_frame: tuple[float, float, float, float] | None = None,
) -> StrokeResult:
"""Resolve <a:ln> child of <p:spPr>.
@@ -91,6 +97,7 @@ def resolve_stroke(
attrs: dict[str, str] = {}
defs: list[str] = []
marker_color_override: str | None = None
compound = ln.attrib.get("cmpd")
if compound not in {None, "sng"}:
@@ -140,21 +147,30 @@ def resolve_stroke(
if alpha < 1.0:
attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
elif paint_name == "gradFill":
# Approximate gradient stroke as the first stop color (SVG supports
# gradient strokes via fill="url()" but it adds a lot of plumbing;
# first-stop is the registered import normalization).
first_gs = paint.find("a:gsLst/a:gs", NS)
if first_gs is None:
first_stop = paint.find("a:gsLst/a:gs", NS)
if first_stop is None:
raise ValueError("DrawingML gradient line requires a color stop")
color_elem = find_color_elem(first_gs)
hex_, alpha = resolve_color(color_elem, palette)
if hex_ is None:
raise ValueError(
"DrawingML gradient line first color cannot be resolved"
marker_color, _marker_alpha = resolve_color(
find_color_elem(first_stop),
palette,
)
marker_color_override = marker_color
gradient = resolve_fill(
paint,
palette,
id_prefix=f"{id_prefix}stroke",
id_seq=id_seq,
)
attrs["stroke"] = gradient.attrs["fill"]
if "fill-opacity" in gradient.attrs:
attrs["stroke-opacity"] = gradient.attrs["fill-opacity"]
defs.extend(
_project_degenerate_stroke_gradients(
gradient.defs,
gradient_frame,
float(attrs.get("stroke-width", "1") or "1"),
)
)
attrs["stroke"] = hex_
if alpha < 1.0:
attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
# Dash pattern
preset_tag = f"{{{NS['a']}}}prstDash"
@@ -237,7 +253,7 @@ def resolve_stroke(
if set(join.attrib) - {"lim"}:
raise ValueError("Invalid DrawingML line join structure")
limit = join.attrib.get("lim")
if limit != "800000":
if limit not in {None, "800000"}:
raise ValueError(
f"Unsupported DrawingML miter limit: {limit!r}"
)
@@ -261,7 +277,12 @@ def resolve_stroke(
or (end_elem.text or "").strip()
):
raise ValueError(f"Invalid DrawingML {which} structure")
marker_color = attrs.get("stroke") or style_stroke_default or "#000000"
marker_color = (
marker_color_override
or attrs.get("stroke")
or style_stroke_default
or "#000000"
)
marker_id, marker_def = _build_arrow_marker(
end_elem,
marker_color,
@@ -277,6 +298,68 @@ def resolve_stroke(
return StrokeResult(attrs=attrs, defs=defs)
def _project_degenerate_stroke_gradients(
definitions: list[str],
frame: tuple[float, float, float, float] | None,
stroke_width: float,
) -> list[str]:
"""Use page coordinates when objectBoundingBox has a degenerate axis."""
if frame is None:
return definitions
x, y, width, height = frame
if width > 0 and height > 0:
return definitions
projected: list[str] = []
safe_width = width if width > 0 else max(stroke_width, 1.0)
safe_height = height if height > 0 else max(stroke_width, 1.0)
origin_x = x if width > 0 else x - safe_width / 2
origin_y = y if height > 0 else y - safe_height / 2
for definition in definitions:
gradient = ET.fromstring(definition)
tag = gradient.tag.rsplit("}", 1)[-1]
gradient.set("gradientUnits", "userSpaceOnUse")
if tag == "linearGradient":
for name, origin, span in (
("x1", origin_x, safe_width),
("x2", origin_x, safe_width),
("y1", origin_y, safe_height),
("y2", origin_y, safe_height),
):
gradient.set(
name,
fmt_num(origin + float(gradient.get(name, "0")) * span, 5),
)
elif tag == "radialGradient":
for name, origin, span, default in (
("cx", origin_x, safe_width, "0.5"),
("fx", origin_x, safe_width, gradient.get("cx", "0.5")),
("cy", origin_y, safe_height, "0.5"),
("fy", origin_y, safe_height, gradient.get("cy", "0.5")),
):
gradient.set(
name,
fmt_num(
origin + float(gradient.get(name, default)) * span,
5,
),
)
gradient.set(
"r",
fmt_num(
float(gradient.get("r", "0.5"))
* max(safe_width, safe_height),
5,
),
)
gradient.set(
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
gradient_preview_fingerprint(gradient),
)
projected.append(ET.tostring(gradient, encoding="unicode"))
return projected
# ---------------------------------------------------------------------------
# Arrow marker generation
# ---------------------------------------------------------------------------
@@ -63,7 +63,7 @@ def render_normalized_chart_svg(
chart_type = str(payload.get("type") or "")
if chart_type not in {
"area", "bar", "column", "doughnut", "line", "pie",
"scatter", "bubble",
"scatter", "bubble", "combo",
}:
return None
@@ -92,6 +92,8 @@ def render_normalized_chart_svg(
parts = [*title_parts]
if chart_type in {"pie", "doughnut"}:
parts.extend(_render_pie(payload, styles, content, chart_type == "doughnut"))
elif chart_type == "combo":
parts.extend(_render_combo(payload, styles, content))
elif chart_type in {"scatter", "bubble"}:
parts.extend(_render_xy(payload, styles, content, chart_type))
else:
@@ -300,6 +302,134 @@ def _render_category(
return parts
def _render_combo(
payload: dict[str, Any],
styles: list[SeriesVisualStyle],
content: _Rect,
) -> list[str]:
"""Render supported category combo plots against one shared value scale."""
categories = [str(value) for value in payload.get("categories") or []]
plots = payload.get("plots") or []
if not categories or not isinstance(plots, list) or not plots:
return []
all_series = [
series
for plot_payload in plots
if isinstance(plot_payload, dict)
for series in plot_payload.get("series") or []
if isinstance(series, dict)
]
values = [
float(value)
for series in all_series
for value in series.get("values") or []
]
if not all_series or not values:
return []
label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021))
axes = payload.get("axes") if isinstance(payload.get("axes"), dict) else {}
value_axis = axes.get("value") if isinstance(axes.get("value"), dict) else {}
show_value_labels = value_axis.get("visible") is not False
show_gridlines = value_axis.get("major_gridlines") is not False
left = max(12.0, content.w * 0.035)
if show_value_labels:
left = max(34.0, content.w * 0.075)
bottom = max(25.0, content.h * 0.105)
plot_rect = _Rect(
content.x + left,
content.y + 5.0,
max(12.0, content.w - left - 10.0),
max(12.0, content.h - bottom - 8.0),
)
lo, hi, ticks = _nice_scale(values, include_zero=True)
parts = _vertical_grid_and_ticks(
plot_rect,
ticks,
lo,
hi,
label_size,
False,
show_labels=show_value_labels,
show_gridlines=show_gridlines,
)
parts.extend(
_column_category_labels(
plot_rect,
categories,
label_size,
point_aligned=False,
)
)
style_offset = 0
for plot_payload in plots:
if not isinstance(plot_payload, dict):
continue
plot_type = str(plot_payload.get("type") or "")
plot_series = plot_payload.get("series") or []
if not isinstance(plot_series, list) or not plot_series:
continue
plot_styles = _complete_styles(
styles[style_offset:style_offset + len(plot_series)],
len(plot_series),
)
style_offset += len(plot_series)
grouping = str(plot_payload.get("grouping") or "standard")
segments, percent = _category_segments(
plot_series,
len(categories),
grouping,
)
normalized_plot = {
**payload,
**plot_payload,
"categories": categories,
"series": plot_series,
}
if plot_type == "column":
parts.extend(
_render_bars(
normalized_plot,
categories,
plot_series,
plot_styles,
segments,
plot_rect,
lo,
hi,
grouping,
horizontal=False,
label_size=label_size,
percent=percent,
)
)
elif plot_type in {"line", "area"}:
category_span = plot_rect.w / max(len(categories), 1)
point_plot = _Rect(
plot_rect.x + category_span / 2.0,
plot_rect.y,
max(1.0, plot_rect.w - category_span),
plot_rect.h,
)
parts.extend(
_render_lines_or_areas(
normalized_plot,
categories,
plot_series,
plot_styles,
segments,
point_plot,
lo,
hi,
plot_type,
label_size,
percent,
)
)
return parts
def _category_labels(payload: dict[str, Any]) -> list[str] | None:
raw_categories = payload.get("categories") or []
axes = payload.get("axes")
@@ -405,7 +535,6 @@ def _render_bars(
category_span = (plot.h if horizontal else plot.w) / max(len(categories), 1)
cluster = category_span * 0.72
bar_span = cluster if stacked else cluster / max(len(series), 1)
labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None
for series_index, (item, style, row) in enumerate(zip(series, styles, segments)):
fill = style.fill or "none"
stroke = style.stroke or "none"
@@ -436,6 +565,7 @@ def _render_bars(
f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
f'stroke-width="{_fmt(max(0.4, style.stroke_width))}"/>'
)
labels = _point_data_labels(payload, item, category_index)
if labels:
value = float(item["values"][category_index])
normalized_percent = end - start if percent else None
@@ -455,7 +585,16 @@ def _render_bars(
tx = x + w / 2
ty = y1 - 4.0 if end >= start else y1 + label_size + 3.0
anchor = "middle"
parts.append(_text(tx, ty, label, size=max(6.0, label_size - 1), anchor=anchor))
parts.append(_text(
tx,
ty,
label,
size=_label_font_size(labels, label_size),
anchor=anchor,
fill=_entry_color(labels, "#444444"),
weight="600" if labels.get("bold") else None,
font_family=str(labels.get("font_family") or "Arial"),
))
return parts
@@ -478,7 +617,6 @@ def _render_lines_or_areas(
_category_point_x(plot, idx, count)
for idx in range(count)
]
labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None
show_markers = chart_type == "line" and payload.get("line_style") == "lineMarker"
for series_index, (item, style, row) in enumerate(zip(series, styles, segments)):
fill_color = style.fill or "none"
@@ -518,8 +656,9 @@ def _render_lines_or_areas(
f'stroke-opacity="{_fmt(style.marker_stroke_opacity)}" '
f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>'
)
if labels:
for idx, (x, y) in enumerate(top):
labels = _point_data_labels(payload, item, idx)
if labels:
value = float(item["values"][idx])
start, end = row[idx]
normalized_percent = end - start if percent else None
@@ -531,7 +670,16 @@ def _render_lines_or_areas(
percent_value=normalized_percent,
)
if label:
parts.append(_text(x, y - 5.0, label, size=max(6.0, label_size - 1), anchor="middle"))
parts.append(_text(
x,
y - 5.0,
label,
size=_label_font_size(labels, label_size),
anchor="middle",
fill=_entry_color(labels, "#444444"),
weight="600" if labels.get("bold") else None,
font_family=str(labels.get("font_family") or "Arial"),
))
return parts
@@ -550,6 +698,11 @@ def _render_pie(
styles = _complete_styles(styles, len(categories))
label_size = max(6.0, min(10.0, content.h * 0.035, content.w * 0.018))
radius = max(5.0, min(content.w, content.h) * 0.34)
inner_radius = radius * (
float(payload.get("hole_size", 75)) / 100.0
if doughnut
else 0.0
)
cx = content.x + content.w / 2
cy = content.y + content.h / 2
if total <= 0:
@@ -559,7 +712,7 @@ def _render_pie(
]
if doughnut:
parts.append(
f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius * 0.75)}" '
f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(inner_radius)}" '
'fill="none" stroke="#D5DAE1" stroke-width="1"/>'
)
parts.append(
@@ -577,8 +730,7 @@ def _render_pie(
stroke = style.stroke or "none"
if sweep >= math.tau - 1e-9:
if doughnut:
inner = radius * 0.75
path = _full_ring_path(cx, cy, radius, inner)
path = _full_ring_path(cx, cy, radius, inner_radius)
parts.append(
f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}" '
@@ -591,7 +743,14 @@ def _render_pie(
f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}"/>'
)
else:
path = _sector_path(cx, cy, radius, angle, end, radius * 0.75 if doughnut else 0.0)
path = _sector_path(
cx,
cy,
radius,
angle,
end,
inner_radius,
)
parts.append(
f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
@@ -925,6 +1084,17 @@ def _legend_entries(
chart_type = str(payload.get("type") or "")
if chart_type in {"pie", "doughnut"}:
labels = [str(value) for value in payload.get("categories") or []]
elif chart_type == "combo":
labels = [
str(series.get("name") or f"Series {index + 1}")
for index, series in enumerate(
series
for plot_payload in payload.get("plots") or []
if isinstance(plot_payload, dict)
for series in plot_payload.get("series") or []
if isinstance(series, dict)
)
]
else:
labels = [str(item.get("name") or f"Series {idx + 1}") for idx, item in enumerate(payload.get("series") or [])]
completed = _complete_styles(styles, len(labels))
@@ -980,6 +1150,8 @@ def _data_label(
*,
percent_value: float | None,
) -> str:
if config.get("text") is not None:
return str(config["text"])
fields: list[str] = []
if config.get("show_series"):
fields.append(series)
@@ -1009,6 +1181,54 @@ def _data_label(
return " · ".join(field for field in fields if field)
def _point_data_labels(
payload: dict[str, Any],
series: dict[str, Any],
point_index: int,
) -> dict[str, Any] | None:
root_labels = payload.get("data_labels")
series_labels = series.get("data_labels")
config = series_labels if isinstance(series_labels, dict) else root_labels
if not isinstance(config, dict):
return None
points = config.get("points")
if not isinstance(points, list):
return config
point = next(
(
item for item in points
if isinstance(item, dict) and item.get("idx") == point_index
),
None,
)
if point is None:
inherited = {
key: value
for key, value in config.items()
if key not in {"points", "source_ooxml"}
}
return inherited if any(
inherited.get(field)
for field in (
"show_category",
"show_percent",
"show_series",
"show_value",
)
) else None
if point.get("delete") is True:
return None
return {**config, **point}
def _label_font_size(config: dict[str, Any], fallback: float) -> float:
try:
size = float(config.get("font_size", fallback - 1.0))
except (TypeError, ValueError, OverflowError):
size = fallback - 1.0
return max(6.0, min(size, 24.0))
def _format_data_label_value(value: float, number_format: Any) -> str:
"""Render the safe numeric subset used by normalized data labels.
@@ -1162,11 +1382,13 @@ def _text(
anchor: str = "start",
fill: str = "#444444",
weight: str | None = None,
font_family: str = "Arial",
) -> str:
weight_attr = f' font-weight="{weight}"' if weight else ""
return (
f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" '
f'font-family="Arial" font-size="{_fmt(size)}" fill="{fill}"{weight_attr}>'
f'font-family="{html.escape(font_family, quote=True)}" '
f'font-size="{_fmt(size)}" fill="{fill}"{weight_attr}>'
f'{html.escape(str(value))}</text>'
)
@@ -164,10 +164,30 @@ def _adjust_for_group(child_xfrm: Xfrm, group_xfrm: Xfrm) -> Xfrm:
ch_x = group_xfrm.ch_x or 0.0
ch_y = group_xfrm.ch_y or 0.0
new_x = group_xfrm.x + (child_xfrm.x - ch_x) * sx
new_y = group_xfrm.y + (child_xfrm.y - ch_y) * sy
center_x = group_xfrm.x + (
child_xfrm.x + child_xfrm.w / 2.0 - ch_x
) * sx
center_y = group_xfrm.y + (
child_xfrm.y + child_xfrm.h / 2.0 - ch_y
) * sy
# A parent group's child-frame scaling is applied after the child's own
# rotation. For a quarter-turn, the child's local x axis therefore lands
# on the parent's y axis (and vice versa). Scaling the unrotated frame by
# ``sx``/``sy`` before retaining the rotation reverses that order and can
# visibly stretch nested 90-degree groups under a non-uniform parent.
quarter_turn = round(child_xfrm.rot / 90.0) % 2 == 1
exact_quarter_turn = abs(
child_xfrm.rot - round(child_xfrm.rot / 90.0) * 90.0
) < 1e-7
if quarter_turn and exact_quarter_turn:
new_w = child_xfrm.w * sy
new_h = child_xfrm.h * sx
else:
new_w = child_xfrm.w * sx
new_h = child_xfrm.h * sy
new_x = center_x - new_w / 2.0
new_y = center_y - new_h / 2.0
return Xfrm(
x=new_x, y=new_y, w=new_w, h=new_h,
@@ -26,6 +26,7 @@ import base64
import copy
import hashlib
import json
import posixpath
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
@@ -39,10 +40,20 @@ from pptx_shapes import (
from pptx_effects import (
EFFECT_REASON_ATTR,
EFFECT_STATUS_ATTR,
NATIVE_EFFECT_ATTR,
NATIVE_EFFECT_SHA256_ATTR,
txbody_has_run_effects,
unsupported_effect_metadata,
)
from hyperlink_contract import SHAPE_HYPERLINK_ATTR
from svg_to_pptx.drawingml.paths import (
PathCommand,
normalize_path_commands,
parse_svg_path,
parse_svg_points,
svg_path_to_absolute,
transform_path_commands,
)
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload
@@ -114,6 +125,7 @@ class AssemblyContext:
strict: bool = False
group_id_prefix: str = ""
render_graphic_previews: bool = True
preserve_placeholder_inheritance: bool = False
asset_name_map: dict[str, str] = field(default_factory=dict)
diagnostics: list[ImportDiagnostic] = field(default_factory=list)
source_slide_index: int | None = None
@@ -129,6 +141,7 @@ class AssemblyContext:
# Accumulated outputs
defs: list[str] = field(default_factory=list)
media: dict[str, bytes] = field(default_factory=dict)
group_fills: list[FillResult | None] = field(default_factory=list)
def bind_palette(self) -> None:
"""Route tolerant color diagnostics through the current object context."""
@@ -219,6 +232,7 @@ def assemble_slide(
asset_name_map: dict[str, str] | None = None,
strict: bool = False,
diagnostics: list[ImportDiagnostic] | None = None,
preserve_placeholder_inheritance: bool = False,
) -> tuple[str, dict[str, bytes]]:
"""Convert one slide to a complete SVG string + media files map.
@@ -243,6 +257,7 @@ def assemble_slide(
keep_hidden=keep_hidden,
strict=strict,
render_graphic_previews=(inheritance_mode == "flat"),
preserve_placeholder_inheritance=preserve_placeholder_inheritance,
asset_name_map=asset_name_map or {},
diagnostics=diagnostics if diagnostics is not None else [],
source_slide_index=slide.index,
@@ -562,6 +577,21 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
inherited_has_run_effects = txbody_has_run_effects(
*node.inherited_lst_styles
)
metadata_tx_body, inherited_styles_materialized = (
_materialize_inherited_list_styles(
tx_body,
node.inherited_lst_styles,
)
)
export_tx_body = (
source_tx_body
if (
ctx.preserve_placeholder_inheritance
and node.placeholder is not None
and source_tx_body is not None
)
else metadata_tx_body
)
has_run_effects = local_has_run_effects or inherited_has_run_effects
if geom is not None and has_run_effects:
if is_vertical:
@@ -572,7 +602,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
geom.attrs.update(unsupported_effect_metadata(
"unsupported-run-effect-route:relationship-bearing-text"
))
elif inherited_has_run_effects:
elif inherited_has_run_effects and not inherited_styles_materialized:
geom.attrs.update(unsupported_effect_metadata(
"unsupported-run-effect-route:inherited-text-style"
))
@@ -631,6 +661,11 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
text_result = TextResult()
if text_result.defs:
ctx.defs.extend(text_result.defs)
visible_text_svg = (
text_result.svg
if is_vertical
else _counter_reflected_text_svg(text_result.svg, node.xfrm)
)
if is_vertical:
# Vertical text: geometry + image in one group, text in separate group
@@ -642,12 +677,12 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
top_level=top_level,
extra_attrs=_geometry_group_attrs(geom),
)
if not text_result.svg:
if not visible_text_svg:
return shape_xml
text_group = (
f'<g id="{ctx.group_id_prefix}shape-{node.spid or ctx.shape_seq[0]}-text"'
f' data-name="{_xml_escape(node.name)} text">\n'
f"{text_result.svg}\n</g>"
f"{visible_text_svg}\n</g>"
)
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 (
'<g data-pptx-text-flip-compensation="true" '
f'transform="{_xml_escape(transform)}">\n{text_svg}\n</g>'
)
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 (
'<metadata data-pptx-part="placeholder-sppr" '
'data-pptx-encoding="base64" '
f'data-pptx-ooxml-sha256="{hashlib.sha256(raw).hexdigest()}">'
f'{base64.b64encode(raw).decode("ascii")}</metadata>'
)
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,11 +1309,13 @@ def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict
defaults: dict[str, str] = {}
fill_ref = style.find("a:fillRef", NS)
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)
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
@@ -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("<svg"):
flattened = None
if (
not ctx.strict
and geom.attrs.get("data-pptx-geometry-kind") == "custom"
):
flattened = _flatten_vector_custom_crop(image_xml)
if flattened is not None:
image_xml = flattened
ctx.diagnose(
"vector-custom-geometry-crop-normalized",
"SVG-only picture combines srcRect with custom geometry; "
"Office renders the complete vector as the custom-shape fill",
"ignore srcRect and clip the complete vector to the custom geometry",
)
else:
try:
clip_geom = _project_clip_into_nested_crop(geom, image_xml)
except ValueError as exc:
if ctx.strict:
raise ValueError(
f"Cannot project picture geometry into its crop viewBox: {exc}"
) from exc
ctx.diagnose(
"nested-crop-shape-clip-omitted",
f"Cannot project picture geometry into its crop viewBox: {exc}",
"source image crop retained without the additional shape clip",
)
return image_xml
ctx.clip_seq[0] += 1
clip_id = f"{ctx.group_id_prefix}clip{ctx.clip_seq[0]}"
clip_shape = _geom_to_svg(geom, "")
clip_shape = _geom_to_svg(clip_geom, "")
ctx.defs.append(
f'<clipPath id="{clip_id}" clipPathUnits="userSpaceOnUse">'
f'{clip_shape}</clipPath>'
@@ -1216,15 +1425,121 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None,
return _inject_clip_path(image_xml, clip_id)
def _flatten_vector_custom_crop(image_xml: str) -> str | None:
"""Normalize an SVG-only custom-shape crop to a plain full-vector image."""
wrapper = ET.fromstring(image_xml)
if wrapper.tag != "svg" or len(wrapper) != 1 or wrapper[0].tag != "image":
return None
image = wrapper[0]
href = image.attrib.get("href", "")
href_path = href.split("#", 1)[0].split("?", 1)[0].casefold()
if not (
href_path.endswith(".svg")
or href_path.startswith("data:image/svg+xml")
):
return None
attrs = {
key: value
for key, value in image.attrib.items()
if key not in {"x", "y", "width", "height", "preserveAspectRatio"}
}
for key in ("x", "y", "width", "height"):
value = wrapper.attrib.get(key)
if value is None:
return None
attrs[key] = value
attrs["preserveAspectRatio"] = "none"
return f"<image{_attrs_to_xml(attrs)}/>"
def _inject_clip_path(image_xml: str, clip_id: str) -> str:
clip_attr = f' clip-path="url(#{clip_id})"'
if image_xml.startswith("<image"):
return image_xml.replace("<image", f"<image{clip_attr}", 1)
if image_xml.startswith("<svg"):
return image_xml.replace("<svg", f'<svg data-pptx-crop="1"{clip_attr}', 1)
marked = image_xml.replace("<svg", '<svg data-pptx-crop="1"', 1)
return marked.replace("<image", f"<image{clip_attr}", 1)
return image_xml
def _project_clip_into_nested_crop(
geom: GeomResult,
image_xml: str,
) -> GeomResult:
"""Map absolute slide geometry into one nested crop's viewBox space."""
wrapper = ET.fromstring(image_xml)
if wrapper.tag != "svg":
raise ValueError("expected a nested <svg> crop wrapper")
try:
frame_x = float(wrapper.attrib["x"])
frame_y = float(wrapper.attrib["y"])
frame_w = float(wrapper.attrib["width"])
frame_h = float(wrapper.attrib["height"])
view_box = [float(token) for token in wrapper.attrib["viewBox"].split()]
except (KeyError, ValueError) as exc:
raise ValueError("crop wrapper has incomplete numeric geometry") from exc
if frame_w <= 0 or frame_h <= 0 or len(view_box) != 4:
raise ValueError(
"crop wrapper requires positive dimensions and four viewBox values"
)
vb_x, vb_y, vb_w, vb_h = view_box
if vb_w <= 0 or vb_h <= 0:
raise ValueError("crop viewBox dimensions must be positive")
scale_x = vb_w / frame_w
scale_y = vb_h / frame_h
matrix = (
scale_x,
0.0,
0.0,
scale_y,
vb_x - frame_x * scale_x,
vb_y - frame_y * scale_y,
)
if geom.tag == "path" and geom.path_d:
commands = normalize_path_commands(
svg_path_to_absolute(parse_svg_path(geom.path_d))
)
transformed = transform_path_commands(commands, matrix)
return GeomResult(
tag="path",
path_d=_serialize_clip_path(transformed),
)
if geom.tag in {"polygon", "polyline"} and geom.points:
points = parse_svg_points(
geom.points,
min_points=3 if geom.tag == "polygon" else 2,
)
transformed = [
(
x * scale_x + matrix[4],
y * scale_y + matrix[5],
)
for x, y in points
]
return GeomResult(
tag=geom.tag,
points=" ".join(
f"{fmt_num(x, 5)},{fmt_num(y, 5)}"
for x, y in transformed
),
)
raise ValueError(f"unsupported clip geometry <{geom.tag}>")
def _serialize_clip_path(commands: list[PathCommand]) -> str:
"""Serialize normalized M/L/C/Z commands with crop-safe precision."""
parts: list[str] = []
for command in commands:
parts.append(command.cmd)
if command.args:
parts.append(" ".join(fmt_num(value, 5) for value in command.args))
return " ".join(parts)
# ---------------------------------------------------------------------------
# Picture (<p:pic>)
# ---------------------------------------------------------------------------
@@ -1323,11 +1638,42 @@ def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool
def _convert_group(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
"""Render group contents flat (children already remapped to slide space)."""
parent_fill = ctx.group_fills[-1] if ctx.group_fills else None
group_properties = node.xml.find("p:grpSpPr", NS)
try:
resolved_fill = resolve_fill(
group_properties,
ctx.palette,
id_prefix="g",
id_seq=ctx.grad_seq,
group_fill=parent_fill,
)
except ValueError as exc:
if ctx.strict:
raise
ctx.diagnose(
"group-fill-omitted",
str(exc),
"inherit the nearest resolved ancestor group fill when available",
)
group_fill = parent_fill
else:
ctx.defs.extend(resolved_fill.defs)
group_fill = (
FillResult(attrs=dict(resolved_fill.attrs))
if resolved_fill.attrs
else parent_fill
)
inner_parts: list[str] = []
ctx.group_fills.append(group_fill)
try:
for child in node.children:
chunk = _convert_node(child, ctx, top_level=False)
if chunk:
inner_parts.append(chunk)
finally:
ctx.group_fills.pop()
if not inner_parts:
return ""
inner = "\n".join(inner_parts)
@@ -1377,12 +1723,22 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
if payload_metadata
else rendered
)
roundtrip_metadata, roundtrip_attrs = (
_roundtrip_graphic_frame_metadata(
node,
ctx,
inner,
top_level=top_level,
)
)
if roundtrip_metadata:
inner = f"{roundtrip_metadata}\n{inner}"
return _wrap_shape_group(
inner,
node,
ctx,
top_level=top_level,
extra_attrs=replacement_attrs,
extra_attrs=replacement_attrs + roundtrip_attrs,
)
preview_svg = ""
@@ -1488,6 +1844,174 @@ def _replacement_payload_metadata(payload: object) -> str:
)
def _package_rels_path(part_name: str) -> str:
parent, name = posixpath.split(part_name)
return f"{parent}/_rels/{name}.rels" if parent else f"_rels/{name}.rels"
def _package_content_types(ctx: AssemblyContext) -> tuple[dict[str, str], dict[str, str]]:
payload = ctx.pkg.read_part_bytes("[Content_Types].xml")
if payload is None:
raise RuntimeError("source package has no [Content_Types].xml")
try:
root = ET.fromstring(payload)
except ET.ParseError as exc:
raise RuntimeError("source package content types are malformed") from exc
defaults = {
item.attrib.get("Extension", "").lower(): item.attrib.get("ContentType", "")
for item in root.findall(f"{{{NS['ct']}}}Default")
}
overrides = {
item.attrib.get("PartName", "").lstrip("/"): item.attrib.get("ContentType", "")
for item in root.findall(f"{{{NS['ct']}}}Override")
}
return defaults, overrides
def _roundtrip_chart_package(
node: ShapeNode,
ctx: AssemblyContext,
graphic_data: ET.Element | None,
) -> dict[str, Any] | None:
"""Collect the closed source chart dependency graph for exact round-trip."""
if not ctx.preserve_placeholder_inheritance or graphic_data is None:
return None
chart_ref = graphic_data.find(f"{{{CHART_URI}}}chart")
if chart_ref is None:
return None
rel_id = chart_ref.attrib.get(f"{{{NS['r']}}}id")
chart_part = ctx.slide_part.resolve_rel(rel_id or "")
if not chart_part or not chart_part.startswith("ppt/charts/"):
return None
try:
defaults, overrides = _package_content_types(ctx)
pending = [chart_part]
seen: set[str] = set()
parts: list[dict[str, str]] = []
while pending:
part_name = pending.pop()
if part_name in seen:
continue
if not part_name.startswith(
("ppt/charts/", "ppt/embeddings/", "ppt/theme/")
):
return None
payload = ctx.pkg.read_part_bytes(part_name)
if payload is None:
return None
seen.add(part_name)
content_type = overrides.get(part_name) or defaults.get(
posixpath.splitext(part_name)[1].lstrip(".").lower(),
"",
)
parts.append({
"content_type": content_type,
"encoding": "base64",
"name": part_name,
"payload": base64.b64encode(payload).decode("ascii"),
"sha256": hashlib.sha256(payload).hexdigest(),
})
rels_name = _package_rels_path(part_name)
rels_payload = ctx.pkg.read_part_bytes(rels_name)
if rels_payload is None:
continue
try:
rels_root = ET.fromstring(rels_payload)
except ET.ParseError:
return None
for relationship in rels_root.findall(
f"{{{NS['rel']}}}Relationship"
):
if relationship.attrib.get("TargetMode") == "External":
return None
target = relationship.attrib.get("Target")
if not target:
return None
resolved = posixpath.normpath(
posixpath.join(posixpath.dirname(part_name), target)
).lstrip("/")
if resolved.startswith("../"):
return None
pending.append(resolved)
parts.append({
"content_type": "",
"encoding": "base64",
"name": rels_name,
"payload": base64.b64encode(rels_payload).decode("ascii"),
"sha256": hashlib.sha256(rels_payload).hexdigest(),
})
except RuntimeError:
return None
frame = copy.deepcopy(node.xml)
nv_pr = frame.find("p:nvGraphicFramePr/p:nvPr", NS)
custom_data = nv_pr.find("p:custDataLst", NS) if nv_pr is not None else None
if custom_data is not None:
nv_pr.remove(custom_data)
relationship_attrs = [
(owner, name)
for owner in frame.iter()
for name in owner.attrib
if isinstance(name, str) and name.startswith(f"{{{NS['r']}}}")
]
if len(relationship_attrs) != 1 or relationship_attrs[0][0].tag != f"{{{CHART_URI}}}chart":
return None
frame_payload = ET.tostring(frame, encoding="utf-8")
return {
"chart_part": chart_part,
"frame": {
"encoding": "base64",
"payload": base64.b64encode(frame_payload).decode("ascii"),
"sha256": hashlib.sha256(frame_payload).hexdigest(),
},
"parts": sorted(parts, key=lambda item: item["name"]),
}
def _roundtrip_graphic_frame_metadata(
node: ShapeNode,
ctx: AssemblyContext,
visible_markup: str,
*,
top_level: bool,
) -> tuple[str, list[str]]:
"""Preserve one relationship-free native frame behind its SVG fallback."""
if not ctx.preserve_placeholder_inheritance or not top_level:
return "", []
native_frame = copy.deepcopy(node.xml)
nv_pr = native_frame.find("p:nvGraphicFramePr/p:nvPr", NS)
custom_data = (
nv_pr.find("p:custDataLst", NS)
if nv_pr is not None else None
)
if custom_data is not None:
# Office tags are non-visual, slide-relationship-bound metadata. The
# validated source package sidecar retains them; the portable native
# frame omits them so it stays relationship-free on a regenerated slide.
nv_pr.remove(custom_data)
if has_relationship_attributes(native_frame):
return "", []
raw = ET.tostring(native_frame, encoding="utf-8")
transform = node.xfrm.to_svg_transform()
fallback_hash = svg_native_fallback_markup_fingerprint(
visible_markup,
root_transform=transform,
external_markup="".join(ctx.defs),
)
metadata = (
'<metadata data-pptx-part="roundtrip-graphic-frame" '
'data-pptx-encoding="base64" '
f'data-pptx-ooxml-sha256="{hashlib.sha256(raw).hexdigest()}">'
f'{base64.b64encode(raw).decode("ascii")}</metadata>'
)
return metadata, [
'data-pptx-roundtrip-object="graphic-frame"',
f'{NATIVE_FALLBACK_SHA256_ATTR}="{fallback_hash}"',
]
def _render_graphic_table(
node: ShapeNode,
ctx: AssemblyContext,
@@ -1555,10 +2079,26 @@ def _render_graphic_chart(
)
replacement_attrs: list[str] = ['data-pptx-import-source="pptx"']
payload_metadata = ""
if result.native_payload:
if node.name and not result.native_payload.get("name"):
result.native_payload["name"] = node.name
payload_metadata = _replacement_payload_metadata(result.native_payload)
source_package = _roundtrip_chart_package(
node,
ctx,
graphic_data,
)
if result.native_payload or source_package is not None:
payload = result.native_payload or {
"height": round(node.xfrm.h, 3),
"width": round(node.xfrm.w, 3),
"x": round(node.xfrm.x, 3),
"y": round(node.xfrm.y, 3),
}
if node.name and not payload.get("name"):
payload["name"] = node.name
if source_package is not None:
payload["source_package"] = source_package
replacement_attrs.append(
'data-pptx-roundtrip-object="source-chart-package"'
)
payload_metadata = _replacement_payload_metadata(payload)
replacement_attrs.append('data-pptx-replace-with="chart"')
elif result.native_status:
replacement_attrs.append(
@@ -1867,8 +2407,6 @@ def _wrap_shape_group(
)
if node.name:
attrs.append(f'data-name="{_xml_escape(node.name)}"')
if node.placeholder is not None and node.placeholder.type:
attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"')
if node.placeholder is not None and node.kind == SHAPE:
sp_pr = node.xml.find("p:spPr", NS)
if sp_pr is not None and any(
@@ -1881,6 +2419,9 @@ def _wrap_shape_group(
if any(
attribute.split("=", 1)[0] == "data-pptx-replace-with"
for attribute in extra_attrs
) and not any(
attribute.split("=", 1)[0] == NATIVE_FALLBACK_SHA256_ATTR
for attribute in extra_attrs
):
fallback_hash = svg_native_fallback_markup_fingerprint(
inner,
@@ -1951,6 +2492,8 @@ def _geometry_group_attrs(geom: GeomResult | None) -> list[str]:
"data-pptx-geometry-reason",
EFFECT_STATUS_ATTR,
EFFECT_REASON_ATTR,
NATIVE_EFFECT_ATTR,
NATIVE_EFFECT_SHA256_ATTR,
)
attrs: list[str] = []
for key, value in geom.attrs.items():
@@ -1988,6 +2531,15 @@ def _object_metadata(
}
if node.name:
attrs["data-pptx-shape-name"] = node.name
if node.placeholder is not None:
if node.placeholder.type:
attrs["data-ph-type"] = node.placeholder.type
if node.placeholder.idx is not None:
attrs["data-pptx-placeholder-index"] = node.placeholder.idx
if node.placeholder.sz is not None:
attrs["data-pptx-placeholder-size"] = node.placeholder.sz
if node.placeholder.orient is not None:
attrs["data-pptx-placeholder-orientation"] = node.placeholder.orient
if node.kind == CONNECTOR:
attrs.update(_connector_metadata(node, _shape_scope(ctx)))
return attrs
@@ -1831,6 +1831,12 @@ def _line_element_to_svg(
palette,
id_prefix=id_prefix,
id_seq=id_seq,
gradient_frame=(
min(x1, x2),
min(y1, y2),
abs(x2 - x1),
abs(y2 - y1),
),
)
defs.extend(stroke.defs)
attrs = stroke.attrs
@@ -24,6 +24,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from unicodedata import east_asian_width
from xml.etree import ElementTree as ET
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
@@ -92,6 +93,8 @@ class TextParagraph:
space_after_px: float = 0.0
empty_line_font_size_px: float = DEFAULT_FONT_SIZE_PX
bullet_prefix: str = "" # rendered prefix like '• ' or '1. '
bullet_fill: str | None = None
bullet_fill_opacity: float = 1.0
@dataclass
@@ -265,6 +268,7 @@ def convert_vertical_txbody(
if tx_body is None:
return TextResult()
body_pr = tx_body.find("a:bodyPr", NS)
paragraphs = _parse_paragraphs(
tx_body, palette, theme_fonts or {}, default_fill=default_fill,
default_font_size_px=default_font_size_px,
@@ -276,6 +280,13 @@ def convert_vertical_txbody(
strict=strict,
diagnostic_sink=diagnostic_sink,
)
if body_pr is not None and body_pr.attrib.get("vert") == "eaVert":
return _convert_east_asian_vertical(
paragraphs,
xfrm,
body_pr,
)
runs = [
run
for para in paragraphs
@@ -338,6 +349,183 @@ def convert_vertical_txbody(
)
def _convert_east_asian_vertical(
paragraphs: list[TextParagraph],
xfrm: Xfrm,
body_pr: ET.Element,
) -> TextResult:
"""Render eaVert columns with upright CJK and sideways Latin runs."""
columns: list[list[tuple[bool, str, TextRun]]] = []
for paragraph in paragraphs:
column: list[tuple[bool, str, TextRun]] = []
for run in paragraph.runs:
if run.is_break:
if column:
columns.append(column)
column = []
continue
start = 0
while start < len(run.text):
upright = _is_east_asian_vertical_upright(run.text[start])
end = start + 1
while (
end < len(run.text)
and _is_east_asian_vertical_upright(run.text[end]) == upright
):
end += 1
column.append((upright, run.text[start:end], run))
start = end
if column:
columns.append(column)
if not columns:
return TextResult()
box_x, box_y, box_w, box_h = _rotated_bbox(xfrm)
lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
tins = _read_emu_attr(body_pr, "tIns", DEFAULT_INSETS_EMU["t"])
rins = _read_emu_attr(body_pr, "rIns", DEFAULT_INSETS_EMU["r"])
bins = _read_emu_attr(body_pr, "bIns", DEFAULT_INSETS_EMU["b"])
inner_x = box_x + lins
inner_y = box_y + tins
inner_w = max(box_w - lins - rins, 1.0)
inner_h = max(box_h - tins - bins, 1.0)
column_widths = [
max(run.font_size_px * 1.05 for _upright, _text, run in column)
for column in columns
]
total_width = sum(column_widths)
right_x = inner_x + min(inner_w, (inner_w + total_width) / 2.0)
column_centers: list[float] = []
for width in column_widths:
column_centers.append(right_x - width / 2.0)
right_x -= width
anchor = body_pr.attrib.get("anchor", "t")
bottom_y = inner_y + inner_h
text_blocks: list[str] = []
for column, center_x in zip(columns, column_centers):
content_height = sum(
_east_asian_vertical_segment_height(upright, text, run)
for upright, text, run in column
)
if anchor == "ctr":
cursor_y = inner_y + max(0.0, (inner_h - content_height) / 2.0)
elif anchor == "b":
cursor_y = inner_y + max(0.0, inner_h - content_height)
else:
cursor_y = inner_y
for upright, text, run in column:
available = bottom_y - cursor_y
if available <= 0:
break
if upright:
advance = run.font_size_px * 1.05
visible_count = min(len(text), int(available // advance))
visible = text[:visible_count]
if not visible:
break
text_blocks.append(
_emit_upright_vertical_segment(
visible,
run,
center_x,
cursor_y,
advance,
)
)
cursor_y += advance * visible_count
if visible_count < len(text):
break
continue
visible = _fit_sideways_vertical_text(text, run, available)
if not visible:
break
text_blocks.append(
_emit_sideways_vertical_segment(
visible,
run,
center_x,
cursor_y,
)
)
cursor_y += _estimate_run_width(visible, run)
if len(visible) < len(text):
break
return TextResult(
svg="\n".join(text_blocks),
defs=_collect_text_defs(paragraphs),
contains_inline_formula=False,
)
def _is_east_asian_vertical_upright(char: str) -> bool:
"""Keep East Asian wide/full-width glyphs upright in eaVert text."""
return _is_cjk(char) or east_asian_width(char) in {"W", "F"}
def _east_asian_vertical_segment_height(
upright: bool,
text: str,
run: TextRun,
) -> float:
if upright:
return len(text) * run.font_size_px * 1.05
return _estimate_run_width(text, run)
def _fit_sideways_vertical_text(
text: str,
run: TextRun,
available: float,
) -> str:
end = 0
for index in range(1, len(text) + 1):
if _estimate_run_width(text[:index], run) > available:
break
end = index
return text[:end]
def _emit_upright_vertical_segment(
text: str,
run: TextRun,
center_x: float,
top_y: float,
advance: float,
) -> str:
first_baseline = top_y + run.font_size_px * 0.85
attrs = _text_base_attrs(run, center_x, first_baseline, "middle")
spans = [_xml_escape(text[0])]
for char in text[1:]:
spans.append(
f'<tspan x="{fmt_num(center_x)}" dy="{fmt_num(advance)}">'
f"{_xml_escape(char)}</tspan>"
)
markup = f"<text{attrs}>{''.join(spans)}</text>"
return _wrap_run_hyperlink(markup, run)
def _emit_sideways_vertical_segment(
text: str,
run: TextRun,
center_x: float,
top_y: float,
) -> str:
baseline_x = center_x - run.font_size_px * 0.3
start_y = top_y + run.font_size_px * 0.1
attrs = _text_base_attrs(run, baseline_x, start_y, "start")
transform = (
f' transform="rotate(90 {fmt_num(baseline_x)} {fmt_num(start_y)})"'
)
markup = f"<text{attrs}{transform}>{_xml_escape(text)}</text>"
return _wrap_run_hyperlink(markup, run)
def _rotated_bbox(xfrm: Xfrm) -> tuple[float, float, float, float]:
rot = round(xfrm.rot) % 360
cx = xfrm.x + xfrm.w / 2.0
@@ -482,6 +670,9 @@ def _parse_paragraph(
para.bullet_prefix = _resolve_bullet_prefix(
para_style_chain, para.level, autonum_state,
)
para.bullet_fill, para.bullet_fill_opacity = _resolve_bullet_fill(
para_style_chain, palette,
)
# Default endParaRPr style (applies if a run has no rPr)
end_rpr = p_elem.find("a:endParaRPr", NS)
@@ -507,6 +698,18 @@ def _parse_paragraph(
diagnostic_sink=diagnostic_sink,
)
def append_resolved_text(text: str, rpr: ET.Element | None) -> None:
"""Preserve literal newlines inside a:t as explicit DrawingML breaks."""
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
segments = normalized.split("\n")
for index, segment in enumerate(segments):
if segment or len(segments) == 1:
para.runs.append(resolved_run(segment, rpr))
if index < len(segments) - 1:
line_break = resolved_run("", rpr)
line_break.is_break = True
para.runs.append(line_break)
for child in list(p_elem):
if not isinstance(child.tag, str):
continue
@@ -515,7 +718,7 @@ def _parse_paragraph(
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 ""
para.runs.append(resolved_run(text, rpr))
append_resolved_text(text, rpr)
elif local == "br":
break_rpr = child.find("a:rPr", NS)
para.runs.append(TextRun(
@@ -540,7 +743,7 @@ def _parse_paragraph(
if field_type == "slidenum" and slide_number is not None:
text = str(slide_number)
if text:
para.runs.append(resolved_run(text, rpr))
append_resolved_text(text, rpr)
elif (
child.tag
== "{http://schemas.microsoft.com/office/drawing/2010/main}m"
@@ -940,6 +1143,10 @@ def _resolve_bullet_prefix(
bu_char = _child_chain(sources, "a:buChar")
if bu_char is not None:
ch = bu_char.attrib.get("char", "")
bu_font = _child_chain(sources, "a:buFont")
typeface = bu_font.attrib.get("typeface", "") if bu_font is not None else ""
if typeface.casefold() == "wingdings" and ch == "l":
ch = ""
return f"{ch} "
bu_auto = _child_chain(sources, "a:buAutoNum")
if bu_auto is not None:
@@ -958,6 +1165,19 @@ def _resolve_bullet_prefix(
return ""
def _resolve_bullet_fill(
sources: tuple[ET.Element | None, ...],
palette: ColorPalette | None,
) -> tuple[str | None, float]:
"""Resolve an explicit DrawingML bullet color independently of text."""
bu_clr = _child_chain(sources, "a:buClr")
if bu_clr is None:
return None, 1.0
color_elem = find_color_elem(bu_clr)
color, opacity = resolve_color(color_elem, palette)
return color, opacity
def _format_auto_number(value: int, kind: str) -> str:
lower = kind.lower()
if "alphalc" in lower:
@@ -1132,8 +1352,19 @@ def _wrap_paragraph_into_lines(
first_run = next((r for r in para.runs if not r.is_break), None)
if first_run is not None:
bullet_run = _copy_run(first_run, text=para.bullet_prefix)
if para.bullet_fill is not None:
bullet_run.fill = para.bullet_fill
bullet_run.fill_opacity = para.bullet_fill_opacity
hanging_width = max(-para.indent_px, 0.0)
measured_width = _estimate_run_width(para.bullet_prefix, bullet_run)
if hanging_width > measured_width:
space_width = _estimate_run_width(" ", bullet_run)
extra_spaces = round(
(hanging_width - measured_width) / space_width
)
bullet_run.text += " " * max(extra_spaces, 0)
lines[-1].append(bullet_run)
cur_w = _estimate_run_width(para.bullet_prefix, bullet_run)
cur_w = _estimate_run_width(bullet_run.text, bullet_run)
for run in para.runs:
if run.is_break:
@@ -2,7 +2,8 @@
"""PPT Master project-management CLI implementation.
Usage:
python3 scripts/project_manager.py init <project_name> [--format ppt169] [--dir <path>] [--quick-generate]
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
[--dir <path>] [--quick-generate]
python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...] [--move | --copy]
python3 scripts/project_manager.py scaffold-spec <project_path>
python3 scripts/project_manager.py scaffold-lock <project_path>
@@ -12,7 +13,8 @@ Usage:
python3 scripts/project_manager.py page-context-report <project_path>
Examples:
python3 scripts/project_manager.py init demo --format ppt169
python3 scripts/project_manager.py init demo
python3 scripts/project_manager.py init widescreen --format ppt169
python3 scripts/project_manager.py validate projects/demo
Dependencies:
@@ -97,6 +99,10 @@ BITMAP_IMAGE_SUFFIXES = {
IMAGE_ASSET_SUFFIXES = BITMAP_IMAGE_SUFFIXES | {
".emf", ".wmf", ".svg",
}
DEFERRED_CANVAS_MESSAGE = (
"Canvas is determined during authoring and recorded in spec_lock.md "
"(Default) or the first SVG (Quick)."
)
def _validate_image_manifest(
@@ -233,7 +239,7 @@ class ProjectManager:
def init_project(
self,
project_name: str,
canvas_format: str = "ppt169",
canvas_format: str | None = None,
base_dir: str | None = None,
*,
quick_generate: bool = False,
@@ -251,6 +257,8 @@ class ProjectManager:
"Project name must be a single, non-absolute path component"
)
normalized_format: str | None = None
if canvas_format is not None:
normalized_format = normalize_canvas_format(canvas_format)
if normalized_format not in self.CANVAS_FORMATS:
available = ", ".join(sorted(self.CANVAS_FORMATS.keys()))
@@ -260,9 +268,13 @@ class ProjectManager:
)
date_str = datetime.now().strftime("%Y%m%d")
# A name already carrying a `_<format>_<YYYYMMDD>` suffix (e.g. a full
# project dir name pasted back into init) is used as-is — re-appending
# would produce `name_ppt169_20260101_ppt169_20260102`.
if normalized_format is None:
project_dir_name = f"{project_name}_{date_str}"
else:
# A name already carrying a `_<format>_<YYYYMMDD>` suffix (e.g. a
# full project dir name pasted back into init) is used as-is —
# re-appending would produce
# `name_ppt169_20260101_ppt169_20260102`.
if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name):
project_dir_name = project_name
else:
@@ -296,13 +308,17 @@ class ProjectManager:
for rel_path in project_dirs:
(project_path / rel_path).mkdir(parents=True, exist_ok=True)
canvas_info = self.CANVAS_FORMATS[normalized_format]
if not quick_generate:
canvas_summary = (
f"- Canvas format: {normalized_format}\n"
if normalized_format is not None
else f"- {DEFERRED_CANVAS_MESSAGE}\n"
)
readme_path = project_path / "README.md"
readme_path.write_text(
(
f"# {project_name}\n\n"
f"- Canvas format: {normalized_format}\n"
f"{canvas_summary}"
f"- Created: {date_str}\n\n"
"## Directories\n\n"
"- `svg_output/`: raw SVG output\n"
@@ -322,6 +338,10 @@ class ProjectManager:
)
print(f"Project created: {project_path}")
if normalized_format is None:
print(DEFERRED_CANVAS_MESSAGE)
else:
canvas_info = self.CANVAS_FORMATS[normalized_format]
print(f"Canvas: {canvas_info['name']} ({canvas_info['dimensions']})")
return str(project_path)
@@ -1046,6 +1066,11 @@ class ProjectManager:
def get_project_info(self, project_path: str) -> dict[str, object]:
shared = get_project_info_common(project_path)
canvas_format = (
"Not encoded in the project directory name"
if shared.get("format") == "unknown"
else shared.get("format_name", "Unknown")
)
return {
"name": shared.get("name", Path(project_path).name),
"path": shared.get("path", str(project_path)),
@@ -1054,7 +1079,7 @@ class ProjectManager:
"has_spec": shared.get("has_spec", False),
"has_source": shared.get("has_source", False),
"source_count": shared.get("source_count", 0),
"canvas_format": shared.get("format_name", "Unknown"),
"canvas_format": canvas_format,
"create_date": shared.get("date_formatted", "Unknown"),
}
@@ -1065,7 +1090,8 @@ def build_parser() -> argparse.ArgumentParser:
description="PPT Master project management helpers.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/project_manager.py init demo --format ppt169
python3 scripts/project_manager.py init demo
python3 scripts/project_manager.py init widescreen --format ppt169
python3 scripts/project_manager.py import-sources projects/demo file.md
python3 scripts/project_manager.py scaffold-spec projects/demo_ppt169_20260718
python3 scripts/project_manager.py scaffold-lock projects/demo_ppt169_20260718
@@ -1079,7 +1105,11 @@ def build_parser() -> argparse.ArgumentParser:
init = subparsers.add_parser("init", help="Create a project directory")
init.add_argument("project_name", help="Project name")
init.add_argument("--format", default="ppt169", help="Canvas format (default: ppt169)")
init.add_argument(
"--format",
default=None,
help="Registered canvas format; omit to determine the canvas during authoring",
)
init.add_argument("--dir", default=None, help="Base directory for the project")
init.add_argument(
"--quick-generate",
@@ -1179,10 +1209,15 @@ def main(argv: list[str] | None = None) -> int:
print("3. Generate SVG files into svg_output/")
profile = "default"
try:
canvas_note = (
f"; canvas={args.format}"
if args.format is not None
else ""
)
append_note(
project_path,
f"Project initialized: profile={profile}; "
f"canvas={args.format}; path={project_path}",
f"Project initialized: profile={profile}{canvas_note}; "
f"path={project_path}",
)
except OSError as exc:
print(
@@ -6,12 +6,13 @@ Stable CLI entry point for project creation, source import, validation, and
page-context diagnostics. Implementation lives in ``project_management/``.
Usage:
python3 scripts/project_manager.py init <project_name> [--format ppt169]
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
python3 scripts/project_manager.py import-sources <project_path> <sources...>
python3 scripts/project_manager.py validate <project_path>
Examples:
python3 scripts/project_manager.py init demo --format ppt169
python3 scripts/project_manager.py init demo
python3 scripts/project_manager.py init widescreen --format ppt169
python3 scripts/project_manager.py validate projects/demo
Dependencies:
@@ -26,7 +26,13 @@ from urllib.parse import unquote, unquote_to_bytes, urlsplit
from xml.etree import ElementTree as ET
SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'})
SVG_WORK_DIR_NAMES = frozenset({
'svg',
'svg_output',
'svg_final',
'svg-flat',
'svg_flat',
})
SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-'
TEMPLATE_SOURCE_DIR_NAME = 'templates'
TEMPLATE_SPEC_FILENAME = 'design_spec.md'
@@ -165,6 +165,7 @@ def _normalize_preview_hrefs(root: ET.Element) -> None:
def _inline_icons(
content: str,
icons_dir: Path,
target_dir: Path,
fallback_dir: Optional[Path] = None,
) -> tuple[str, list[dict]]:
"""Replace <use data-icon="..."/> with rendered <g> for browser preview.
@@ -190,7 +191,11 @@ def _inline_icons(
continue
icon_path, _ = resolve_icon_path(icon_name, icons_dir, fallback_dir)
color = str(attrs.get('fill', '#000000'))
elements, style, base_size = extract_paths_from_icon(icon_path, color)
elements, style, base_size = extract_paths_from_icon(
icon_path,
color,
target_dir=target_dir,
)
except Exception as exc:
warnings.append({'icon': icon_name, 'reason': f'{type(exc).__name__}: {exc}'})
logger.warning('icon inline failed: name=%r reason=%s', icon_name, exc)
@@ -713,6 +718,7 @@ def create_app(
content, warnings = _inline_icons(
content,
icons_dir,
svg_file.parent,
icons_fallback_dir,
)
if not pending_edits:
@@ -49,6 +49,7 @@ import re
import sys
import argparse
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
@@ -258,7 +259,41 @@ def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None
return icon_path, base_size
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]:
def _rebase_preserve_asset_hrefs(
content: str,
source_dir: Path,
target_dir: Path,
) -> str:
"""Rebase relative hrefs when a preserve-color asset is inlined."""
pattern = re.compile(
r'(\b(?:xlink:)?href\s*=\s*)(["\'])(.*?)\2',
re.IGNORECASE | re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
value = match.group(3)
if value.startswith(("#", "/")):
return match.group(0)
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc or not parsed.path:
return match.group(0)
source_target = (source_dir / parsed.path).resolve()
try:
relative = Path(os.path.relpath(source_target, target_dir)).as_posix()
except ValueError:
return match.group(0)
rewritten = urlunsplit(("", "", relative, parsed.query, parsed.fragment))
return f'{match.group(1)}{match.group(2)}{rewritten}{match.group(2)}'
return pattern.sub(replace, content)
def extract_paths_from_icon(
icon_path: Path,
target_color: str = '#000000',
*,
target_dir: Path | None = None,
) -> tuple[list[str], str, BaseGeometry]:
"""
Extract drawable elements from an icon SVG file.
@@ -274,6 +309,15 @@ def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> t
if _is_preserve_color_asset(content):
geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE)
elements = _extract_svg_body(content)
if target_dir is not None:
elements = [
_rebase_preserve_asset_hrefs(
element,
icon_path.parent,
target_dir,
)
for element in elements
]
return elements, 'preserve', geometry
style = _detect_icon_style(content)
@@ -483,7 +527,10 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
)
continue
elements, style, base_size = extract_paths_from_icon(icon_path)
elements, style, base_size = extract_paths_from_icon(
icon_path,
target_dir=svg_path.parent,
)
color = resolve_icon_color(attrs, style)
if not elements:
print(
@@ -105,6 +105,7 @@ from ..native_objects import (
native_metadata_payload_matches,
native_replacement_kind,
native_marker_transform,
require_fresh_native_fallback,
snapshot_native_fallback_freshness,
)
from ..native_objects.marker_status import native_marker_status_errors
@@ -198,6 +199,8 @@ def _native_replacement_enabled(elem: ET.Element, ctx: ConvertContext) -> bool:
kind = native_replacement_kind(elem)
if kind == 'formula':
return True
if elem.get('data-pptx-roundtrip-object') == 'source-chart-package':
return kind == 'chart'
return ctx.native_objects_enabled and kind in {'chart', 'table'}
@@ -567,6 +570,16 @@ def _txbody_metadata(elem: ET.Element) -> ET.Element | None:
return None
def _placeholder_sp_pr_metadata(elem: ET.Element) -> ET.Element | None:
for child in elem:
if (
child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata'
and child.get('data-pptx-part') == 'placeholder-sppr'
):
return child
return None
_TXBODY_UNCHANGED_ATTR = 'data-pptx-runtime-txbody-unchanged'
_PREVIEW_UNCHANGED_ATTR = 'data-pptx-runtime-preview-unchanged'
@@ -689,6 +702,56 @@ def _append_shape_text(
)
def _restore_placeholder_sp_pr(
shape: ShapeResult,
group: ET.Element,
) -> ShapeResult:
"""Restore an unchanged imported placeholder's local p:spPr container."""
metadata = _placeholder_sp_pr_metadata(group)
if metadata is None:
return shape
if metadata.get('data-pptx-encoding') != 'base64':
raise SvgNativeConversionError(
'placeholder spPr metadata requires base64 encoding'
)
expected_hash = metadata.get('data-pptx-ooxml-sha256')
if not expected_hash or not re.fullmatch(r'[0-9a-f]{64}', expected_hash):
raise SvgNativeConversionError(
'placeholder spPr metadata requires a lowercase SHA-256 digest'
)
try:
raw = base64.b64decode((metadata.text or '').strip(), validate=True)
sp_pr = ET.fromstring(raw)
decoded = raw.decode('utf-8')
except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
raise SvgNativeConversionError(
f'Invalid placeholder spPr metadata: {exc}'
) from exc
if hashlib.sha256(raw).hexdigest() != expected_hash:
raise SvgNativeConversionError(
'placeholder spPr metadata hash does not match its payload'
)
if sp_pr.tag != (
'{http://schemas.openxmlformats.org/presentationml/2006/main}spPr'
):
raise SvgNativeConversionError(
'placeholder spPr metadata payload must be p:spPr'
)
if has_relationship_attributes(sp_pr):
raise SvgNativeConversionError(
'placeholder spPr metadata cannot contain relationships'
)
pattern = re.compile(r'<p:spPr>.*?</p:spPr>', re.DOTALL)
if pattern.search(shape.xml) is None:
raise SvgNativeConversionError(
'placeholder spPr metadata can only attach to p:sp'
)
return ShapeResult(
xml=pattern.sub(lambda _match: decoded, shape.xml, count=1),
bounds_emu=shape.bounds_emu,
)
def preserved_native_text_body(
group: ET.Element,
*,
@@ -724,6 +787,7 @@ def preserved_native_text_body(
has_foreign_visual = any(
child.tag.replace(f'{{{SVG_NS}}}', '') not in {'text', 'metadata'}
and child.get('data-pptx-part') not in allowed_parts
and not _is_import_text_flip_compensation(child)
for child in group
)
if decoded_text is None:
@@ -740,6 +804,120 @@ def preserved_native_text_body(
return carrier_children[0], native_text
def _is_import_text_flip_compensation(elem: ET.Element) -> bool:
"""Recognize the importer-only wrapper that keeps flipped text upright."""
if (
elem.tag.replace(f'{{{SVG_NS}}}', '') != 'g'
or elem.get('data-pptx-text-flip-compensation') != 'true'
):
return False
return all(
child.tag.replace(f'{{{SVG_NS}}}', '') in {'g', 'text', 'tspan', 'a'}
for child in elem.iter()
if child is not elem
)
def _roundtrip_graphic_frame(
elem: ET.Element,
ctx: ConvertContext,
) -> ShapeResult | None:
"""Restore an unchanged relationship-free imported graphicFrame."""
if elem.get('data-pptx-roundtrip-object') != 'graphic-frame':
return None
if elem.get('data-pptx-object') != 'graphic-frame':
raise SvgNativeConversionError(
'round-trip graphic-frame metadata requires '
'data-pptx-object="graphic-frame"'
)
require_fresh_native_fallback(
elem,
use_runtime_snapshot=True,
)
metadata = next(
(
child
for child in elem
if child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata'
and child.get('data-pptx-part') == 'roundtrip-graphic-frame'
),
None,
)
if metadata is None or metadata.get('data-pptx-encoding') != 'base64':
raise SvgNativeConversionError(
'round-trip graphic-frame metadata requires a base64 payload'
)
expected_hash = metadata.get('data-pptx-ooxml-sha256')
if not expected_hash or not re.fullmatch(r'[0-9a-f]{64}', expected_hash):
raise SvgNativeConversionError(
'round-trip graphic-frame metadata requires a lowercase SHA-256'
)
try:
raw = base64.b64decode((metadata.text or '').strip(), validate=True)
frame = ET.fromstring(raw)
except (ValueError, binascii.Error, ET.ParseError) as exc:
raise SvgNativeConversionError(
f'Invalid round-trip graphic-frame metadata: {exc}'
) from exc
if hashlib.sha256(raw).hexdigest() != expected_hash:
raise SvgNativeConversionError(
'round-trip graphic-frame metadata hash does not match its payload'
)
pml_namespace = (
'http://schemas.openxmlformats.org/presentationml/2006/main'
)
if frame.tag != f'{{{pml_namespace}}}graphicFrame':
raise SvgNativeConversionError(
'round-trip graphic-frame payload must be p:graphicFrame'
)
if has_relationship_attributes(frame):
raise SvgNativeConversionError(
'round-trip graphic-frame payload cannot contain relationships'
)
shape_id = ctx.claim_shape_id(
elem.get('data-pptx-shape-id'),
elem.get('data-pptx-shape-scope'),
)
c_nv_pr = frame.find(
f'{{{pml_namespace}}}nvGraphicFramePr/'
f'{{{pml_namespace}}}cNvPr'
)
if c_nv_pr is None:
raise SvgNativeConversionError(
'round-trip graphic-frame payload has no p:cNvPr'
)
c_nv_pr.set('id', str(shape_id))
raw_frame = elem.get('data-pptx-frame')
try:
values = tuple(
float(value)
for value in re.split(r'[\s,]+', (raw_frame or '').strip())
)
except ValueError as exc:
raise SvgNativeConversionError(
f'Invalid round-trip graphic-frame bounds: {raw_frame!r}'
) from exc
if (
len(values) != 4
or not all(math.isfinite(value) for value in values)
or values[2] <= 0
or values[3] <= 0
):
raise SvgNativeConversionError(
f'Invalid round-trip graphic-frame bounds: {raw_frame!r}'
)
bounds = (
round(values[0] * EMU_PER_PX),
round(values[1] * EMU_PER_PX),
round((values[0] + values[2]) * EMU_PER_PX),
round((values[1] + values[3]) * EMU_PER_PX),
)
return ShapeResult(
xml=ET.tostring(frame, encoding='unicode'),
bounds_emu=bounds,
)
# ---------------------------------------------------------------------------
# Group handling
# ---------------------------------------------------------------------------
@@ -753,6 +931,10 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes
keep their absolute slide coordinates unchanged.
"""
exact_graphic_frame = _roundtrip_graphic_frame(elem, ctx)
if exact_graphic_frame is not None:
return exact_graphic_frame
transform = elem.get('transform', '')
native_subtree_active = _contains_enabled_native_replacement(elem, ctx)
if native_subtree_active:
@@ -889,6 +1071,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
geometry_result,
native_text,
)
restored = _restore_placeholder_sp_pr(restored, elem)
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
if shape_match:
@@ -932,11 +1115,12 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
or logical_picture_effect_group
)
):
restored_child = _restore_placeholder_sp_pr(child_results[0], elem)
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', child_results[0].xml)
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored_child.xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return child_results[0]
return restored_child
# Multiple children, or a top-level semantic one-child group: wrap in
# <p:grpSp> so PowerPoint can animate the group as one unit.
@@ -1564,7 +1748,11 @@ def convert_svg_to_slide_shapes(
) from exc
_mark_unchanged_txbody_groups(root)
_mark_unchanged_preset_previews(root)
if native_objects:
has_roundtrip_object = any(
elem.get('data-pptx-roundtrip-object') is not None
for elem in root.iter()
)
if native_objects or has_roundtrip_object:
try:
snapshot_native_fallback_freshness(root)
except NativeMarkerAttributeError as exc:
@@ -23,7 +23,13 @@ from pptx_shapes import (
load_shape_type_values,
validate_ooxml_xfrm,
)
from pptx_effects import EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR
from pptx_effects import (
EFFECT_REASON_ATTR,
EFFECT_STATUS_ATTR,
NATIVE_EFFECT_ATTR,
NATIVE_EFFECT_SHA256_ATTR,
preserved_native_effect_xml,
)
from hyperlink_contract import svg_hyperlink_href
from pptx_to_svg.preset_authoring import AUTHORING_ATTR, AUTHORING_VALUE
from resource_paths import (
@@ -457,6 +463,7 @@ def _wrap_shape(
effect_xml: str = '', extra_xml: str = '',
rot: int = 0,
xfrm_attr: str = '',
placeholder_xml: str = '',
) -> str:
"""Wrap DrawingML content into a <p:sp> shape element."""
rot_attr = f' rot="{rot}"' if rot else ''
@@ -464,7 +471,7 @@ def _wrap_shape(
return f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
<p:cNvSpPr/><p:nvPr/>
<p:cNvSpPr/><p:nvPr>{placeholder_xml}</p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
@@ -528,6 +535,8 @@ def _wrap_geometry_object(
xfrm_attr: str = '',
) -> str:
"""Wrap a semantic leaf as a shape or connector without guessing."""
if not effect_xml:
effect_xml = _element_effect_xml(elem, ctx)
name = elem.get('data-pptx-shape-name') or name
shape_style_xml = _decode_shape_style(elem)
object_kind = elem.get('data-pptx-object')
@@ -545,6 +554,7 @@ def _wrap_geometry_object(
effect_xml,
extra_xml=shape_style_xml,
xfrm_attr=xfrm_attr,
placeholder_xml=_imported_placeholder_xml(elem),
)
prst = elem.get('data-pptx-prst')
@@ -571,6 +581,56 @@ def _wrap_geometry_object(
)
def _imported_placeholder_xml(elem: ET.Element) -> str:
"""Restore an imported slide placeholder marker when its identity is exact."""
placeholder_type = elem.get('data-ph-type')
placeholder_index = elem.get('data-pptx-placeholder-index')
if not placeholder_type or placeholder_index is None:
return ''
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', placeholder_type):
raise ValueError(
f'Invalid imported placeholder type: {placeholder_type!r}'
)
if not placeholder_index.isdigit() or int(placeholder_index) > 0xFFFFFFFF:
raise ValueError(
f'Invalid imported placeholder index: {placeholder_index!r}'
)
attrs = {
'type': placeholder_type,
'idx': placeholder_index,
}
placeholder_size = elem.get('data-pptx-placeholder-size')
if placeholder_size is not None:
if placeholder_size not in {'full', 'half', 'quarter'}:
raise ValueError(
f'Invalid imported placeholder size: {placeholder_size!r}'
)
attrs['sz'] = placeholder_size
orientation = elem.get('data-pptx-placeholder-orientation')
if orientation is not None:
if orientation not in {'horz', 'vert'}:
raise ValueError(
f'Invalid imported placeholder orientation: {orientation!r}'
)
attrs['orient'] = orientation
serialized = ' '.join(
f'{name}="{_xml_escape(value)}"'
for name, value in attrs.items()
)
return f'<p:ph {serialized}/>'
def _element_effect_xml(elem: ET.Element, ctx: ConvertContext) -> str:
"""Honor an authored SVG filter before the imported native fallback."""
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
return build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
return preserved_native_effect_xml(elem) or ''
def _decode_shape_style(elem: ET.Element) -> str:
encoded = elem.get('data-pptx-shape-style')
if not encoded:
@@ -4605,13 +4665,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
# Resolve clip-path → DrawingML geometry
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
effect_xml = ''
filter_id = get_effective_filter_id(elem, ctx)
if filter_id and filter_id in ctx.defs:
effect_xml = build_effect_xml(
ctx.defs[filter_id],
get_element_opacity(elem, ctx),
)
effect_xml = _element_effect_xml(elem, ctx)
# Resolve preserveAspectRatio="<align> slice" as DrawingML crop metadata.
# Image optimization only downscales the full source image; it never crops
@@ -4794,6 +4848,8 @@ _NESTED_CROP_OUTER_ATTRIBUTES = frozenset({
'data-pptx-editable',
EFFECT_REASON_ATTR,
EFFECT_STATUS_ATTR,
NATIVE_EFFECT_ATTR,
NATIVE_EFFECT_SHA256_ATTR,
'data-pptx-frame',
'data-pptx-layer',
'data-pptx-object',
@@ -5241,13 +5297,7 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
svg_w,
svg_h,
)
effect_xml = ''
filter_id = get_effective_filter_id(elem, ctx)
if filter_id and filter_id in ctx.defs:
effect_xml = build_effect_xml(
ctx.defs[filter_id],
get_element_opacity(elem, ctx),
)
effect_xml = _element_effect_xml(elem, ctx)
blip_xml = _build_image_blip_xml(
r_id,
get_element_opacity(image_elem, ctx),
@@ -6,6 +6,7 @@ import math
from xml.etree import ElementTree as ET
from pptx_shapes import validate_ooxml_line_width
from pptx_gradients import preserved_native_gradient_xml
from .context import ConvertContext
from .theme_colors import ThemeColorSpec, color_node_xml
@@ -47,6 +48,9 @@ def build_gradient_fill(
usage: str = "fill",
) -> str:
"""Build <a:gradFill> from SVG linearGradient or radialGradient element."""
native = preserved_native_gradient_xml(grad_elem)
if native is not None:
return native
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
stops_xml = []
@@ -16,6 +16,13 @@ from collections.abc import Iterator
from decimal import Decimal, ROUND_HALF_UP
from xml.etree import ElementTree as ET
from pptx_gradients import (
NATIVE_GRADIENT_ATTR,
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
NATIVE_GRADIENT_SHA256_ATTR,
preserved_native_gradient_xml,
)
from pptx_shapes import (
OOXML_COORDINATE_MAX,
resolve_preset_preview_hash,
@@ -2143,6 +2150,17 @@ def project_marker_errors(root: ET.Element) -> list[str]:
else:
marker_channel = 'fill'
marker_paint = marker_fill
gradient_id = resolve_url_id(stroke_value)
gradient = definitions.get(gradient_id) if gradient_id else None
if gradient is not None:
try:
native_gradient = preserved_native_gradient_xml(gradient)
except ValueError:
native_gradient = None
if native_gradient is not None:
# The original DrawingML line owns both gradient stroke
# and arrowhead paint; the solid SVG marker is its preview.
continue
stroke_color, _stroke_alpha = parse_svg_color(stroke_value or '')
marker_color, _marker_alpha = parse_svg_color(marker_paint)
if stroke_color is None or marker_color is None:
@@ -2408,6 +2426,21 @@ def project_gradient_errors(root: ET.Element) -> list[str]:
continue
gradient_id = gradient.get('id')
label = f'<{tag} id="{gradient_id}">' if gradient_id else f'<{tag}>'
if any(
gradient.get(name) is not None
for name in (
NATIVE_GRADIENT_ATTR,
NATIVE_GRADIENT_SHA256_ATTR,
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
)
):
try:
native = preserved_native_gradient_xml(gradient)
except ValueError as exc:
errors.add(f'{label} has invalid imported gradient payload: {exc}')
continue
if native is not None:
continue
attribute_names = {
name.rsplit('}', 1)[-1]
for name in gradient.attrib
@@ -2,6 +2,10 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import posixpath
import sys
from typing import Any
from xml.etree import ElementTree as ET
@@ -99,18 +103,200 @@ __all__ = [
]
def _decode_source_chart_blob(blob: object, field_name: str) -> bytes:
if not isinstance(blob, dict) or blob.get("encoding") != "base64":
raise RuntimeError(f"Native PPTX chart {field_name} must be base64 metadata")
encoded = blob.get("payload")
expected_sha = blob.get("sha256")
if not isinstance(encoded, str) or not isinstance(expected_sha, str):
raise RuntimeError(f"Native PPTX chart {field_name} is incomplete")
try:
payload = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise RuntimeError(f"Native PPTX chart {field_name} is invalid base64") from exc
if hashlib.sha256(payload).hexdigest() != expected_sha.lower():
raise RuntimeError(f"Native PPTX chart {field_name} checksum mismatch")
return payload
def _source_part_owner(rels_name: str) -> str:
marker = "/_rels/"
if marker not in rels_name or not rels_name.endswith(".rels"):
raise RuntimeError("Native PPTX chart source relationship part name is invalid")
parent, filename = rels_name.split(marker, 1)
return f"{parent}/{filename[:-5]}"
def _decode_source_chart_package(
payload: dict[str, Any],
) -> tuple[str, ET.Element, dict[str, bytes], dict[str, str]] | None:
source = payload.get("source_package")
if source is None:
return None
if not isinstance(source, dict):
raise RuntimeError("Native PPTX chart source_package must be an object")
chart_part = source.get("chart_part")
raw_parts = source.get("parts")
if (
not isinstance(chart_part, str)
or not chart_part.startswith("ppt/charts/")
or not isinstance(raw_parts, list)
or not raw_parts
or len(raw_parts) > 32
):
raise RuntimeError("Native PPTX chart source_package inventory is invalid")
parts: dict[str, bytes] = {}
content_types: dict[str, str] = {}
total_size = 0
for index, item in enumerate(raw_parts):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart source_package part must be an object")
name = item.get("name")
if (
not isinstance(name, str)
or "\\" in name
or name.startswith("/")
or posixpath.normpath(name) != name
or not name.startswith(
("ppt/charts/", "ppt/embeddings/", "ppt/theme/")
)
or name in parts
):
raise RuntimeError("Native PPTX chart source_package part name is invalid")
part_payload = _decode_source_chart_blob(
item,
f"source_package.parts[{index}]",
)
total_size += len(part_payload)
if total_size > 20_000_000:
raise RuntimeError("Native PPTX chart source_package is too large")
parts[name] = part_payload
content_type = item.get("content_type")
if content_type is not None and not isinstance(content_type, str):
raise RuntimeError(
"Native PPTX chart source_package content_type must be a string"
)
if content_type:
content_types[name] = content_type
chart_xml = parts.get(chart_part)
if chart_xml is None:
raise RuntimeError("Native PPTX chart source_package omits its chart part")
try:
chart_root = ET.fromstring(chart_xml)
except ET.ParseError as exc:
raise RuntimeError("Native PPTX chart source package chart XML is malformed") from exc
if chart_root.tag != f"{{{CHART_URI}}}chartSpace":
raise RuntimeError("Native PPTX chart source package root must be c:chartSpace")
package_rel_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
for name, part_payload in parts.items():
if not name.endswith(".rels"):
continue
try:
rels_root = ET.fromstring(part_payload)
except ET.ParseError as exc:
raise RuntimeError(
"Native PPTX chart source relationship XML is malformed"
) from exc
owner = _source_part_owner(name)
for rel in rels_root.findall(f"{{{package_rel_ns}}}Relationship"):
if rel.attrib.get("TargetMode") == "External":
raise RuntimeError(
"Native PPTX chart source_package cannot contain external relationships"
)
target = rel.attrib.get("Target")
if not target:
raise RuntimeError(
"Native PPTX chart source_package relationship has no target"
)
resolved = posixpath.normpath(
posixpath.join(posixpath.dirname(owner), target)
).lstrip("/")
if resolved not in parts:
raise RuntimeError(
"Native PPTX chart source_package relationship target is missing"
)
frame_payload = _decode_source_chart_blob(
source.get("frame"),
"source_package.frame",
)
try:
frame = ET.fromstring(frame_payload)
except ET.ParseError as exc:
raise RuntimeError("Native PPTX chart source frame is malformed") from exc
pml_ns = "http://schemas.openxmlformats.org/presentationml/2006/main"
if frame.tag != f"{{{pml_ns}}}graphicFrame":
raise RuntimeError("Native PPTX chart source frame must be p:graphicFrame")
return chart_part, frame, parts, content_types
def _source_chart_frame_xml(
frame: ET.Element,
*,
shape_id: int,
rel_id: str,
) -> str:
pml_ns = "http://schemas.openxmlformats.org/presentationml/2006/main"
rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
c_nv_pr = frame.find(f"{{{pml_ns}}}nvGraphicFramePr/{{{pml_ns}}}cNvPr")
chart_refs = list(frame.iter(f"{{{CHART_URI}}}chart"))
if c_nv_pr is None or len(chart_refs) != 1:
raise RuntimeError("Native PPTX chart source frame structure is invalid")
relationship_attrs = [
(node, name)
for node in frame.iter()
for name in node.attrib
if isinstance(name, str) and name.startswith(f"{{{rel_ns}}}")
]
if len(relationship_attrs) != 1 or relationship_attrs[0][0] is not chart_refs[0]:
raise RuntimeError(
"Native PPTX chart source frame has unsupported relationships"
)
c_nv_pr.set("id", str(shape_id))
chart_refs[0].set(f"{{{rel_ns}}}id", rel_id)
return ET.tostring(frame, encoding="unicode")
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
chart_data = _chart_data(payload)
source_package = _decode_source_chart_package(payload)
chart_data = None if source_package is not None else _chart_data(payload)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
shape_id = ctx.next_id()
shape_id = (
ctx.claim_shape_id(
elem.get("data-pptx-shape-id"),
elem.get("data-pptx-shape-scope"),
)
if source_package is not None
else ctx.next_id()
)
rel_id = ctx.next_rel_id()
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
part_index = ctx.slide_num * 100 + local_index
workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
workbook_part = f"ppt/embeddings/{workbook_name}"
if chart_data["kind"] == "chartex":
if source_package is not None:
chart_part, source_frame, source_parts, source_content_types = source_package
chart_name = posixpath.basename(chart_part)
graphic_uri = CHART_URI
chart_ref_xml = ""
ctx.rel_entries.append({
"id": rel_id,
"type": CHART_REL_TYPE,
"target": posixpath.relpath(chart_part, "ppt/slides"),
})
for part_name, part_payload in source_parts.items():
existing = ctx.package_files.get(part_name)
if existing is not None and existing != part_payload:
raise RuntimeError(
f"Native PPTX chart source package part collision: {part_name}"
)
ctx.package_files[part_name] = part_payload
ctx.content_type_overrides.update(source_content_types)
elif chart_data is not None and chart_data["kind"] == "chartex":
chart_name = f"chartEx{part_index}.xml"
style_name = f"style{part_index}.xml"
colors_name = f"colors{part_index}.xml"
@@ -165,6 +351,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
)
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
assert chart_data is not None
if chart_data["kind"] == "xy":
ctx.package_files[workbook_part] = _minimal_xy_chart_workbook(chart_data)
else:
@@ -172,7 +359,14 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
chart_frame_xml = f'''<p:graphicFrame>
chart_frame_xml = (
_source_chart_frame_xml(
source_frame,
shape_id=shape_id,
rel_id=rel_id,
)
if source_package is not None
else f'''<p:graphicFrame>
<p:nvGraphicFramePr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
@@ -185,6 +379,11 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
</a:graphicData>
</a:graphic>
</p:graphicFrame>'''
)
if source_package is not None:
companion_xml = ""
else:
assert chart_data is not None
text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles)
companion_xml = _chart_companion_text_xml(
@@ -244,6 +443,16 @@ def _validate_native_object_marker_payload(
"Native PPTX table bounds must provide at least one EMU per row and column"
)
elif kind == "chart":
source_package = _decode_source_chart_package(payload)
if (
elem.get("data-pptx-roundtrip-object")
== "source-chart-package"
and source_package is None
):
raise RuntimeError(
"Round-trip source-chart marker requires source_package"
)
if source_package is None:
chart_data = _chart_data(payload)
_chart_plot_area_layout(
chart_data,
@@ -297,6 +506,7 @@ def validate_native_object_marker_with_warnings(
if kind == "table" and isinstance(validated_data, list):
warnings.extend(_native_table_warnings(elem, validated_data))
elif kind == "chart":
if payload.get("source_package") is None:
warnings.extend(_native_chart_chrome_warnings(elem, payload))
return warnings
@@ -347,6 +557,7 @@ def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult
)
if kind == "table":
return _build_native_table(elem, ctx, payload)
if payload.get("source_package") is None:
payload, warnings = _native_chart_export_payload(elem, payload)
for warning in warnings:
print(
@@ -135,6 +135,30 @@ def _chart_data_labels(
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")
source_ooxml = config.get("source_ooxml")
if source_ooxml is not None:
if not isinstance(source_ooxml, dict):
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml must be an object"
)
if source_ooxml.get("encoding") != "base64":
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml encoding must be base64"
)
payload_text = source_ooxml.get("payload")
checksum = source_ooxml.get("sha256")
if not isinstance(payload_text, str) or not payload_text:
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml payload must be non-empty"
)
if (
not isinstance(checksum, str)
or len(checksum) != 64
or any(char not in "0123456789abcdef" for char in checksum.lower())
):
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml sha256 is invalid"
)
return config
@@ -172,6 +196,16 @@ def _data_label_point_items(
chart_type,
grouping,
)
if data.get("delete") is not None and not isinstance(data["delete"], bool):
raise RuntimeError(
"Native PPTX chart data_labels.points delete must be a boolean"
)
if data.get("text") is not None and (
not isinstance(data["text"], str) or not data["text"]
):
raise RuntimeError(
"Native PPTX chart data_labels.points text must be a non-empty string"
)
seen.add(index)
data["idx"] = index
items.append(data)
@@ -696,7 +730,13 @@ def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str,
return style
def _category_series(payload: dict[str, Any], categories: list[Any]) -> list[dict[str, Any]]:
def _category_series(
payload: dict[str, Any],
categories: list[Any],
*,
chart_type: str,
grouping: str | None,
) -> list[dict[str, Any]]:
raw_series = payload.get("series", [])
if not categories or not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX chart requires non-empty categories and series")
@@ -752,6 +792,14 @@ def _category_series(payload: dict[str, Any], categories: list[Any]) -> list[dic
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
data_labels = _chart_data_labels(
item,
chart_type,
grouping,
len(categories),
)
if data_labels is not None:
series_item["data_labels"] = data_labels
series.append(series_item)
return series
@@ -783,7 +831,17 @@ def _category_chart_data(
)
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
series = _category_series(payload, categories)
grouping = (
_chart_grouping(chart_type, payload, alias_grouping)
if chart_type in {"bar", "column", "line", "area"}
else None
)
series = _category_series(
payload,
categories,
chart_type=chart_type,
grouping=grouping,
)
if chart_type in {"doughnut", "of_pie", "pie"}:
if len(series) != 1:
raise RuntimeError("Native PPTX pie-family charts support exactly one series")
@@ -816,11 +874,6 @@ 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,
@@ -953,12 +1006,17 @@ def _combo_plot_entry(
)
if not plot_categories:
raise RuntimeError("Native PPTX combo plot categories must be non-empty")
plot_series = fallback_series or _category_series(plot_payload, plot_categories)
grouping = (
_chart_grouping(chart_type, plot_payload, alias_grouping)
if chart_type in {"area", "column", "line"}
else None
)
plot_series = fallback_series or _category_series(
plot_payload,
plot_categories,
chart_type=chart_type,
grouping=grouping,
)
entry: dict[str, Any] = {
"axis": axis,
"categories": plot_categories,
@@ -1031,7 +1089,18 @@ def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
"Native PPTX combo typed series with plot-scoped metadata "
"must use plots"
)
one_series = _category_series({"series": [item]}, categories)
typed_chart_type, typed_grouping_alias, _typed_style = _combo_plot_type(item)
typed_grouping = (
_chart_grouping(typed_chart_type, item, typed_grouping_alias)
if typed_chart_type in {"area", "column", "line"}
else None
)
one_series = _category_series(
{"series": [item]},
categories,
chart_type=typed_chart_type,
grouping=typed_grouping,
)
plot = _combo_plot_entry(
item,
categories,
@@ -1289,7 +1358,12 @@ def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
{"name": default_name, "values": payload.get(field_name, [])}
for field_name, default_name in field_names
]
series = _category_series({"series": raw_series}, categories)
series = _category_series(
{"series": raw_series},
categories,
chart_type="stock",
grouping=None,
)
if len(series) != 4:
raise RuntimeError("Native PPTX stock chart requires exactly four series: open, high, low, close")
return {
@@ -2,6 +2,9 @@
from __future__ import annotations
import base64
import binascii
import hashlib
from typing import Any
from xml.etree import ElementTree as ET
@@ -150,6 +153,88 @@ def _data_label_flags_xml(config: dict[str, Any]) -> str:
)
def _source_data_labels_xml(config: dict[str, Any]) -> str | None:
source = config.get("source_ooxml")
if source is None:
return None
if not isinstance(source, dict) or source.get("encoding") != "base64":
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml must be base64 metadata"
)
encoded = source.get("payload")
expected_sha = source.get("sha256")
if not isinstance(encoded, str) or not isinstance(expected_sha, str):
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml is incomplete"
)
try:
payload = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml payload is invalid base64"
) from exc
if len(payload) > 2_000_000:
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml payload is too large"
)
if hashlib.sha256(payload).hexdigest() != expected_sha.lower():
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml checksum mismatch"
)
try:
root = ET.fromstring(payload)
except ET.ParseError as exc:
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml is malformed"
) from exc
chart_ns = "http://schemas.openxmlformats.org/drawingml/2006/chart"
rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
if root.tag != f"{{{chart_ns}}}dLbls":
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml root must be c:dLbls"
)
if any(
isinstance(name, str) and name.startswith(f"{{{rel_ns}}}")
for node in root.iter()
for name in node.attrib
):
raise RuntimeError(
"Native PPTX chart data_labels.source_ooxml cannot contain relationships"
)
return ET.tostring(root, encoding="unicode")
def _data_label_custom_text_xml(
text: str,
*,
font_size: int,
color: str | None,
bold: bool,
font_face: str | None,
language: str | None,
) -> str:
fill_xml = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
if color else ""
)
bold_attr = ' b="1"' if bold else ""
paragraphs: list[str] = []
for line in text.split("\n"):
lang = detect_text_lang(line, language)
rtl_attr = ' rtl="1"' if text_uses_rtl(line, language) else ""
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(line) else ""
paragraphs.append(
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
f'sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}'
f'{run_rtl}</a:rPr><a:t>{_xml_escape(line)}</a:t></a:r></a:p>'
)
return (
"<c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
+ "".join(paragraphs)
+ "</c:rich></c:tx>"
)
def _data_labels_xml(
config: dict[str, Any] | None,
*,
@@ -163,6 +248,9 @@ def _data_labels_xml(
) -> str:
if config is None:
return ""
source_xml = _source_data_labels_xml(config)
if source_xml is not None:
return source_xml
show_leader_lines = _chart_bool(
_first_present(
config.get("show_leader_lines"),
@@ -208,6 +296,9 @@ def _data_labels_xml(
if item is None:
point_label_xml += f'<c:dLbl><c:idx val="{idx}"/><c:delete val="1"/></c:dLbl>'
continue
if item.get("delete") is True:
point_label_xml += f'<c:dLbl><c:idx val="{idx}"/><c:delete val="1"/></c:dLbl>'
continue
item_font_size_raw = _first_present(item.get("font_size"), item.get("fontSize"))
item_font_size = (
_font_size_hpt(item_font_size_raw, 12)
@@ -239,8 +330,19 @@ def _data_labels_xml(
font_face=item_font_face,
language=language,
)
custom_text_xml = ""
if item.get("text") is not None:
custom_text_xml = _data_label_custom_text_xml(
str(item["text"]),
font_size=item_font_size,
color=item_color,
bold=item_bold,
font_face=item_font_face,
language=language,
)
point_label_xml += (
f'<c:dLbl><c:idx val="{idx}"/>'
f"{custom_text_xml}"
f"{item_num_fmt_xml}"
f"{item_text_properties_xml}"
f"{item_position_xml}"
@@ -399,9 +501,13 @@ def _series_xml(
)
marker_xml = _marker_xml(radar_marker_style)
invert_xml = '<c:invertIfNegative val="0"/>' if chart_type in {"bar", "column"} else ""
item_data_labels = _data_labels_config(item)
effective_data_labels = (
item_data_labels if item_data_labels is not None else data_labels
)
data_labels_xml = (
_data_labels_xml(
data_labels,
effective_data_labels,
chart_type=chart_type,
grouping=grouping,
point_count=len(item["values"]),
@@ -410,7 +516,10 @@ def _series_xml(
default_font_face=data_label_font_face,
language=language,
)
if _series_scoped_data_labels(data_labels)
if (
item_data_labels is not None
or _series_scoped_data_labels(data_labels)
)
and chart_type in {"area", "bar", "column", "line"}
else ""
)
@@ -41,7 +41,10 @@ def snapshot_native_fallback_freshness(root: ET.Element) -> None:
for elem in root.iter():
if elem.tag.rsplit("}", 1)[-1] == "metadata":
continue
if not native_replacement_kind(elem):
if (
not native_replacement_kind(elem)
and elem.get("data-pptx-roundtrip-object") is None
):
continue
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
@@ -27,6 +27,15 @@ from xml.sax.saxutils import escape, quoteattr
from pptx import Presentation
from pptx.util import Emu
from pptx_embedded_fonts import (
FONT_CONTENT_TYPE,
FONT_REL_TYPE,
PML_NS as EMBEDDED_FONT_PML_NS,
REL_NS as EMBEDDED_FONT_REL_NS,
EmbeddedFontBundle,
EmbeddedFontError,
embedded_font_typefaces,
)
from pptx_transitions import (
MorphPairExpectation,
NATIVE_TRANSITIONS,
@@ -69,6 +78,7 @@ from ..animation_config import (
from ..drawingml.context import resolve_text_flow
from ..drawingml.converter import convert_svg_to_slide_shapes
from ..drawingml.theme_colors import (
ThemeColorError,
ThemeColorSpec,
apply_theme_color_spec,
rewrite_chart_accent_colors,
@@ -151,6 +161,7 @@ P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
for _prefix, _uri in (
("p", PML_NS),
@@ -4796,6 +4807,135 @@ def _clear_preserved_slide_collections(extract_dir: Path) -> None:
_write_xml_tree(presentation_path, tree)
def _install_source_theme_xml(extract_dir: Path, payload: bytes) -> None:
"""Install a validated imported theme into the generated flat package."""
try:
root = ET.fromstring(payload)
except ET.ParseError as exc:
raise ThemeColorError(f"Imported source theme is malformed: {exc}") from exc
if root.tag != f"{{{DML_NS}}}theme":
raise ThemeColorError("Imported source theme root must be a:theme")
if any(
isinstance(name, str) and name.startswith(f"{{{REL_NS}}}")
for node in root.iter()
for name in node.attrib
):
raise ThemeColorError("Imported source theme cannot contain relationships")
theme_paths = sorted((extract_dir / "ppt" / "theme").glob("theme*.xml"))
if not theme_paths:
raise ThemeColorError("Generated PPTX package has no theme part")
for theme_path in theme_paths:
theme_path.write_bytes(payload)
def _install_source_embedded_fonts(
extract_dir: Path,
bundle: EmbeddedFontBundle,
) -> tuple[str, ...]:
"""Install validated source font parts into the generated PPTX package."""
typefaces = embedded_font_typefaces(bundle)
try:
font_list = ET.fromstring(bundle.font_list_xml)
except ET.ParseError as exc:
raise EmbeddedFontError(
f"Embedded font list XML is malformed: {exc}"
) from exc
ppt_dir = extract_dir / "ppt"
presentation_path = ppt_dir / "presentation.xml"
presentation_rels_path = ppt_dir / "_rels" / "presentation.xml.rels"
presentation_tree = ET.parse(presentation_path)
presentation_root = presentation_tree.getroot()
rels_tree = ET.parse(presentation_rels_path)
rels_root = rels_tree.getroot()
fonts_dir = ppt_dir / "fonts"
fonts_dir.mkdir(exist_ok=True)
for relationship in list(rels_root):
if relationship.attrib.get("Type") != FONT_REL_TYPE:
continue
target = relationship.attrib.get("Target", "")
target_path = PurePosixPath(target)
if (
not target_path.is_absolute()
and target_path.parts[:1] == ("fonts",)
and target_path.suffix.lower() == ".fntdata"
):
candidate = ppt_dir.joinpath(*target_path.parts)
if candidate.is_file():
candidate.unlink()
rels_root.remove(relationship)
rid_numbers = [
int(match.group(1))
for relationship in rels_root
if (match := re.fullmatch(
r"rId(\d+)",
relationship.attrib.get("Id", ""),
))
]
next_rid = max(rid_numbers, default=0) + 1
relationship_mapping: dict[str, str] = {}
for index, part in enumerate(bundle.parts, start=1):
filename = f"font{index}.fntdata"
(fonts_dir / filename).write_bytes(part.payload)
relationship_id = f"rId{next_rid}"
next_rid += 1
ET.SubElement(
rels_root,
f"{{{PACKAGE_REL_NS}}}Relationship",
{
"Id": relationship_id,
"Type": FONT_REL_TYPE,
"Target": f"fonts/{filename}",
},
)
relationship_mapping[part.relationship_id] = relationship_id
for node in font_list.iter():
relationship_attr = f"{{{EMBEDDED_FONT_REL_NS}}}id"
original_id = node.attrib.get(relationship_attr)
if original_id is None:
continue
replacement_id = relationship_mapping.get(original_id)
if replacement_id is None:
raise EmbeddedFontError(
f"Embedded font list references an unknown part: {original_id}"
)
node.set(relationship_attr, replacement_id)
existing = presentation_root.find(
f"{{{EMBEDDED_FONT_PML_NS}}}embeddedFontLst"
)
if existing is not None:
insertion_index = list(presentation_root).index(existing)
presentation_root.remove(existing)
else:
default_text_style = presentation_root.find(
f"{{{EMBEDDED_FONT_PML_NS}}}defaultTextStyle"
)
insertion_index = (
list(presentation_root).index(default_text_style)
if default_text_style is not None
else len(presentation_root)
)
presentation_root.insert(insertion_index, font_list)
_write_xml_tree(presentation_path, presentation_tree)
_write_xml_tree(presentation_rels_path, rels_tree)
content_types_path = extract_dir / "[Content_Types].xml"
content_types_path.write_text(
_add_default_content_type(
content_types_path.read_text(encoding="utf-8"),
"fntdata",
FONT_CONTENT_TYPE,
),
encoding="utf-8",
)
return typefaces
def create_pptx_with_native_svg(
svg_files: list[Path],
output_path: Path,
@@ -4835,6 +4975,8 @@ def create_pptx_with_native_svg(
theme_font_spec: ThemeFontSpec | None = None,
master_text_style_spec: MasterTextStyleSpec | None = None,
theme_color_spec: ThemeColorSpec | None = None,
source_theme_xml: bytes | None = None,
source_embedded_fonts: EmbeddedFontBundle | None = None,
structured_baseline: bool = False,
baseline_layout_specs: list[TemplateSlideSpec] | None = None,
layout_definition_files: list[Path] | None = None,
@@ -4926,6 +5068,10 @@ def create_pptx_with_native_svg(
callers may omit it; other routes ignore this value.
theme_color_spec: Locked project color scheme for context-aware
flat/structured theme inheritance. Preserve mode ignores this value.
source_theme_xml: Complete validated source theme used only by an
explicit PPTX-import diagnostic round-trip.
source_embedded_fonts: Validated source font-list metadata and font
parts used only by an explicit PPTX-import diagnostic round-trip.
primary_language: Canonical BCP-47 deck content language. ``None``
preserves legacy per-run language detection.
structured_baseline: Obsolete compatibility argument; must remain false.
@@ -5112,9 +5258,18 @@ def create_pptx_with_native_svg(
)
if use_native_shapes:
print(f" Mode: Native DrawingML shapes (directly editable)")
native_object_mode = (
"Enabled"
if native_objects
else (
"Exact round-trip source packages only"
if native_structure_contract is not None
else "Disabled"
)
)
print(
" Native table/chart objects: "
f"{'Enabled' if native_objects else 'Disabled'}"
f"{native_object_mode}"
)
print(f" PPTX structure: {pptx_structure}")
if image_optimize:
@@ -5214,6 +5369,22 @@ def create_pptx_with_native_svg(
)
if active_theme_color_spec is not None:
apply_theme_color_spec(extract_dir, active_theme_color_spec)
if (
source_theme_xml is not None
and native_structure_contract is None
):
_install_source_theme_xml(extract_dir, source_theme_xml)
if source_embedded_fonts is not None:
installed_typefaces = _install_source_embedded_fonts(
extract_dir,
source_embedded_fonts,
)
if verbose:
print(
" Embedded fonts: preserved "
f"{len(source_embedded_fonts.parts)} part(s) for "
+ ", ".join(installed_typefaces)
)
structure = _read_slide_layout_targets(extract_dir, len(svg_files))
media_dir = extract_dir / 'ppt' / 'media'
@@ -3,6 +3,8 @@
from __future__ import annotations
import argparse
import base64
import binascii
import hashlib
import json
import math
@@ -26,6 +28,11 @@ from language_tags import ( # noqa: E402
normalize_language_tag,
)
from native_payloads import PAYLOAD_STORE_RELATIVE_PATH # noqa: E402
from pptx_embedded_fonts import ( # noqa: E402
EmbeddedFontBundle,
EmbeddedFontError,
load_embedded_font_bundle,
)
from pptx_animations import ( # noqa: E402
ANIMATIONS,
animation_seconds_to_milliseconds,
@@ -58,7 +65,11 @@ from ..native_objects import (
native_replacement_status,
)
from ..native_objects.marker_status import native_marker_release_block_reason
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
from ..drawingml.theme_colors import (
ThemeColorError,
ThemeColorSpec,
load_theme_color_spec,
)
from ..drawingml.context import (
TEXT_FLOW_PRESERVE,
TEXT_FLOW_REFLOW,
@@ -66,6 +77,8 @@ from ..drawingml.context import (
)
from ..drawingml.theme_fonts import (
ThemeFontError,
ThemeFontFace,
ThemeFontSpec,
load_master_text_style_spec,
load_theme_font_spec,
)
@@ -77,7 +90,9 @@ from .narration import (
probe_audio_duration,
)
from .template_structure import (
PptxStructureLock,
TemplateStructureError,
load_native_structure_contract,
load_pptx_structure_lock,
parse_template_slides,
structured_layout_definition_files,
@@ -91,6 +106,10 @@ from ..animation_config import (
validate_animation_config_errors,
validate_transition_config,
)
from template_import.native_structure import (
CONTRACT_NAME as NATIVE_STRUCTURE_NAME,
SOURCE_TEMPLATE_NAME,
)
def _as_dict(value: object) -> dict:
@@ -131,6 +150,145 @@ class PptxPostflightValidationError(RuntimeError):
"""Reject a generated PPTX that fails package postflight validation."""
def _load_diagnostic_import_source(
project_path: Path,
) -> tuple[
ThemeColorSpec | None,
ThemeFontSpec | None,
bytes | None,
EmbeddedFontBundle | None,
]:
"""Load source-document evidence emitted for diagnostic round-trip."""
report_path = project_path / 'conversion-report.json'
if not report_path.is_file():
return None, None, None, None
try:
report = json.loads(report_path.read_text(encoding='utf-8'))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ThemeColorError(
f'Cannot read diagnostic import theme from {report_path}: {exc}'
) from exc
if not isinstance(report, dict):
raise ThemeColorError(
f'Diagnostic import report must be a JSON object: {report_path}'
)
source_document = report.get('sourceDocument')
if not isinstance(source_document, dict):
return None, None, None, None
theme = source_document.get('theme')
if not isinstance(theme, dict):
color_spec = None
font_spec = None
theme_xml = None
else:
color_spec = _diagnostic_theme_color_spec(theme.get('colors'))
font_spec = _diagnostic_theme_font_spec(theme.get('fonts'))
theme_xml = _diagnostic_theme_xml(theme.get('ooxml'))
embedded_fonts = load_embedded_font_bundle(
project_path,
source_document.get('embeddedFonts'),
)
return color_spec, font_spec, theme_xml, embedded_fonts
def _diagnostic_theme_color_spec(value: object) -> ThemeColorSpec | None:
"""Build an exact source color scheme without semantic role promotion."""
if not isinstance(value, dict):
return None
required_slots = {
'dk1', 'lt1', 'dk2', 'lt2',
'accent1', 'accent2', 'accent3',
'accent4', 'accent5', 'accent6',
'hlink', 'folHlink',
}
slots: dict[str, str] = {}
for slot, raw_color in value.items():
if slot not in required_slots or not isinstance(raw_color, str):
continue
color = raw_color.strip().lstrip('#').upper()
if re.fullmatch(r'[0-9A-F]{6}', color):
slots[slot] = color
if not required_slots.issubset(slots):
return None
return ThemeColorSpec(
slots=slots,
roles={},
role_slots={},
)
def _diagnostic_theme_font_spec(value: object) -> ThemeFontSpec | None:
"""Build exact major/minor theme faces from importer evidence."""
if not isinstance(value, dict):
return None
def face(prefix: str) -> ThemeFontFace | None:
latin = value.get(f'{prefix}Latin')
east_asian = (
value.get(f'{prefix}EastAsia')
or value.get(f'{prefix}ScriptHans')
or latin
)
complex_script = value.get(f'{prefix}ComplexScript') or latin
if not all(
isinstance(item, str) and item.strip()
for item in (latin, east_asian, complex_script)
):
return None
return ThemeFontFace(
latin=latin.strip(),
ea=east_asian.strip(),
cs=complex_script.strip(),
)
major = face('major')
minor = face('minor')
if major is None or minor is None:
return None
return ThemeFontSpec(
major=major,
minor=minor,
major_family=major.ea,
minor_family=minor.ea,
)
def _diagnostic_theme_xml(value: object) -> bytes | None:
"""Validate the complete source theme part stored by pptx_to_svg."""
if not isinstance(value, dict):
return None
if value.get('encoding') != 'base64':
raise ThemeColorError('Diagnostic source theme OOXML must use base64')
payload = value.get('payload')
expected_sha256 = value.get('sha256')
if not isinstance(payload, str) or not isinstance(expected_sha256, str):
raise ThemeColorError(
'Diagnostic source theme OOXML requires payload and sha256'
)
try:
raw = base64.b64decode(payload, validate=True)
except (binascii.Error, ValueError) as exc:
raise ThemeColorError(
'Diagnostic source theme OOXML payload is not canonical base64'
) from exc
if hashlib.sha256(raw).hexdigest() != expected_sha256.strip().lower():
raise ThemeColorError(
'Diagnostic source theme OOXML sha256 does not match its payload'
)
try:
root = ET.fromstring(raw)
except ET.ParseError as exc:
raise ThemeColorError(
f'Diagnostic source theme OOXML is malformed: {exc}'
) from exc
dml_namespace = 'http://schemas.openxmlformats.org/drawingml/2006/main'
if root.tag != f'{{{dml_namespace}}}theme':
raise ThemeColorError(
'Diagnostic source theme OOXML root must be a:theme'
)
return raw
@dataclass
class _PostflightReceipt:
"""Carry the compact export result printed after the audit is written."""
@@ -909,6 +1067,17 @@ Recorded narration:
'and support normal export capabilities.'
),
)
parser.add_argument(
'--roundtrip',
action='store_true',
help=(
'Diagnostic only: rebuild layered slide_*.svg against the validated '
'source_template.pptx/native_structure.json emitted by '
'pptx_to_svg.py --roundtrip, including unchanged validated source '
'chart packages. Requires -s svg and does not alter normal '
'flat/structured release export.'
),
)
text_flow_group = parser.add_mutually_exclusive_group()
text_flow_group.add_argument(
@@ -1128,6 +1297,7 @@ Recorded narration:
raw_argv = list(argv) if argv is not None else sys.argv[1:]
legacy_native_objects = '--native-objects' in raw_argv
args = parser.parse_args(raw_argv)
diagnostic_source = args.source not in {None, 'output'}
if legacy_native_objects:
print(
'Warning: --native-objects is deprecated; use '
@@ -1153,6 +1323,23 @@ Recorded narration:
)
return 1
if args.roundtrip:
conflicts: list[str] = []
if args.quick_generate:
conflicts.append('--quick-generate')
if args.source != 'svg':
conflicts.append('-s/--source must be svg')
if args.pptx_structure is not None:
conflicts.append('--pptx-structure must be omitted')
if conflicts:
print(
"Error: --roundtrip cannot be used because "
+ ", ".join(conflicts),
file=sys.stderr,
)
return 1
args.pptx_structure = 'preserve'
if args.quick_generate:
conflicts: list[str] = []
if args.source not in {None, 'output'}:
@@ -1169,6 +1356,15 @@ Recorded narration:
if not args.with_notes:
args.no_notes = True
args.pptx_structure = 'flat'
elif diagnostic_source and not args.roundtrip:
if args.pptx_structure not in {None, 'flat'}:
print(
"Error: a non-output diagnostic --source supports only "
"--pptx-structure flat",
file=sys.stderr,
)
return 1
args.pptx_structure = 'flat'
project_path = Path(args.project_path)
if not project_path.exists():
@@ -1179,7 +1375,7 @@ Recorded narration:
native_structure_contract = None
pptx_structure = args.pptx_structure
lock_path = project_path / 'spec_lock.md'
if not args.quick_generate and not lock_path.is_file():
if not args.quick_generate and not diagnostic_source and not lock_path.is_file():
print(
"Error: spec_lock.md is required for release SVG export",
file=sys.stderr,
@@ -1187,11 +1383,11 @@ Recorded narration:
return 1
declared_structure_mode = (
None
if args.quick_generate
if args.quick_generate or diagnostic_source
else _declared_pptx_structure_mode(project_path)
)
primary_language = None
if not args.quick_generate:
if not args.quick_generate and not diagnostic_source:
try:
primary_language = _declared_primary_language(project_path)
except LanguageTagError as exc:
@@ -1204,7 +1400,10 @@ Recorded narration:
"language detection.",
file=sys.stderr,
)
if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
if (
pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES
and not (args.roundtrip and pptx_structure == 'preserve')
):
_print_structure_contract_error(pptx_structure)
return 1
if (
@@ -1230,6 +1429,20 @@ Recorded narration:
)
return 1
if args.roundtrip:
structure_lock = PptxStructureLock(
mode='preserve',
source_template=project_path / SOURCE_TEMPLATE_NAME,
native_structure=project_path / NATIVE_STRUCTURE_NAME,
)
try:
native_structure_contract = load_native_structure_contract(
structure_lock,
)
except TemplateStructureError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if (
pptx_structure in _RELEASE_PPTX_STRUCTURE_MODES
and declared_structure_mode == pptx_structure
@@ -1250,7 +1463,13 @@ Recorded narration:
theme_font_spec = None
master_text_style_spec = None
theme_color_spec = None
if pptx_structure in {'flat', 'structured'} and not args.quick_generate:
source_theme_xml = None
source_embedded_fonts = None
if (
pptx_structure in {'flat', 'structured'}
and not args.quick_generate
and not diagnostic_source
):
try:
theme_font_spec = load_theme_font_spec(project_path)
master_text_style_spec = load_master_text_style_spec(project_path)
@@ -1273,6 +1492,17 @@ Recorded narration:
file=sys.stderr,
)
return 1
elif pptx_structure in {'flat', 'preserve'} and diagnostic_source:
try:
(
theme_color_spec,
theme_font_spec,
source_theme_xml,
source_embedded_fonts,
) = _load_diagnostic_import_source(project_path)
except (EmbeddedFontError, 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
@@ -1292,10 +1522,10 @@ Recorded narration:
canvas_format = args.format
expected_viewbox = (
None
if args.quick_generate
if args.quick_generate or diagnostic_source
else _declared_canvas_viewbox(project_path)
)
if expected_viewbox is None and not args.quick_generate:
if expected_viewbox is None and not args.quick_generate and not diagnostic_source:
print(
"Error: spec_lock.md must contain canvas.viewBox for release export",
file=sys.stderr,
@@ -1310,6 +1540,12 @@ Recorded narration:
native_source,
allow_fallback=args.source is None and not args.quick_generate,
)
if args.roundtrip:
native_files = [
path
for path in native_files
if re.fullmatch(r'slide_\d+\.svg', path.name)
]
ref_files = native_files
if not native_files:
if args.quick_generate:
@@ -1952,6 +2188,8 @@ Recorded narration:
theme_font_spec=theme_font_spec,
master_text_style_spec=master_text_style_spec,
theme_color_spec=theme_color_spec,
source_theme_xml=source_theme_xml,
source_embedded_fonts=source_embedded_fonts,
primary_language=primary_language,
)
@@ -119,9 +119,14 @@ final confirmation.
🚧 **GATE**: Step 1 complete; source content is ready (Markdown file, user-provided text, or requirements described in conversation are all valid).
```bash
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name>
```
**Hard rule — truthful canvas token**: append
`--format <registered_format>` only when an explicit user/source fact already
establishes an exact registered canvas before initialization. Otherwise omit
the flag; Stage 1 confirms the canvas and `spec_lock.md` records its viewBox.
Project initialization creates `<project_path>/validation/workflow.log` and
records the initialization milestone. After the project exists, run each
project-scoped Python tool normally. The shared CLI bootstrap automatically
@@ -60,20 +60,20 @@ two fidelity profiles never compose.
## 3. Create the Project Workspace
Match the canvas to the source so 1:1 pages and paste-back align. Determine the source aspect first — before the project exists, run `beautify_identity.py <source.pptx>` to **stdout** and read `canvas.aspect` (the formal standard intake bundle is written in Step 4, after `init`) — then `init` with the matching format:
Match the canvas to the source so 1:1 pages and paste-back align. Determine the source aspect first — before the project exists, run `beautify_identity.py <source.pptx>` to **stdout** and read `canvas.aspect` (the formal standard intake bundle is written in Step 4, after `init`) — then select the source-faithful canvas, passing `--format` only for an exact registered match:
| Source aspect | Format |
| Source aspect | Canvas |
|---|---|
| ≈1.778 (16:9) | `ppt169` |
| ≈1.333 (4:3) | `ppt43` |
| other | nearest format in [`canvas-formats.md`](../../references/canvas-formats.md); record the source pixel size in the spec |
| other | the exact source `width_px`x`height_px`; omit `--format` |
```bash
# Default runtime:
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> [--format <format>]
# Quick runtime instead:
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format> --quick-generate
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> [--format <format>] --quick-generate
# Both runtimes then import once:
python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source.pptx>
@@ -152,20 +152,26 @@ ordinary Quick and other compatible profiles.
its kind/canvas, and run that stage's read-only schema/structured preflight.
Do not scan the library, fuzzy-match a name, or open a selector. Explicit user
canvas wins; otherwise use the selected structure owner (Layout before Deck)
canvas when present, then fall back to `ppt169`.
canvas when present. Pass that choice to `init --format` only when it exactly
matches a registered canvas; otherwise retain its viewBox for authoring.
- **Free design**: no exact root was supplied. Continue immediately with the
requested canvas or `ppt169`. A bare template name, brand mention, style
phrase, or vague request to choose a template is ordinary brief input, not a
workspace reference.
requested canvas when one exists. If no canvas is specified, decide the
viewBox during SVG authoring instead of assigning an initialization default.
A bare template name, brand mention, style phrase, or vague request to choose
a template is ordinary brief input, not a workspace reference.
Neither branch creates anything under `confirm_ui/` or executes
`confirm_ui/server.py`. Initialize the minimal workspace with:
```bash
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> \
--format <format> --quick-generate
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --quick-generate
```
**Hard rule — truthful canvas token**: append
`--format <registered_format>` only when the branch above resolved an exact
registered canvas. Otherwise omit it; the first SVG root viewBox becomes the
canvas authority. Never encode custom dimensions as a format token.
It creates `svg_output/` plus the cold
`validation/workflow.log` command/outcome audit log, and no root README. After
this command, run project-scoped Python tools directly; their shared CLI
@@ -56,10 +56,14 @@ If the content material is only a topic with no supporting facts, gather or ask
Create a dedicated project directory under `projects/`. Do not write outputs directly into `projects/` root. Reuse the standard project manager so source import rules stay consistent with the rest of the repository:
```bash
python3 skills/ppt-master/scripts/project_manager.py init "<project_name>" --format ppt169
python3 skills/ppt-master/scripts/project_manager.py init "<project_name>"
python3 skills/ppt-master/scripts/project_manager.py import-sources "<project_dir>" "<source.pptx>" "<material...>"
```
The source PPTX slide size remains the native canvas authority. Do not add an
initialization `--format` unless that source canvas has already been verified
as an exact registered format.
**Source import rule**: `project_manager.py import-sources` moves only sources under repository `projects/` and copies all others. `--copy` preserves a projects-local input; `--move` never widens that scope. Reuse this path.
Use this fixed layout:
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "b9938d94635fca7a4560449713b0b1ba87d77bc6",
"commit": "ee628d75dea87325735fafa7c54f5d7d7edb8774",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-08-24T16:00:00Z"
"syncedAt": "2026-08-25T16:00:00Z"
}
@@ -1,6 +1,7 @@
# [UI UX Pro Max](https://uupm.cc)
<p align="center">
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.vi.md">🇻🇳 Tiếng Việt</a> |
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.zh.md">🇨🇳 简体中文</a> |
<a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/blob/main/README.md">🇺🇸 English</a>
</p>
@@ -2,8 +2,8 @@
"sourceId": "ui-ux-pro-max",
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"commit": "13179471f97162b3297558621a76682438caf017",
"commit": "e353a508767c6d39f0e7698b084dbfc8699fffd3",
"adapter": "claude-skill",
"sourcePath": ".claude/skills/ui-ux-pro-max",
"syncedAt": "2026-08-24T16:00:00Z"
"syncedAt": "2026-08-25T16:00:00Z"
}