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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
<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)
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
|
||||
+565
-50
@@ -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
|
||||
|
||||
|
||||
|
||||
+294
-7
@@ -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(
|
||||
|
||||
+34
-9
@@ -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:
|
||||
|
||||
+63
-13
@@ -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(
|
||||
|
||||
+98
-15
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+233
-11
@@ -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>'
|
||||
)
|
||||
|
||||
|
||||
+22
-2
@@ -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,
|
||||
|
||||
+569
-17
@@ -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
|
||||
|
||||
+234
-3
@@ -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:
|
||||
|
||||
+48
-13
@@ -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
-2
@@ -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(
|
||||
|
||||
+191
-3
@@ -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:
|
||||
|
||||
+66
-16
@@ -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),
|
||||
|
||||
+4
@@ -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 = []
|
||||
|
||||
+33
@@ -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
|
||||
|
||||
+216
-5
@@ -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(
|
||||
|
||||
+84
-10
@@ -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 {
|
||||
|
||||
+111
-2
@@ -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 ""
|
||||
)
|
||||
|
||||
+4
-1
@@ -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:
|
||||
|
||||
+172
-1
@@ -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'
|
||||
|
||||
+246
-8
@@ -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
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
+12
-6
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user