From 31e4a7ce5e1765c055379508f9514896b5151968 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Wed, 26 Aug 2026 00:01:55 +0800 Subject: [PATCH] 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. --- config/external-sources.lock.json | 20 +- .../plugins/caveman/THIRD_PARTY_SOURCE.json | 4 +- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ppt-master/README.md | 10 +- .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../skills/ppt-master/scripts/README.md | 11 +- .../skills/ppt-master/scripts/docs/project.md | 27 +- .../ppt-master/scripts/docs/svg-pipeline.md | 3 + .../skills/ppt-master/scripts/error_helper.py | 7 +- .../ppt-master/scripts/extract_svg_assets.py | 154 ++++- .../scripts/generate_examples_index.py | 12 +- .../skills/ppt-master/scripts/pptx_effects.py | 86 ++- .../ppt-master/scripts/pptx_embedded_fonts.py | 368 +++++++++++ .../ppt-master/scripts/pptx_gradients.py | 117 ++++ .../skills/ppt-master/scripts/pptx_to_svg.py | 14 + .../scripts/pptx_to_svg/chart_to_svg.py | 623 ++++++++++++++++-- .../scripts/pptx_to_svg/converter.py | 301 ++++++++- .../scripts/pptx_to_svg/effect_to_svg.py | 43 +- .../scripts/pptx_to_svg/fill_to_svg.py | 76 ++- .../scripts/pptx_to_svg/ln_to_svg.py | 113 +++- .../pptx_to_svg/normalized_chart_svg.py | 246 ++++++- .../scripts/pptx_to_svg/shape_walker.py | 28 +- .../scripts/pptx_to_svg/slide_to_svg.py | 608 ++++++++++++++++- .../scripts/pptx_to_svg/tbl_to_svg.py | 6 + .../scripts/pptx_to_svg/txbody_to_svg.py | 237 ++++++- .../scripts/project_management/cli.py | 83 ++- .../ppt-master/scripts/project_manager.py | 5 +- .../ppt-master/scripts/resource_paths.py | 8 +- .../ppt-master/scripts/svg_editor/server.py | 8 +- .../scripts/svg_finalize/embed_icons.py | 51 +- .../svg_to_pptx/drawingml/converter.py | 194 +++++- .../scripts/svg_to_pptx/drawingml/elements.py | 82 ++- .../scripts/svg_to_pptx/drawingml/styles.py | 4 + .../scripts/svg_to_pptx/drawingml/utils.py | 33 + .../svg_to_pptx/native_objects/__init__.py | 299 +++++++-- .../svg_to_pptx/native_objects/chart_data.py | 94 ++- .../svg_to_pptx/native_objects/chart_xml.py | 113 +++- .../native_objects/fallback_hash.py | 5 +- .../svg_to_pptx/pptx_package/builder.py | 173 ++++- .../scripts/svg_to_pptx/pptx_package/cli.py | 254 ++++++- .../ppt-master/workflows/generate-pptx.md | 7 +- .../workflows/profiles/beautify-pptx.md | 10 +- .../workflows/profiles/quick-generate.md | 18 +- .../workflows/template-fill-pptx.md | 6 +- .../plugins/shadcn/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ui-ux-pro-max/README.md | 3 +- .../ui-ux-pro-max/THIRD_PARTY_SOURCE.json | 4 +- 48 files changed, 4260 insertions(+), 322 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_embedded_fonts.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_gradients.py diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 8b8e8973..8f80bf79 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -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" } ] } diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index 7c9fef2b..ea3fb7f1 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 4b8b3f0a..ed109cc1 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-08-24T16:01:54Z" + "syncedAt": "2026-08-25T16:01:54Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 0c9c791a..cb2c3d3c 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 0ea357b2..f319b948 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -12,8 +12,14 @@ English | [中文](./README_CN.md) +## ❤️ Sponsors + +This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors. + +> **[Want to appear here?](SPONSORING.md)** +
-This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors. +Click to collapse

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

- 🇨🇳 简体中文 | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | 🇺🇸 English

diff --git a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json index fc8592eb..f0518be8 100644 --- a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json @@ -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" }