diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index cce469d7..6946eb64 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -168,8 +168,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "cc8b722a24e9fb540e67c2d58a9ea3da534cd682", - "syncedAt": "2026-10-21T06:48:34Z" + "commit": "604ecdec8dd6a0405e1abdb49565430902373276", + "syncedAt": "2026-10-21T16:00:00Z" }, { "id": "grill-me", @@ -258,8 +258,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "72895219d44df111ee6ac81e4eac10d81a5e5cbf", - "syncedAt": "2026-10-21T06:48:34Z" + "commit": "1984f672c01923e26a23ab24063d42a5263b1561", + "syncedAt": "2026-10-21T16:00:00Z" }, { "id": "shuorenhua", diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 7697d512..be35b827 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-10-21T06:51:10Z" + "syncedAt": "2026-10-21T16:02:37Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 1ff31480..d0275332 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": "72895219d44df111ee6ac81e4eac10d81a5e5cbf", + "commit": "1984f672c01923e26a23ab24063d42a5263b1561", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-21T06:48:34Z" + "syncedAt": "2026-10-21T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 2c7df0a7..547cf684 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": "cc8b722a24e9fb540e67c2d58a9ea3da534cd682", + "commit": "604ecdec8dd6a0405e1abdb49565430902373276", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-10-21T06:48:34Z" + "syncedAt": "2026-10-21T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-formula.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-formula.md index 89bdeec1..8ad0398c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-formula.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-formula.md @@ -57,7 +57,7 @@ Formula handling is not a user-confirmed policy, image resource, manifest, or `s **Hard rule — block metadata is truth**: one direct `` child with non-empty `latex`, `display: block`, `font_size` in `(0, 400]`, a visible `color`, and `align: left|center|right`; finite `data-pptx-x/y`, positive `data-pptx-width/height`, and matching root-coordinate `data-pptx-bounds`. Export replaces the whole group with `a14:m > m:oMathPara > m:oMath`. -**Hard rule — preview is SVG, never fallback**: every preview is semantically equivalent ordinary SVG text/shapes/lines/paths — no ``, ``, visible raw LaTeX, or runtime renderer. The exporter discards the preview and emits no picture branch. +**Hard rule — preview is ordinary SVG**: every preview is semantically equivalent ordinary SVG text/shapes/lines/paths — no ``, ``, visible raw LaTeX, or runtime renderer. Export keeps it as the native-shape fallback that non-PowerPoint clients show in place of the math. --- @@ -67,4 +67,4 @@ Formula handling is not a user-confirmed policy, image resource, manifest, or `s **Hard rule — repair LaTeX upstream**: unsupported source or an invalid marker blocks the page. Rewrite within the profile without changing the planned mathematics, or return it to the content owner. Never substitute a PNG, flatten structural math into ordinary text, hand-write OMML, or leave raw LaTeX visible. -**Validation**: planned LaTeX can be compile-checked before authoring ([`svg-pipeline.md`](../scripts/docs/svg-pipeline.md) § svg_to_pptx); the early/final SVG checker validates every marker, compiles its LaTeX, and applies the shared native-height envelope to page/module text bounds; native export repeats validation and uses that envelope for the generated text frame. Output is standard editable Office Math for PowerPoint 2010+; WPS, Keynote, and LibreOffice receive no embedded fallback and are outside the rendering/editability contract. +**Validation**: planned LaTeX can be compile-checked before authoring ([`svg-pipeline.md`](../scripts/docs/svg-pipeline.md) § svg_to_pptx); the early/final SVG checker validates every marker, compiles its LaTeX, and applies the shared native-height envelope to page/module text bounds; native export repeats validation and uses that envelope for the generated text frame. Output is standard editable Office Math for PowerPoint 2010+; WPS, Keynote, and LibreOffice show the preview fallback and remain outside the rendering/editability contract. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py index 33f3142f..4411b7fd 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py @@ -16,11 +16,15 @@ Dependencies: from __future__ import annotations +import base64 +import binascii import copy import hashlib import json +import math import os import re +import sys import tempfile from collections import Counter from dataclasses import dataclass @@ -52,12 +56,13 @@ from svg_authoring_view import ( SOURCE_PROXY_ATTRIBUTE, SOURCE_PROXY_KIND, _element_chain, + _format_transform_number, _materialize_adopted_context, project_svg_batch, semantic_subtree_sha256, ) from svg_compatibility import normalize_single_child_group_filters -from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS +from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS, parse_transform_matrix SVG_NS = "http://www.w3.org/2000/svg" @@ -1538,25 +1543,109 @@ def _restore_preserved_effect_metadata( item.set(name, value) +def _text_body_frame(element: ET.Element) -> tuple[float, ...] | None: + """Read a finite carrier frame for geometry and relative text comparisons.""" + try: + frame = tuple(float(value) for value in re.split( + r"[\s,]+", (element.get("data-pptx-frame") or "").strip(), + )) + except ValueError: + return None + if len(frame) != 4 or not all(math.isfinite(value) for value in frame): + return None + return frame + + +def _text_body_geometry(occurrence: RefOccurrence) -> tuple | None: + """Compare carrier size and effective axes, allowing only position changes.""" + frame = _text_body_frame(occurrence.element) + if frame is None: + return None + contextual = _contextual_authoring_element(occurrence.element, occurrence.root) + # Match the precision used when the shared context helper composes ancestry. + axes = tuple(f"{value:.12g}" for value in parse_transform_matrix(contextual.get("transform", ""))[:4]) + return frame[2:], axes + + +def _text_body_state(occurrence: RefOccurrence, *, frame_relative: bool = False) -> list[str] | None: + """Fingerprint text with carrier translations removed, retaining local edits.""" + carrier = copy.deepcopy(occurrence.element) + root = carrier + context = [carrier] + for ancestor in reversed(_element_chain(occurrence.root, occurrence.element)[:-1]): + wrapper = ET.Element(ancestor.tag, ancestor.attrib) + wrapper.append(root) + root = wrapper + context.append(wrapper) + # Normalize only the carrier and its ancestors. Text-local coordinates and + # transforms (including those on wrappers inside the carrier) still count. + for node in context: + axes = parse_transform_matrix(node.get("transform", ""))[:4] + node.attrib.pop("transform", None) + if axes != (1.0, 0.0, 0.0, 1.0): + node.set("transform", "matrix(" + " ".join( + f"{value:.12g}" for value in (*axes, 0.0, 0.0) + ) + ")") + if frame_relative: + frame = _text_body_frame(carrier) + if frame is None: + return None + for text in carrier.iter(f"{{{SVG_NS}}}text"): + for node in text.iter(): + if node.tag not in {f"{{{SVG_NS}}}text", f"{{{SVG_NS}}}tspan"}: + continue + for axis, origin in zip(("x", "y"), frame[:2]): + raw = node.get(axis) + if raw is None: + continue + try: + values = [float(value) - origin for value in re.split(r"[\s,]+", raw.strip())] + except ValueError: + return None + if not all(math.isfinite(value) for value in values): + return None + node.set(axis, " ".join(_format_transform_number(value) for value in values)) + return [ + semantic_subtree_sha256(_contextual_authoring_element(text, root)) + for text in carrier.iter(f"{{{SVG_NS}}}text") + ] + + +def _text_body_formula_loss_warning(metadata: ET.Element, target: ET.Element, reason: str) -> str | None: + """Diagnose lost native math without taking over payload validation.""" + try: + body = ET.fromstring(base64.b64decode((metadata.text or "").strip(), validate=True)) + except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError): + # An unreadable payload has an unknown formula count. Keep validation + # with the converter when the original restoration path consumes it. + return None + math_count = sum(1 for _ in body.iter("{http://schemas.microsoft.com/office/drawing/2010/main}m")) + marker_count = sum( + 1 for node in target.iter() + if node.get("data-pptx-inline-formula") or node.get("data-pptx-replace-with") == "formula" + ) + if math_count > marker_count: + return f"native formulas will become plain text; reason={reason}; source txBody cannot be restored" + return None + + def _restore_unchanged_text_body( target: ET.Element, source: ET.Element, current: RefOccurrence, baseline: RefOccurrence, -) -> None: - """Recover native text only while its projected text and context agree.""" +) -> str | None: + """Recover unchanged native text after translation, or diagnose lost math.""" metadata = next((child for child in source if child.get("data-pptx-part") == "txbody"), None) - if metadata is None or current.element.get("data-pptx-frame") != baseline.element.get("data-pptx-frame"): - return - - def text_state(occurrence: RefOccurrence) -> list[str]: - return [ - semantic_subtree_sha256(_contextual_authoring_element(text, occurrence.root)) - for text in occurrence.element.iter(f"{{{SVG_NS}}}text") - ] - - if text_state(current) != text_state(baseline): + if metadata is None: return + geometry = _text_body_geometry(current) + if geometry is None or geometry != _text_body_geometry(baseline): + return _text_body_formula_loss_warning(metadata, target, "size/transform-changed") + if _text_body_state(current) != _text_body_state(baseline): + relative_state = _text_body_state(current, frame_relative=True) + if relative_state is None or relative_state != _text_body_state(baseline, frame_relative=True): + return _text_body_formula_loss_warning(metadata, target, "text-edited") restored = copy.deepcopy(metadata) # Projection compacts text runs and paragraphs. Validate that projection # above, then let the existing decoder validate the recovered payload. @@ -1798,6 +1887,8 @@ def _materialize_document( "picture relationship" ) + warnings: list[dict[str, Any]] = [] + def restore_nodes( element: ET.Element, source_dir: Path | None = None, @@ -1872,9 +1963,25 @@ def _materialize_document( if clone.get(name) is None and source.get(name) is not None: clone.set(name, str(source.get(name))) _restore_preserved_effect_metadata(clone, source) - _restore_unchanged_text_body( + warning = _restore_unchanged_text_body( clone, source, current_occurrences[source_ref][0], baseline_occurrences[source_ref][0], ) + if warning: + object_id = element.get("id") or source_ref + output_index = page.output_index if page else document.source_slide + warnings.append({ + "code": "native-formula-to-text", + "output_page": output_index, + "source_slide": document.source_slide, + "object_id": object_id, + "source_ref": source_ref, + "message": warning, + }) + print( + f"[ROUNDTRIP][WARNING] page={output_index} ({document.name}) " + f"object={object_id} source_ref={source_ref}: {warning}", + file=sys.stderr, + ) if ( _local_name(clone.tag) == "g" and source_ref is None and set(clone.attrib) <= {*INHERITABLE_ATTRS, "style", "opacity", "transform"} @@ -2022,6 +2129,7 @@ def _materialize_document( "authored_unreferenced": authored_unreferenced, "expanded_vector_uses": expanded_vector_uses, "defs_changed": defs_changed, + "warnings": warnings, } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md index e175611c..ff09ec26 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md @@ -376,9 +376,10 @@ replacement timing tree. ### Native formula reverse import -The importer reconstructs formulas only from the closed OMML vocabulary owned -by the native formula compiler. One formula-only `a14:m > m:oMathPara` text -shape becomes a bounded `` with canonical +The importer reconstructs formulas from the native compiler's closed OMML +vocabulary after the import-only color normalization below. One formula-only +`a14:m > m:oMathPara` text shape becomes a bounded +`` with canonical LaTeX JSON and a visible linear SVG preview when its carrier is an ungrouped, unstyled, unrotated rectangular formula shape. Carrier styling, effects, hyperlinks, or placeholder ownership force diagnosed fallback rather than @@ -388,8 +389,43 @@ Supported `a14:m > m:oMath` zones inside an ordinary paragraph become leaf their surrounding text runs. The generated markers pass the same native-object and inline-formula validators used by SVG-to-PPTX export. +**Color normalization** applies only to `a:solidFill` directly inside +`m:r/a:rPr` or `m:ctrlPr/a:rPr`. Each fill must contain exactly one correctly +namespaced `a:srgbClr` or `a:schemeClr`. The shared color resolver uses the +caller's effective palette and color map, never a fixed `theme1.xml`, and +strictly validates color attributes and the supported `alpha`, `alphaMod`, +and `alphaOff` modifiers even during tolerant import. The importer validates +resource limits before normalization and changes a copy of the OMML only. +Resolved RGB replaces the color node before the unchanged compiler validator +runs. The resolved marker color is the formula's base: block JSON `color` or +inline run `fill`. Serialization omits run and control colors already supplied +by that base or an enclosing local color scope. Only changes to the effective +color become LaTeX `\textcolor` scopes; nested scopes can explicitly restore +the base color when needed. Theme tokens become current RGB values and lose +their original theme-token linkage. Re-export applies the exporter's existing anchor mapping +to marker defaults; local LaTeX color scopes remain explicit RGB overrides. + +**Transparency** must remain representable by the destination marker and its +existing exporter. Inline formulas retain one uniform effective alpha across +their runs and controls through the marker's `fill` / `fill-opacity` style; +surrounding runs retain their own opacity. This includes the uniform alpha +emitted by PPT Master's inline exporter with local color overrides. Alpha +must fit DrawingML's 1/100000 precision. Mixed alpha, including an opaque run +or control among translucent ones, is not reconstructed. Block markers keep +RGB in JSON `color`; their existing exporter emits opaque RGB only, so a +translucent block formula also takes the diagnosed opaque-`txBody` path. +Alpha is never discarded to make a formula pass validation. + +Canonical read-back uses explicit braces for script bases and argument bodies, +`\left` / `\right` for delimiter nodes, Unicode glyphs for symbols, `\mathrm` +for upright math runs, and `\mathop` for function nodes. Insignificant source +whitespace is not recovered. Recompilation may split or join adjacent runs +with identical effective styling without changing their text or appearance. + This is normalized semantic read-back, not recovery of the author's original -LaTeX spelling and not a general Office Math converter. Every OMML root must +LaTeX spelling and not a general Office Math converter. Multiple color +children, incorrect namespaces, unresolved theme colors, unsupported modifiers, +and unknown OMML remain outside the reversible subset. Every normalized root must pass the compiler's namespace, element, attribute, structure, size, and depth gates, and the reconstructed LaTeX must compile again under the same profile. If any formula in one text body falls outside that boundary, tolerant import diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md index 6800072d..e9db0cb3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md @@ -212,7 +212,7 @@ failure rather than a silent downgrade. ### 3.2 Deterministic Morph Identity -The generated route may add an explicit `slides..morph` block to +Generate and Edit Native PPTX may add an explicit `slides..morph` block to bind direct-root SVG groups across adjacent slides. The sidecar stable key is lowered to the same top-level `p:cNvPr@name="!!"` on both final Slide-local objects. This does not create an Animation Pane row and does not @@ -231,8 +231,14 @@ roster expectations are then refreshed. Package read-back requires: undeclared shared `!!` name on a Morph edge. Morph without an explicit pair block retains PowerPoint's automatic matching -behavior. Explicit pairing is generated-route authoring; source-preserving -round-trip export keeps existing object names and transition XML. +behavior. Round-trip export preserves source names unless an explicit pair +assigns a new identity. An endpoint on an unchanged page resolves through its +imported Slide-local shape id and counts as `patched`, including planned copies. +When only identity changes, export replaces only the `cNvPr@name` attribute +value; every other slide XML byte and its relationships stay unchanged. Notes +and requested motion remain separate overlays. An endpoint without a unique +native identity fails with its page/object and instructions to edit that page +as well or pair two edited pages. --- 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 6dc41369..7e9ab706 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 @@ -93,10 +93,49 @@ vector edit units from `icons/imported/`, and retains edits/deletions/new content without rewriting the bundle. Unchanged slides and resources pass through byte-for-byte. Object comparison includes effective ancestor transforms, opacity, inherited styles, and enclosing links; changing a proxy's context also -fails. When an edited shape's text and text context still match the baseline, -the materializer restores its relationship-free source `txBody` through the -existing fingerprint/relationship checks, preserving fields, numbering, -autofit, language, and text effects. Edited text follows normal conversion. +fails. When an edited shape's text and effective text style still match the +baseline, unchanged geometry or a pure translation retains its relationship-free +source `txBody` through the existing fingerprint/relationship checks. This +preserves the complete text body, including native Office Math, fields, +numbering, autofit, language, and text effects. Translation may change frame +x/y without changing width/height, or move the object or its ancestors with +unchanged effective rotation, flip, and scale. Moving frame, geometry, and +text coordinates together is also supported: when absolute text positions +differ, the materializer compares every numeric `text`/`tspan` x/y coordinate +relative to the frame origin. Relative text positions, text-local transforms, +and styles remain part of the fingerprint; moving only one line or editing +text/style never restores stale text over the edit. + +Width/height or non-translation transform changes continue to rebuild from +SVG. Whenever a source `txBody` is not restored and its `a14:m` count exceeds +the remaining formula marker count, including after text or style edits, the +materializer emits a non-blocking `[ROUNDTRIP][WARNING]` to stderr with the +output page, SVG filename, object id, and source ref, stating that native +formulas will become plain text. Its reason is `size/transform-changed` or +`text-edited`. An unreadable payload has an unknown formula count, produces +no formula-loss warning, and remains subject to the converter's existing +payload validation when consumed. The same `native-formula-to-text` diagnostic +is recorded under each document's `warnings` in the postflight report's +`source.authoring_roundtrip`; ordinary text and formulas rebuilt from native +markers do not receive this warning. This does not expand the formula reverse +import boundary, certify resized text layout/autofit, or change the existing +POSTFLIGHT quality-gate status. + +Native charts rebuilt from edited JSON retain the marker's scoped source shape +id on the chart `graphicFrame`, just like restored source chart packages. +For a rebuilt imported frame, `data-pptx-shape-name` takes precedence over the +payload name. Source animation targets and forced-Morph `!!` names survive. +Synthesized companion text receives fresh ids and follows the frame as sibling +shapes without a new wrapper or inherited source name. The frame retains its +original hierarchy level and `graphicFrame` type. Markers without a source id +keep generated chart grouping, id allocation, and naming unchanged. Rebuilt +chart relationships point to the newly generated chart/workbook parts; +restored packages keep their source parts. +New chart part families reserve unused names across the complete export +(including source/template parts, workbooks, relationships, and ChartEx +styles/colors); conflicting writes or content types fail with the part path +and both owners, while identical immutable source dependencies may be reused. + A page edit rebuilds that output page; a changed materialized or derived resource rebuilds every output page that references it. Changed materialized bytes must still match the source package part's extension @@ -820,9 +859,14 @@ python3 -c "import sys; sys.path.insert(0, 'skills/ppt-master/scripts'); from sv Matrices, multiline derivations, and other high-structure expressions remain blocks. Formula replacement is always active, independent of `--native-charts-and-tables`: export replaces only the registered SVG preview -and writes editable PowerPoint 2010+ Office Math. It emits no formula PNG, media -relationship, or compatibility fallback, and makes no rendering/editability -promise for Keynote, WPS, LibreOffice, or another non-PowerPoint client. +and writes editable PowerPoint 2010+ Office Math in `mc:Choice Requires="a14"`. +The same `mc:AlternateContent` carries an `mc:Fallback` built from the SVG +preview as ordinary native shapes (block) or the same text box with the preview +run in place of the math (inline), with fresh shape ids; no formula PNG or media +relationship is created. A round-trip shape that restores source Office Math +from its unchanged txBody gets the same branch from its visible SVG text. Clients without a14 math, such as LibreOffice, show +that fallback; no rendering/editability promise is made for Keynote, WPS, +LibreOffice, or another non-PowerPoint client. ```bash python3 scripts/svg_to_pptx.py @@ -1033,9 +1077,16 @@ Behind [`native-formula.md`](../../references/native-formula.md) §3. The compil **Normalization**: `\dfrac` / `\tfrac`, `\dbinom` / `\tbinom`, and continued-fraction alignment normalize to the corresponding OMML structure; explicit big-delimiter grades become auto-sizing delimiters; `\mathscr` → `\mathcal`; `smallmatrix` → `matrix`; array columns become centered; style/size commands and equation tags are accepted but not stored. Color is stored in generated formula runs and structural control properties; `\boldsymbol` / `\bm` applies bold-italic to structural control glyphs. +The compiler validates its output against the closed OMML grammar before +marker-level defaults are applied. +The block and inline builders then merge marker-level run/control styles; +the inline exporter can emit theme colors and alpha in that styled OMML. +The import-only normalization and transparency boundary are owned by +[`conversion.md`, Native formula reverse import](conversion.md#native-formula-reverse-import). + **Fail-closed**: unknown commands or environments, Microsoft's explicitly unsupported commands, unsupported mhchem arrows, unescaped `%` comments, invalid macros, and resource-limit overflow block conversion — stricter than Microsoft 365's literal-text passthrough and macro-limit behavior. -**Compatibility**: the package uses standard editable Office Math and keeps the PowerPoint 2010+ target; the executable profile is pinned to the documentation versions above. Repository verification covers compilation, OMML structure, and PPTX packaging, not a Microsoft 365 UI rendering/editability certification. Earlier PowerPoint versions are not the source-profile baseline; WPS, Keynote, LibreOffice, and other clients receive no embedded fallback. Reverse import is described in [`conversion.md`](conversion.md#native-formula-reverse-import). +**Compatibility**: the package uses standard editable Office Math and keeps the PowerPoint 2010+ target; the executable profile is pinned to the documentation versions above. Repository verification covers compilation, OMML structure, and PPTX packaging, not a Microsoft 365 UI rendering/editability certification. Earlier PowerPoint versions are not the source-profile baseline; WPS, Keynote, LibreOffice, and other clients receive only the native-shape preview fallback. Reverse import is described in [`conversion.md`](conversion.md#native-formula-reverse-import). ## `visual_review.py` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/ooxml.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/ooxml.py index 60790f25..d8f60b23 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/ooxml.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/ooxml.py @@ -254,9 +254,17 @@ def _container_geometry( def _text_containers(slide_root: ET.Element) -> list[ET.Element]: + # mc:Fallback repeats its Choice for other clients; it is not slide content. + fallback_nodes = { + node + for fallback in slide_root.iter(f"{{{MC_NS}}}Fallback") + for node in fallback.iter() + } containers: list[ET.Element] = [] for tag in ("p:sp", "p:graphicFrame"): for element in slide_root.findall(f".//{tag}", NS): + if element in fallback_nodes: + continue if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS): containers.append(element) return containers diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/package.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/package.py index ef3d546e..14c95ebb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/package.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_ooxml/package.py @@ -1,14 +1,14 @@ -"""Write-side OOXML package plumbing for source-preserving slide cloning. +"""Write-side OOXML package plumbing for slide cloning and native objects. Content-type override insertion, relationship-element construction / lookup, and -part-number allocation used when cloning slides into a new package. +part allocation, and collision checks for a complete export package. """ from __future__ import annotations import posixpath import re -from collections.abc import Callable +from collections.abc import Callable, Iterable from pathlib import Path from xml.etree import ElementTree as ET @@ -33,6 +33,79 @@ _EXPLICIT_SLIDE_REL_KINDS = frozenset({ }) +class PackagePartRegistry: + """Reserve package names and reject conflicting native-object writes.""" + + def __init__( + self, + package_root: Path | None = None, + content_types: dict[str, str] | None = None, + ) -> None: + self.package_root = package_root + self.parts: dict[str, bytes] = {} + self.content_types = dict(content_types or {}) + self.owners: dict[str, str] = {} + self.source_names: set[str] = set() + if package_root is not None: + self.reserve_source( + (path.relative_to(package_root).as_posix() + for path in package_root.rglob("*") if path.is_file()), + "template/source package", + ) + self.reserve_source(self.content_types, "template/source content types") + + def reserve_source(self, names: Iterable[str], owner: str) -> None: + """Reserve immutable source names before visiting any new objects.""" + for name in names: + if name not in self.owners: + self.owners[name] = owner + self.source_names.add(name) + + def next_index( + self, patterns: tuple[str, ...], start: int, occupied: Iterable[str] = (), + ) -> int: + """Keep the preferred number unless any member of the part family exists.""" + used = self.owners.keys() | set(occupied) + while any(pattern.format(start) in used for pattern in patterns): + start += 1 + return start + + def register( + self, + parts: dict[str, bytes], + content_types: dict[str, str], + owner: str, + *, + source: bool = False, + ) -> None: + """Register an entire object atomically; identical source parts may be reused.""" + for name in parts.keys() | content_types.keys(): + previous_owner = self.owners.get(name) + existing = self.parts.get(name) + if existing is None and self.package_root is not None: + path = self.package_root / name + if path.is_file(): + existing = path.read_bytes() + previous_type = self.content_types.get(name) + content_type = content_types.get(name) + collision = previous_owner is not None and ( + not source or name not in self.source_names + or (existing is not None and existing != parts.get(name)) + ) + if collision or (previous_type and content_type and previous_type != content_type): + raise RuntimeError( + f"OOXML part collision: {name!r} between {previous_owner!r} and {owner!r}; " + "use a distinct part name for each new object" + ) + for name in parts.keys() | content_types.keys(): + if name not in self.parts: + self.owners[name] = owner + if source: + self.source_names.add(name) + self.parts.update(parts) + self.content_types.update(content_types) + + def unreferenced_slide_relationships( slide: ET.Element, relationships: ET.Element, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/formula_import.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/formula_import.py index 1e3ce79c..935907fe 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/formula_import.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/formula_import.py @@ -1,9 +1,10 @@ """Import PPT Master-owned Office Math into canonical SVG formula markers. -The reverse contract is deliberately narrow: accept only the closed OMML -vocabulary already validated by the native formula compiler, serialize that -structure to compiler-accepted LaTeX, and build a plain-text SVG preview. +The reverse contract is deliberately narrow: normalize supported DrawingML +colors, validate the compiler's closed OMML vocabulary, serialize that structure +to compiler-accepted LaTeX, and build a plain-text SVG preview. Unknown third-party OMML remains outside this module's reconstruction claim. +See scripts/docs/conversion.md, Native formula reverse import, for the boundary. """ from __future__ import annotations @@ -22,6 +23,10 @@ from svg_to_pptx.native_objects.formula_profile import ( ACCENT_COMMANDS, NARY_COMMANDS, ) +from svg_to_pptx.native_objects.formula_omml import validate_omml_resource_limits + +from .color_resolver import ColorPalette, resolve_color +from .emu_units import format_ooxml_alpha MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math" @@ -89,9 +94,15 @@ class FormulaImport: color: str align: str language: str + opacity: float -def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport: +def import_formula( + math_zone: ET.Element, + *, + display: bool, + palette: ColorPalette | None = None, +) -> FormulaImport: """Validate and reconstruct one ``a14:m`` formula zone.""" if math_zone.tag != _A14_M: raise FormulaImportError("formula carrier must be one a14:m element") @@ -107,10 +118,17 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport: ) try: - canonical = validate_omml_fragment( + bounded = validate_omml_resource_limits( ET.tostring(root, encoding="unicode", short_empty_elements=True) ) + normalized_root = ET.fromstring(bounded) + opacity = _normalize_colors(normalized_root, palette) + if display and opacity != 1.0: + raise FormulaImportError("block formula metadata color cannot retain transparency") + canonical = validate_omml_fragment(ET.tostring(normalized_root, encoding="unicode")) validated_root = ET.fromstring(canonical) + font_size_px, color, language = _dominant_run_style(validated_root) + _omit_inherited_colors(validated_root, color[1:]) latex = _serialize_root(validated_root) if display: compile_latex_to_omml(latex) @@ -122,7 +140,6 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport: preview = _preview_root(validated_root).strip() if not latex.strip() or not preview: raise FormulaImportError("formula reconstruction produced empty content") - font_size_px, color, language = _dominant_run_style(validated_root) return FormulaImport( latex=latex, preview=preview, @@ -130,9 +147,47 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport: color=color, align=_formula_alignment(validated_root), language=language, + opacity=opacity, ) +def _normalize_colors(root: ET.Element, palette: ColorPalette | None) -> float: + """Normalize only run/control fills on a copy, retaining one uniform alpha.""" + opacities: set[float] = set() + for owner in root.iter(): + if owner.tag not in {f"{_M}r", f"{_M}ctrlPr"}: + continue + fills = owner.findall(f"{_A}rPr/{_A}solidFill") + if not fills: + opacities.add(1.0) + for fill in fills: + colors = list(fill) + if ( + fill.attrib or (fill.text or "").strip() + or len(colors) != 1 + or colors[0].tag not in {f"{_A}srgbClr", f"{_A}schemeClr"} + or (colors[0].tail or "").strip() + ): + raise FormulaImportError("formula solidFill requires one a:srgbClr or a:schemeClr") + color = colors[0] + if any(child.tag not in {f"{_A}alpha", f"{_A}alphaMod", f"{_A}alphaOff"} for child in color): + raise FormulaImportError("unsupported formula color modifier") + try: + rgb, alpha = resolve_color(color, palette, strict=True) + except ValueError as exc: + raise FormulaImportError(str(exc)) from exc + if rgb is None: + raise FormulaImportError("formula color cannot be resolved in the current palette") + if alpha != float(format_ooxml_alpha(alpha)): + raise FormulaImportError("formula alpha cannot be retained at DrawingML precision") + opacities.add(alpha) + fill.remove(color) + ET.SubElement(fill, f"{_A}srgbClr", {"val": rgb[1:]}) + if len(opacities) > 1: + raise FormulaImportError("mixed formula transparency cannot be represented by one marker") + return next(iter(opacities), 1.0) + + def opaque_formula_preview(math_zone: ET.Element) -> str: """Return readable source text without claiming native reconstruction.""" text = "".join( @@ -142,6 +197,23 @@ def opaque_formula_preview(math_zone: ET.Element) -> str: return text or "[unsupported formula]" +def _omit_inherited_colors(element: ET.Element, inherited_color: str) -> None: + """Keep only color changes in the validated serialization copy.""" + if element.tag == f"{_M}r": + properties = element.find(f"{_A}rPr") + else: + name = _local_name(element) + properties = element.find(f"{_M}{name}Pr/{_M}ctrlPr/{_A}rPr") + color = _drawing_color(properties) + if color == inherited_color: + properties.remove(properties.find(f"{_A}solidFill")) + # A retained structural scope supplies its color to the whole subexpression. + # A descendant may still need an explicit scope to restore the marker base. + for child in element: + if not _local_name(child).endswith(_PROPERTY_SUFFIX): + _omit_inherited_colors(child, color or inherited_color) + + def _serialize_root(root: ET.Element) -> str: if root.tag == f"{_M}oMathPara": math = root.find(f"{_M}oMath") @@ -172,6 +244,15 @@ def _serialize_argument(owner: ET.Element, name: str, *, alignment: bool = False def _serialize_node(element: ET.Element, *, alignment: bool = False) -> str: + source = _serialize_node_content(element, alignment=alignment) + # A scoped color must cover structural glyphs as well as selectable runs. + name = _local_name(element) + control = element.find(f"{_M}{name}Pr/{_M}ctrlPr/{_A}rPr") + color = _drawing_color(control) + return f"\\textcolor{{#{color}}}{{{source}}}" if color is not None else source + + +def _serialize_node_content(element: ET.Element, *, alignment: bool = False) -> str: name = _local_name(element) if name == "r": return _serialize_run(element, alignment=alignment) 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 d97411d9..a8531bc7 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 @@ -604,7 +604,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> ) if carrier_error is not None: raise FormulaImportError(carrier_error) - imported_formula = import_formula(block_formula, display=True) + imported_formula = import_formula(block_formula, display=True, palette=ctx.palette) except FormulaImportError as exc: _diagnose_formula_fallback(ctx, exc) block_formula_failed = True @@ -1036,18 +1036,18 @@ def _prepare_inline_formula_resolver( elif not force_opaque: for zone in zones: try: - imported[id(zone)] = import_formula(zone, display=False) + imported[id(zone)] = import_formula(zone, display=False, palette=ctx.palette) except FormulaImportError as exc: failed = True _diagnose_formula_fallback(ctx, exc) - def _resolve(zone: ET.Element) -> tuple[str | None, str]: + def _resolve(zone: ET.Element) -> tuple[FormulaImport | None, str]: if failed: return None, opaque_formula_preview(zone) item = imported.get(id(zone)) if item is None: return None, opaque_formula_preview(zone) - return item.latex, item.preview + return item, item.preview return _resolve 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 734eb9a7..1aead190 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 @@ -37,6 +37,7 @@ from .emu_units import ( hundredths_pt_to_px, ooxml_bool, ) from .fill_to_svg import resolve_fill +from .formula_import import FormulaImport # --------------------------------------------------------------------------- @@ -75,7 +76,7 @@ class TextRun: HyperlinkResolver = Callable[[str, str], str | None] -InlineFormulaResolver = Callable[[ET.Element], tuple[str | None, str]] +InlineFormulaResolver = Callable[[ET.Element], tuple[FormulaImport | None, str]] TextDiagnosticSink = Callable[[str, str, str], None] @@ -782,14 +783,17 @@ def _parse_paragraph( == "{http://schemas.microsoft.com/office/drawing/2010/main}m" and inline_formula_resolver is not None ): - latex, preview = inline_formula_resolver(child) + formula, preview = inline_formula_resolver(child) if preview: formula_rpr = next( child.iter(f"{{{NS['a']}}}rPr"), None, ) run = resolved_run(preview, formula_rpr) - run.formula_latex = latex + if formula is not None: + run.formula_latex = formula.latex + run.fill = formula.color + run.fill_opacity = formula.opacity para.runs.append(run) return para @@ -1700,14 +1704,19 @@ def _emit_paragraph( else: spans.append(_run_tspan_markup(run)) - base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor) + # Formula paragraphs carry alpha per run, so a translucent first run must + # not make opaque formulas or surrounding text inherit that alpha. + base_attrs = _text_base_attrs( + first_run, anchor_x, first_baseline, text_anchor, + inherit_opacity=not any(run.formula_latex is not None for line in visible_lines for run in line), + ) if metrics_baseline: base_attrs += ' data-pptx-text-baseline="metrics-v1"' return f"{''.join(spans)}" def _text_base_attrs(run: TextRun | None, x: float, y: float, - text_anchor: str) -> str: + text_anchor: str, *, inherit_opacity: bool = True) -> str: parts = [ f'x="{fmt_num(x)}"', f'y="{fmt_num(y, 8)}"', @@ -1719,7 +1728,7 @@ def _text_base_attrs(run: TextRun | None, x: float, y: float, parts.append(f'font-family="{run.font_family}"') parts.append(f'font-size="{fmt_num(run.font_size_px)}"') parts.append(f'fill="{run.fill}"') - if run.fill_opacity < 1.0: + if inherit_opacity and run.fill_opacity < 1.0: parts.append( f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"' ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py index 5db41127..a880e567 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py @@ -73,6 +73,7 @@ EMU_PER_INCH = 914400 DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +MARKUP_COMPATIBILITY_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006" CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex" OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"} IMAGE_EXT_BY_CONTENT_TYPE = { @@ -186,6 +187,34 @@ def _safe_position(shape: object, attr: str) -> int: return 0 +def _expose_math_choice_shapes(slide: object) -> None: + """Unwrap a14 Office Math Choice shapes so ``slide.shapes`` sees them. + + python-pptx skips every ``mc:AlternateContent`` in the shape tree, which + hides formula text boxes and the ordinary text around their math. Only the + in-memory read view changes; the source file is never written. + """ + alternate_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}AlternateContent" + choice_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}Choice" + shape_tags = {f"{{{PRESENTATIONML_NS}}}sp", f"{{{PRESENTATIONML_NS}}}grpSp"} + for wrapper in list(slide.element.iter(alternate_tag)): + choice = wrapper.find(choice_tag) + parent = wrapper.getparent() + if ( + choice is None + or parent is None + or "a14" not in (choice.get("Requires") or "").split() + ): + continue + shapes = list(choice) + if not shapes or any(shape.tag not in shape_tags for shape in shapes): + continue + index = parent.index(wrapper) + parent.remove(wrapper) + for offset, shape in enumerate(shapes): + parent.insert(index + offset, shape) + + def iter_leaf_shapes(shapes: object) -> list[LeafShape]: """Return a flattened, reading-order list of shapes.""" items: list[LeafShape] = [] @@ -1278,6 +1307,7 @@ def convert_presentation_to_markdown( } emitted_diagram_ids: set[str] = set() emitted_chart_relationship_ids: set[str] = set() + _expose_math_choice_shapes(slide) for item in iter_leaf_shapes(slide.shapes): shape = item.shape diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py index c1534472..580c3704 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any from xml.etree import ElementTree as ET from dataclasses import dataclass, field +from pptx_ooxml.package import PackagePartRegistry + if TYPE_CHECKING: from .theme_colors import ThemeColorSpec from .theme_fonts import ThemeFontSpec @@ -84,6 +86,7 @@ class ConvertContext: rel_entries: list[dict[str, str]] = field(default_factory=list) package_files: dict[str, bytes] = field(default_factory=dict) content_type_overrides: dict[str, str] = field(default_factory=dict) + package_parts: PackagePartRegistry = field(default_factory=PackagePartRegistry) rel_id_counter: int = 2 # rId1 reserved for slideLayout svg_dir: Path | None = None # Explicit project boundary for deterministic resource resolution. Public @@ -266,6 +269,7 @@ class ConvertContext: rel_entries=self.rel_entries, package_files=self.package_files, content_type_overrides=self.content_type_overrides, + package_parts=self.package_parts, rel_id_counter=self.rel_id_counter, svg_dir=self.svg_dir, resource_root=self.resource_root, 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 cfe67305..7339f0c3 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 @@ -13,6 +13,7 @@ from typing import Any from xml.etree import ElementTree as ET from native_payloads import NativePayloadError, hydrate_native_payload_refs +from pptx_ooxml.package import PackagePartRegistry from hyperlink_contract import ( SHAPE_HYPERLINK_ATTR, project_hyperlink_errors, @@ -125,8 +126,11 @@ from ..native_objects import ( native_replacement_kind, native_marker_transform, require_fresh_native_fallback, + reserve_source_chart_parts, snapshot_native_fallback_freshness, ) +from ..native_objects.formula import formula_fallback_xml +from ..native_objects.inline_formula import wrap_inline_formula_shape from ..native_objects.marker_status import native_marker_status_errors from ..semantic_markers import is_static_page_frame @@ -724,6 +728,60 @@ def _append_shape_text( ) +_A14_NS = 'http://schemas.microsoft.com/office/drawing/2010/main' +_A14_MATH_TAG = f'{{{_A14_NS}}}m' +# Generated txBody fragments rely on the slide's prefixes; bind them to parse. +_TXBODY_PREFIXES = ( + 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" ' + f'xmlns:a="{DRAWINGML_NS}" xmlns:a14="{_A14_NS}" ' + 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"' +) + + +def _txbody_has_office_math(txbody_xml: str) -> bool: + try: + body = re.sub(r'^\s*<\?xml[^>]*\?>', '', txbody_xml) + root = ET.fromstring(f'{body}') + except ET.ParseError: + return False + return any(node.tag == _A14_MATH_TAG for node in root.iter()) + + +def _with_office_math_fallback( + elem: ET.Element, + restored: ShapeResult, + geometry: ShapeResult, + ctx: ConvertContext, +) -> ShapeResult: + """Give a shape's Office Math text its visible SVG text as MCE Fallback.""" + # The copy is a plain picture of the shape: fresh id, no placeholder role. + carrier_xml = re.sub( + r']*/>', '', carrier_xml) + results = [ShapeResult(xml=carrier_xml, bounds_emu=geometry.bounds_emu)] + for child in elem: + if ( + child.get('data-pptx-part') is None + and child.tag.replace(f'{{{SVG_NS}}}', '') in {'text', 'g'} + ): + result = convert_element(child, ctx) + if result: + results.append(result) + fallback_xml = formula_fallback_xml( + results, + ctx, + _xml_escape(shape_display_name(elem, 'Formula')), + ) + return ShapeResult( + xml=wrap_inline_formula_shape(restored.xml, fallback_xml), + bounds_emu=restored.bounds_emu, + ) + + def _make_semantic_geometry_carrier_visible(element: ET.Element) -> None: """Remove authoring-only hiding from a semantic geometry carrier clone.""" for name in ('visibility', 'display', 'pointer-events'): @@ -847,7 +905,12 @@ def _convert_semantic_shape( 'Semantic shape geometry carrier produced no native shape' ) text_body = _semantic_shape_text_body(shape, ctx) - return _append_shape_text(geometry, text_body) if text_body else geometry + if not text_body: + return geometry + restored = _append_shape_text(geometry, text_body) + if _txbody_has_office_math(text_body): + return _with_office_math_fallback(shape, restored, geometry, ctx) + return restored def _restore_placeholder_sp_pr( @@ -1313,6 +1376,11 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: native_text, ) restored = _restore_placeholder_sp_pr(restored, elem) + if _txbody_has_office_math(native_text): + restored = _with_office_math_fallback( + elem, restored, geometry_result, geometry_ctx, + ) + ctx.sync_from_child(geometry_ctx) if should_animate_group and elem_id: shape_match = re.search(r' tuple[ str, dict[str, bytes], @@ -2017,6 +2086,8 @@ def convert_svg_to_slide_shapes( Parsing, resource, conversion, and package failures remain blocking. resource_root: Explicit project boundary for local images and icons. Omit only for direct library calls that require legacy inference. + package_parts: Shared native-part registry for the complete export, + including the template/source package and previously rebuilt slides. Returns: (slide_xml, media_files, rel_entries, anim_targets, @@ -2375,12 +2446,15 @@ def convert_svg_to_slide_shapes( theme_font_spec=theme_font_spec, theme_color_spec=theme_color_spec, primary_language=primary_language, + package_parts=package_parts if package_parts is not None else PackagePartRegistry(), inherited_styles=_extract_inheritable_styles(root), opacity_multiplier=1.0 if root_opacity is None else root_opacity, parent_by_id={id(child): parent for parent in root.iter() for child in parent}, text_font_sizes=text_font_sizes, text_letter_spacings=text_letter_spacings, ) + if native_objects: + reserve_source_chart_parts(root, ctx) shapes: list[str] = [] converted = 0 skipped = 0 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 a138db30..3ee9f0ac 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 @@ -3085,6 +3085,15 @@ def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]: return True, a < 0, d < 0 +def _inline_formula_preview_run(run: dict[str, Any]) -> dict[str, Any]: + """Return an inline formula run as its ordinary SVG preview-text run.""" + if run.get(_INLINE_FORMULA_KEY) is None: + return run + preview = dict(run) + del preview[_INLINE_FORMULA_KEY] + return preview + + def _build_run_xml( run: dict[str, Any], default_fonts: dict[str, str], @@ -3590,47 +3599,61 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: shape_id = _claim_element_shape_id(elem, ctx) rot_attr = f' rot="{text_rot}"' if text_rot else '' - if paragraph_runs is not None: - # SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100. - line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT) - ln_spc_xml = f'' - paragraph_xml_chunks = [] - for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets): - spc_bef_xml = '' - if extra_px > 0: - spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT) - spc_bef_xml = f'' - runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line) - first_text_run = next( - (run for run in line if not run.get('_line_break')), - None, + def _paragraphs_xml(formula_previews: bool) -> str: + # The MCE Fallback copy writes each inline formula as its SVG preview + # text so a reader without a14 math still shows the formula. + def _runs_xml(line_runs: list[dict[str, Any]]) -> str: + return '\n'.join( + _build_run_xml( + _inline_formula_preview_run(r) if formula_previews else r, + fonts, + ctx, + text_effect_xml, + ) + for r in line_runs ) - effective_line_spacing = ( - '' - if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line) - else ln_spc_xml - ) - p_pr_xml = _paragraph_pr_xml( - algn=algn, - font_size=( - float(first_text_run.get('font_size', font_size)) - if first_text_run is not None - else font_size - ), - body_xml=f'{effective_line_spacing}{spc_bef_xml}', - bullet=bullet, - ctx=ctx, - rtl=text_uses_rtl( - ''.join(str(run.get('text', '')) for run in line), - ctx.primary_language, - ), - ) - paragraph_xml_chunks.append( - f'\n{p_pr_xml}\n{runs_inner}\n' - ) - paragraphs_xml = '\n'.join(paragraph_xml_chunks) - else: - runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs) + + if paragraph_runs is not None: + # SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100. + line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT) + ln_spc_xml = f'' + paragraph_xml_chunks = [] + for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets): + spc_bef_xml = '' + if extra_px > 0: + spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT) + spc_bef_xml = f'' + runs_inner = _runs_xml(line) + first_text_run = next( + (run for run in line if not run.get('_line_break')), + None, + ) + effective_line_spacing = ( + '' + if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line) + else ln_spc_xml + ) + p_pr_xml = _paragraph_pr_xml( + algn=algn, + font_size=( + float(first_text_run.get('font_size', font_size)) + if first_text_run is not None + else font_size + ), + body_xml=f'{effective_line_spacing}{spc_bef_xml}', + bullet=bullet, + ctx=ctx, + rtl=text_uses_rtl( + ''.join(str(run.get('text', '')) for run in line), + ctx.primary_language, + ), + ) + paragraph_xml_chunks.append( + f'\n{p_pr_xml}\n{runs_inner}\n' + ) + return '\n'.join(paragraph_xml_chunks) + + runs_xml = _runs_xml(runs) p_pr_xml = _paragraph_pr_xml( algn=algn, font_size=float(runs[0].get('font_size', font_size)) if runs else font_size, @@ -3638,7 +3661,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: ctx=ctx, rtl=text_uses_rtl(full_text, ctx.primary_language), ) - paragraphs_xml = f'\n{p_pr_xml}\n{runs_xml}\n' + return f'\n{p_pr_xml}\n{runs_xml}\n' + + paragraphs_xml = _paragraphs_xml(False) off_x = px_to_emu(box_x) off_y = px_to_emu(box_y) @@ -3702,9 +3727,10 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: 'anchor="t" anchorCtr="0">\n\n' ) - shape_xml = f''' + def _shape_xml(sp_id: int, sp_paragraphs_xml: str) -> str: + return f''' - + @@ -3718,12 +3744,17 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: {body_pr_xml} -{paragraphs_xml} +{sp_paragraphs_xml} ''' + + shape_xml = _shape_xml(shape_id, paragraphs_xml) if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs): from ..native_objects.inline_formula import wrap_inline_formula_shape - shape_xml = wrap_inline_formula_shape(shape_xml) + shape_xml = wrap_inline_formula_shape( + shape_xml, + _shape_xml(ctx.next_id(), _paragraphs_xml(True)), + ) return ShapeResult( xml=shape_xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy), 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 46141fdd..15ef20d3 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 @@ -339,6 +339,23 @@ def _grow_chart_frame_for_axis_labels( return off_x, off_y, ext_cx, ext_cy +def reserve_source_chart_parts(root: ET.Element, ctx: ConvertContext) -> None: + """Reserve source chart packages even when they follow newly authored charts.""" + for elem in root.iter(): + if native_replacement_kind(elem) != "chart": + continue + source = _decode_source_chart_package(_load_payload(elem, "chart")) + if source is not None: + ctx.package_parts.reserve_source(source[2], _chart_part_owner(elem, ctx)) + + +def _chart_part_owner(elem: ET.Element, ctx: ConvertContext) -> str: + return ( + f"slide {ctx.slide_num} chart {elem.get('id')!r} " + f"(source shape {elem.get('data-pptx-shape-id')!r})" + ) + + def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult: source_package = _decode_source_chart_package(payload) chart_data = None if source_package is not None else _chart_data(payload) @@ -349,17 +366,29 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str axis_font_px=text_sizes["axis"] / 75, ) - 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() + source_id = elem.get("data-pptx-shape-id") + shape_id = ctx.claim_shape_id( + source_id, + elem.get("data-pptx-shape-scope"), ) 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 + if source_package is None: + is_chartex = chart_data is not None and chart_data["kind"] == "chartex" + chart_pattern = "chartEx{}.xml" if is_chartex else "chart{}.xml" + patterns = ( + f"ppt/charts/{chart_pattern}", + f"ppt/charts/_rels/{chart_pattern}.rels", + "ppt/embeddings/Microsoft_Excel_Sheet{}.xlsx", + ) + if is_chartex: + patterns += ("ppt/charts/style{}.xml", "ppt/charts/colors{}.xml") + part_index = ctx.package_parts.next_index( + patterns, part_index, ctx.package_files.keys() | ctx.content_type_overrides.keys(), + ) + parts: dict[str, bytes] = {} + content_types: dict[str, str] = {} workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx" workbook_part = f"ppt/embeddings/{workbook_name}" if source_package is not None: @@ -372,14 +401,8 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str "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) + parts = source_parts + content_types = 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" @@ -399,18 +422,18 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str "type": CHARTEX_REL_TYPE, "target": f"../charts/{chart_name}", }) - ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1") - ctx.package_files[chart_rels_part] = _chart_ex_rels_xml( + parts[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1") + parts[chart_rels_part] = _chart_ex_rels_xml( f"../embeddings/{workbook_name}", style_name, colors_name, ) - ctx.package_files[style_part] = _chart_ex_style_xml() - ctx.package_files[colors_part] = _chart_ex_colors_xml(payload) - ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data) - ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE - ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE - ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE + parts[style_part] = _chart_ex_style_xml() + parts[colors_part] = _chart_ex_colors_xml(payload) + parts[workbook_part] = _minimal_chart_ex_workbook(chart_data) + content_types[chart_part] = CHARTEX_CONTENT_TYPE + content_types[style_part] = CHART_STYLE_CONTENT_TYPE + content_types[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE else: chart_name = f"chart{part_index}.xml" chart_part = f"ppt/charts/{chart_name}" @@ -425,7 +448,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str "type": CHART_REL_TYPE, "target": f"../charts/{chart_name}", }) - ctx.package_files[chart_part] = _chart_xml( + parts[chart_part] = _chart_xml( elem, payload, chart_rels_id="rId1", @@ -434,15 +457,22 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str primary_language=ctx.primary_language, chart_bounds=(off_x, off_y, ext_cx, ext_cy), ) - ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}") + parts[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) + parts[workbook_part] = _minimal_xy_chart_workbook(chart_data) else: - ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data) - ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE + parts[workbook_part] = _minimal_category_chart_workbook(chart_data) + content_types[chart_part] = CHART_CONTENT_TYPE - name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}")) + ctx.package_parts.register( + parts, content_types, _chart_part_owner(elem, ctx), source=source_package is not None, + ) + ctx.package_files.update(parts) + ctx.content_type_overrides.update(content_types) + + source_name = elem.get("data-pptx-shape-name") if source_id is not None else None + name = _xml_escape(str(source_name or payload.get("name") or elem.get("id") or f"Native Chart {shape_id}")) chart_frame_xml = ( _source_chart_frame_xml( source_frame, @@ -494,10 +524,18 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str x0, y0, x1, y1 = companion.bounds_emu min_x, min_y = min(min_x, x0), min(min_y, y0) max_x, max_y = max(max_x, x1), max(max_y, y1) + companion_xml = "".join(companion.xml for companion in companions) + if source_id is not None: + # Preserve the imported frame's hierarchy and Morph object type. + # Frame-first output keeps animation and trace consumers on its id. + return ShapeResult( + xml=chart_frame_xml + companion_xml, + bounds_emu=(min_x, min_y, max_x, max_y), + ) + group_w, group_h = max_x - min_x, max_y - min_y validate_ooxml_xfrm(min_x, min_y, group_w, group_h) group_id = ctx.next_id() - companion_xml = "".join(companion.xml for companion in companions) # The marker owns one selectable/animatable unit. Identity mapping keeps # the chart and labels at their already resolved slide coordinates. xml = f''' diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/formula.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/formula.py index 13b6b0dd..46897871 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/formula.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/formula.py @@ -196,20 +196,71 @@ def _styled_omml(spec: FormulaSpec) -> str: return serialize_styled_formula_omml(root) +def formula_fallback_xml( + results: list[ShapeResult], + ctx: ConvertContext, + name: str, +) -> str: + """Join converted preview shapes into one MCE Fallback shape (or none).""" + if not results: + return "" + if len(results) == 1: + return results[0].xml + bounds = [result.bounds_emu for result in results if result.bounds_emu] + if not bounds: + return "\n".join(result.xml for result in results) + min_x = min(box[0] for box in bounds) + min_y = min(box[1] for box in bounds) + ext_cx = max(max(box[2] for box in bounds) - min_x, 1) + ext_cy = max(max(box[3] for box in bounds) - min_y, 1) + shapes_xml = "\n".join(result.xml for result in results) + return f''' + + + + + + + + + +{shapes_xml} +''' + + +def _preview_fallback_xml(elem: ET.Element, ctx: ConvertContext, name: str) -> str: + """Convert the marker's SVG preview into native shapes for the MCE Fallback.""" + # Deferred import: the drawingml converter imports this package. + from ..drawingml.converter import convert_element + + results = [ + result + for child in elem + if child.tag.rsplit("}", 1)[-1] != "metadata" + and (result := convert_element(child, ctx)) is not None + ] + return formula_fallback_xml(results, ctx, name) + + def build_native_formula( elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any], spec: FormulaSpec | None = None, ) -> ShapeResult: - """Build one Choice-only editable formula with no image fallback.""" + """Build one editable formula whose MCE Fallback is its SVG preview.""" formula_spec = spec or validate_formula_payload(payload, ctx=ctx) 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"), + ) name = _xml_escape( str(payload.get("name") or elem.get("id") or f"Formula {shape_id}") ) omml = _styled_omml(formula_spec) + fallback_xml = _preview_fallback_xml(elem, ctx, name) + fallback = f"\n\n{fallback_xml}\n" if fallback_xml else "" xml = f''' @@ -235,7 +286,7 @@ def build_native_formula( - +{fallback} ''' return ShapeResult( xml=xml, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/inline_formula.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/inline_formula.py index d429b951..2ae47aba 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/inline_formula.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/inline_formula.py @@ -292,13 +292,14 @@ def build_inline_formula_xml(latex: str, run_properties_xml: str) -> str: return f"{styled}" -def wrap_inline_formula_shape(shape_xml: str) -> str: - """Wrap a text shape containing ``a14:m`` in its required MCE Choice.""" +def wrap_inline_formula_shape(shape_xml: str, fallback_xml: str) -> str: + """Wrap an ``a14:m`` text shape in its MCE Choice plus preview-text Fallback.""" return ( f'' f'' f"{shape_xml}" "" + f"{fallback_xml}" "" ) 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 58b7a326..3bf5170f 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 @@ -21,6 +21,7 @@ from datetime import datetime, timezone from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any from xml.etree import ElementTree as ET +from xml.parsers import expat from xml.sax.saxutils import escape, quoteattr from pptx import Presentation @@ -71,7 +72,7 @@ from pptx_opc_validation import ( ) from pptx_workspace import WorkspaceResourceSpec from pptx_ooxml.clone import clone_presentation_slides -from pptx_ooxml.package import prune_unreferenced_directory_parts +from pptx_ooxml.package import PackagePartRegistry, prune_unreferenced_directory_parts from language_tags import language_uses_rtl, office_language_tag from hyperlink_contract import ( HYPERLINK_REL_TYPE, @@ -606,11 +607,94 @@ def _set_shape_name(elem: ET.Element, name: str) -> None: ) +def _roundtrip_morph_shape_ids(svg_path: Path, group_ids: set[str]) -> dict[tuple[str, str], int]: + """Resolve preserved endpoints from imported identity, never SVG id spelling.""" + if not group_ids: + return {} + root = ET.parse(svg_path).getroot() + result: dict[tuple[str, str], int] = {} + for group_id in sorted(group_ids): + groups = [node for node in root if node.tag == '{http://www.w3.org/2000/svg}g' + and node.get('id') == group_id] + shape_id = groups[0].get('data-pptx-shape-id', '') if len(groups) == 1 else '' + if (not shape_id.isdecimal() + or groups[0].get('data-pptx-shape-scope') != 'slide'): + raise TemplateStructureError( + f'Morph target "{svg_path.stem}/{group_id}" has no unique imported ' + 'Slide-local shape identity; edit this endpoint page as well, or ' + 'pair groups on two edited pages' + ) + result[(svg_path.stem, group_id)] = int(shape_id) + return result + + +_XML_START_TAG_RE = re.compile(r'''<(?:"[^"]*"|'[^']*'|[^'">])*>''') +_XML_ATTRIBUTE_RE = re.compile(r'''\s+([^\s=/>]+)\s*=\s*(["'])(.*?)\2''', re.DOTALL) + + +def _patch_morph_name_bytes( + source: bytes, + assignments: dict[str, tuple[str, str]], + slide_number: int, +) -> bytes: + """Replace only cNvPr name values, retaining all other source XML bytes.""" + parser = expat.ParserCreate(namespace_separator='}') + offsets: dict[str, list[tuple[int, str | None]]] = {} + encoding = 'utf-8' + + def declaration(_version: str, declared_encoding: str | None, _standalone: int) -> None: + nonlocal encoding + encoding = declared_encoding or encoding + + def start(name: str, attributes: dict[str, str]) -> None: + shape_id = attributes.get('id') + if name == f'{PML_NS}}}cNvPr' and shape_id in assignments: + offsets.setdefault(shape_id, []).append((parser.CurrentByteIndex, attributes.get('name'))) + + parser.XmlDeclHandler = declaration + parser.StartElementHandler = start + parser.Parse(source, True) + # Byte offsets come from Expat; encode individual spans without a BOM. + if source.startswith((b'\xff\xfe', b'<\x00')): + encoding = 'utf-16-le' + elif source.startswith((b'\xfe\xff', b'\x00<')): + encoding = 'utf-16-be' + replacements: list[tuple[int, int, bytes]] = [] + for shape_id, (name, group_id) in assignments.items(): + locations = offsets.get(shape_id, []) + error = ( + f'Morph target "{group_id}" on slide {slide_number} cannot patch ' + f'cNvPr@name for source shape {shape_id} without rewriting XML; ' + 'edit this endpoint page as well, or pair groups on two edited pages' + ) + if len(locations) != 1: + raise TemplateStructureError(error) + offset, existing_name = locations[0] + if existing_name == name: + continue + tag = _XML_START_TAG_RE.match(source[offset:].decode(encoding)) + attribute = next( + (match for match in _XML_ATTRIBUTE_RE.finditer(tag.group()) if match[1] == 'name'), + None, + ) if tag is not None else None + if attribute is None: + raise TemplateStructureError(error) + start_offset = offset + len(tag.group()[:attribute.start(3)].encode(encoding)) + end_offset = offset + len(tag.group()[:attribute.end(3)].encode(encoding)) + escaped = escape(name, {'"': '"', "'": '''}) + replacements.append((start_offset, end_offset, escaped.encode(encoding, errors='xmlcharrefreplace'))) + for start_offset, end_offset, value in sorted(replacements, reverse=True): + source = source[:start_offset] + value + source[end_offset:] + return source + + def _apply_morph_shape_names( extract_dir: Path, pairs: tuple[MorphPair, ...], slide_numbers: dict[str, int], shape_ids: dict[tuple[str, str], int], + *, + preserved_slides: set[int], ) -> dict[int, dict[str, str]]: """Write forced-Morph names after all structure transformations finish.""" assignments: dict[int, dict[str, tuple[str, str]]] = {} @@ -621,14 +705,21 @@ def _apply_morph_shape_names( (pair.destination_slide, pair.destination_group_id), ): slide_number = slide_numbers[slide_name] - shape_id = str(shape_ids[(slide_name, group_id)]) + resolved_id = shape_ids.get((slide_name, group_id)) + if resolved_id is None: + raise TemplateStructureError( + f'Morph target "{slide_name}/{group_id}" has no exported ' + 'Slide-local shape identity; edit this endpoint page as well, ' + 'or pair groups on two edited pages' + ) + shape_id = str(resolved_id) slide_assignments = assignments.setdefault(slide_number, {}) previous = slide_assignments.setdefault( shape_id, (pair.shape_name, group_id), ) if previous[0] != pair.shape_name: - raise RuntimeError( + raise TemplateStructureError( f'Morph target "{slide_name}/{group_id}" resolves to shape ' f'{shape_id} with conflicting names "{previous[0]}" and ' f'"{pair.shape_name}"' @@ -636,7 +727,7 @@ def _apply_morph_shape_names( slide_names = names_by_slide.setdefault(slide_number, {}) previous_group = slide_names.setdefault(pair.shape_name, group_id) if previous_group != group_id: - raise RuntimeError( + raise TemplateStructureError( f'Morph name "{pair.shape_name}" maps to multiple objects ' f'on slide "{slide_name}"' ) @@ -661,7 +752,7 @@ def _apply_morph_shape_names( c_nv_pr.get("name") if c_nv_pr is not None else None ) if existing_name in desired_names: - raise RuntimeError( + raise TemplateStructureError( f'Morph name "{existing_name}" already belongs to an ' f'unmapped object on slide {slide_number}' ) @@ -669,13 +760,18 @@ def _apply_morph_shape_names( for shape_id, (shape_name, group_id) in slide_assignments.items(): shape = top_level_shapes.get(shape_id) if shape is None: - raise RuntimeError( + raise TemplateStructureError( f'Morph target "{group_id}" no longer resolves to a ' f'Slide-local shape on slide {slide_number}' ) _set_shape_name(shape, shape_name) trace_names.setdefault(slide_number, {})[group_id] = shape_name - _write_xml_tree(slide_path, tree) + if slide_number in preserved_slides: + slide_path.write_bytes(_patch_morph_name_bytes( + slide_path.read_bytes(), slide_assignments, slide_number, + )) + else: + _write_xml_tree(slide_path, tree) return trace_names @@ -6892,6 +6988,7 @@ def create_pptx_with_native_svg( set(), ).add(pair.destination_group_id) morph_shape_ids: dict[tuple[str, str], int] = {} + morph_preserved_slides: set[int] = set() if definition_svg_files and pptx_structure != "structured": raise ValueError( "unselected Layout prototypes require pptx_structure='structured'" @@ -7223,6 +7320,7 @@ def create_pptx_with_native_svg( image_exts_used: set[str] = set() package_exts_used: set[str] = set() package_content_overrides: dict[str, str] = {} + package_parts = PackagePartRegistry(extract_dir, _content_type_overrides(extract_dir)) notes_slides_created: set[int] = set() notes_master_parts_used: set[str] = set() notes_master_theme_parts_created: set[str] = set() @@ -7304,6 +7402,10 @@ def create_pptx_with_native_svg( raise TemplateStructureError( "Internal Layout definitions cannot use source-slide passthrough" ) + morph_shape_ids.update(_roundtrip_morph_shape_ids( + svg_path, morph_group_overrides_by_slide.get(svg_path.stem, set()), + )) + morph_preserved_slides.add(slide_num) if verbose: print( f" {progress_label} {svg_path.name} " @@ -7421,21 +7523,25 @@ def create_pptx_with_native_svg( continue if ( slide_patch is not None - and slide_patch.notes_changed and not slide_patch.visual_changed and not slide_patch.motion_changed + and (slide_patch.notes_changed or morph_group_overrides_by_slide.get(svg_path.stem)) ): - notes_content = ( - notes.get(svg_path.stem, '') if notes else '' - ) - notes_master = _apply_slide_notes( - extract_dir, - rels_path, - slide_num, - notes_content, - primary_language, - enable_notes=enable_notes, - ) + morph_shape_ids.update(_roundtrip_morph_shape_ids( + svg_path, morph_group_overrides_by_slide.get(svg_path.stem, set()), + )) + morph_preserved_slides.add(slide_num) + notes_master = None + if slide_patch.notes_changed: + notes_content = notes.get(svg_path.stem, '') if notes else '' + notes_master = _apply_slide_notes( + extract_dir, + rels_path, + slide_num, + notes_content, + primary_language, + enable_notes=enable_notes, + ) if notes_master is not None: notes_slides_created.add(slide_num) notes_master_parts_used.add(notes_master.package_part) @@ -7446,7 +7552,7 @@ def create_pptx_with_native_svg( if verbose: print( f" {progress_label} {svg_path.name} " - "(Source slide notes overlay)" + "(Source slide notes / identity overlay)" ) success_count += 1 continue @@ -7595,6 +7701,7 @@ def create_pptx_with_native_svg( image_scale=image_scale, image_quality=image_quality, native_objects=native_objects, + package_parts=package_parts, animation_group_overrides=converter_group_overrides, theme_font_spec=active_theme_font_spec, theme_color_spec=active_theme_color_spec, @@ -8337,6 +8444,7 @@ def create_pptx_with_native_svg( morph_pairs, public_slide_numbers, morph_shape_ids, + preserved_slides=morph_preserved_slides, ) if template_shape_roster_expectations is not None: for slide_number in morph_trace_names: 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 4ca3b066..c17252d1 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 @@ -134,6 +134,7 @@ from .template_structure import ( from ..animation_config import ( animation_group_effect_entries, load_animation_config, + resolve_morph_pairs, resolve_slide_animation_config, validate_animation_config, validate_animation_config_errors, @@ -440,6 +441,18 @@ def _roundtrip_passthrough_candidates( current_animation_config, pages, ) + # Explicit identity edits also touch the source endpoint, whose own motion + # row may still match the import baseline. Classify both ends as patches. + morph_slides = { + stem + for pair in resolve_morph_pairs( + [page.svg_stem for page in pages], expanded_animation_config, + ) + for stem in (pair.source_slide, pair.destination_slide) + } + candidates.difference_update( + page.output_index for page in pages if page.svg_stem in morph_slides + ) pages_by_output = {page.output_index: page for page in pages} for index in tuple(candidates): page = pages_by_output[index] @@ -2892,7 +2905,7 @@ Recorded narration: authoring_report=authoring_roundtrip_report, changed_resource_pages=changed_resource_pages, ) - except RuntimeError as exc: + except (RuntimeError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 if roundtrip_passthrough_slides: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_fallback.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_fallback.py new file mode 100644 index 00000000..8eb66b38 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_fallback.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Formula MCE Fallback Regression Tests + +Check that block and inline formulas carry their SVG preview as native shapes +in mc:Fallback while PowerPoint keeps reading the editable a14 Choice. + +Usage: + python3 -m unittest tests.test_formula_fallback + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local SVG-to-PPTX / PPTX-to-SVG converters. +""" + +from __future__ import annotations + +import base64 +import sys +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from pptx_ooxml.ooxml import _text_containers # noqa: E402 +from pptx_shapes.semantic_hash import svg_text_fingerprint # noqa: E402 +from pptx_to_svg.shape_walker import _resolve_alternate_content # noqa: E402 +from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: E402 + + +NS = { + 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', + 'a14': 'http://schemas.microsoft.com/office/drawing/2010/main', + 'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math', + 'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', + 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', +} + +BLOCK_FORMULA = ( + '' + '' + '{preview}' + '' +) + + +def _block_formula(preview: str) -> str: + return BLOCK_FORMULA.replace('{preview}', preview) + + +def _preserved_formula_shape(extra_attributes: str = '') -> str: + """An imported shape whose unchanged source txBody holds Office Math.""" + txbody = ( + f'' + '' + 'a' + 'b' + '' + ) + shape = ( + '' + '' + '' + f'{base64.b64encode(txbody.encode("utf-8")).decode("ascii")}' + '' + 'a/b' + '' + ) + sha = svg_text_fingerprint(ET.fromstring(shape.replace('{sha}', ''))) + return shape.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace( + '{sha}', sha, + ) + + +class FormulaFallbackTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.svg_path = self.root / '01_formula.svg' + + def _export(self, body: str) -> ET.Element: + self.svg_path.write_text( + '' + f'{body}', + encoding='utf-8', + ) + xml, *_rest = convert_svg_to_slide_shapes( + self.svg_path, resource_root=self.root, + ) + return ET.fromstring(xml) + + def _alternate_content(self, slide: ET.Element) -> ET.Element: + wrappers = slide.findall('.//mc:AlternateContent', NS) + self.assertEqual(len(wrappers), 1) + return wrappers[0] + + def _assert_unique_shape_ids(self, slide: ET.Element) -> None: + ids = [node.get('id') for node in slide.iter(f"{{{NS['p']}}}cNvPr")] + self.assertEqual(len(ids), len(set(ids))) + + def test_block_formula_fallback_is_preview_text(self) -> None: + slide = self._export(_block_formula( + ( + 'DSCR = EBITDA / PD' + ), + )) + wrapper = self._alternate_content(slide) + choice = wrapper.find('mc:Choice', NS) + fallback = wrapper.find('mc:Fallback', NS) + self.assertEqual(choice.get('Requires'), 'a14') + self.assertIsNotNone(choice.find('.//a14:m/m:oMathPara', NS)) + self.assertIsNotNone(fallback) + self.assertIsNone(fallback.find('.//a14:m', NS)) + fallback_text = ''.join( + node.text or '' for node in fallback.iter(f"{{{NS['a']}}}t") + ) + self.assertEqual(fallback_text, 'DSCR = EBITDA / PD') + self._assert_unique_shape_ids(slide) + + def test_multi_part_block_preview_becomes_one_group(self) -> None: + slide = self._export(_block_formula( + ( + 'DSCR =' + 'EBITDA' + '' + 'PD' + ), + )) + fallback = self._alternate_content(slide).find('mc:Fallback', NS) + children = list(fallback) + self.assertEqual(len(children), 1) + self.assertEqual(children[0].tag, f"{{{NS['p']}}}grpSp") + self.assertEqual(len(children[0].findall('p:sp', NS)), 4) + self._assert_unique_shape_ids(slide) + + def test_inline_formula_fallback_keeps_text_box_with_preview_run(self) -> None: + slide = self._export( + '' + 'ratio aᵢ/bᵢ' + ' holds' + ) + wrapper = self._alternate_content(slide) + choice_shape = wrapper.find('mc:Choice/p:sp', NS) + fallback_shape = wrapper.find('mc:Fallback/p:sp', NS) + self.assertIsNotNone(choice_shape.find('.//a14:m/m:oMath', NS)) + self.assertIsNotNone(fallback_shape) + self.assertIsNone(fallback_shape.find('.//a14:m', NS)) + fallback_text = ''.join( + node.text or '' for node in fallback_shape.iter(f"{{{NS['a']}}}t") + ) + self.assertEqual(fallback_text, 'ratio aᵢ/bᵢ holds') + self.assertEqual( + choice_shape.find('p:spPr/a:xfrm', NS).find('a:off', NS).attrib, + fallback_shape.find('p:spPr/a:xfrm', NS).find('a:off', NS).attrib, + ) + self._assert_unique_shape_ids(slide) + + def test_import_selects_choice_and_ignores_fallback(self) -> None: + slide = self._export(_block_formula( + ( + 'DSCR = EBITDA / PD' + ), + )) + selected = _resolve_alternate_content(self._alternate_content(slide)) + self.assertIsNotNone(selected) + self.assertIsNotNone(selected.find('.//a14:m', NS)) + self.assertNotIn( + 'EBITDA / PD', + ''.join(node.text or '' for node in selected.iter(f"{{{NS['a']}}}t")), + ) + + def test_text_readback_ignores_fallback_copy(self) -> None: + slide = self._export( + '' + 'ratio x holds' + ) + containers = _text_containers(slide) + self.assertEqual(len(containers), 1) + self.assertIsNotNone(containers[0].find('.//a14:m', NS)) + + def test_restored_source_math_gets_visible_text_fallback(self) -> None: + slide = self._export(_preserved_formula_shape()) + wrapper = self._alternate_content(slide) + self.assertIsNotNone(wrapper.find('mc:Choice/p:sp//a14:m', NS)) + fallback = wrapper.find('mc:Fallback', NS) + self.assertIsNotNone(fallback) + self.assertIsNone(fallback.find('.//a14:m', NS)) + self.assertEqual( + ''.join(node.text or '' for node in fallback.iter(f"{{{NS['a']}}}t")), + 'a/b', + ) + self.assertIsNotNone(fallback.find(".//a:srgbClr[@val='F5F5F5']", NS)) + self._assert_unique_shape_ids(slide) + + def test_fallback_copy_of_placeholder_drops_placeholder_role(self) -> None: + slide = self._export(_preserved_formula_shape( + ' data-ph-type="body" data-pptx-placeholder-index="1"' + )) + wrapper = self._alternate_content(slide) + self.assertIsNotNone(wrapper.find('mc:Choice/p:sp/p:nvSpPr/p:nvPr/p:ph', NS)) + self.assertEqual(len(slide.findall('.//p:ph', NS)), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_import_colors.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_import_colors.py new file mode 100644 index 00000000..ca466fa8 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_import_colors.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Formula Import Color Regression Tests + +Exercise formula color normalization through the public import and SVG +converters, including native re-export and opaque text-body retention. + +Usage: + python3 -m unittest tests.test_formula_import_colors + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local PPT Master converters. +""" + +from __future__ import annotations + +import base64 +import json +import sys +import tempfile +import unittest +import zipfile +from html import escape +from pathlib import Path +from xml.etree import ElementTree as ET + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from pptx_to_svg.color_resolver import ColorPalette, resolve_color # noqa: E402 +from pptx_to_svg.formula_import import FormulaImportError, import_formula # noqa: E402 +from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402 +from pptx_to_svg.slide_to_svg import assemble_slide # noqa: E402 +from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: E402 +from svg_to_pptx.drawingml.theme_colors import ThemeColorSpec # noqa: E402 +from svg_to_pptx.native_objects.formula_compiler import ( # noqa: E402 + FormulaCompileError, + validate_omml_fragment, +) + +NS = { + 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', + 'a14': 'http://schemas.microsoft.com/office/drawing/2010/main', + 'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math', + 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', + 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + 's': 'http://www.w3.org/2000/svg', +} +THEME = ThemeColorSpec( + slots={'dk1': '173B57', 'accent2': 'C0504D'}, + roles={'text': '173B57', 'accent': 'C0504D'}, + role_slots={'text': 'dk1', 'accent': 'accent2'}, +) + + +class FormulaImportColorTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.palette = ColorPalette(None, None, strict=False) + self.palette.scheme.update(THEME.slots) + + def _export(self, svg: str, *, themed: bool = False) -> ET.Element: + path = self.root / 'formula.svg' + path.write_text(svg, encoding='utf-8') + xml, *_ = convert_svg_to_slide_shapes( + path, resource_root=self.root, theme_color_spec=THEME if themed else None, + ) + return ET.fromstring(xml) + + def _source( + self, latex: str = r'\frac{x}{y}+z', *, display: bool = False, + themed: bool = False, style: str = '', prefix: str = 'before ', + ) -> ET.Element: + if display: + payload = escape(json.dumps({ + 'latex': latex, 'display': 'block', 'font_size': 28, + 'color': '#173B57', 'align': 'center', + })) + body = ( + '' + f'{payload}' + 'x/y+z' + ) + else: + body = ( + '' + f'{prefix}x/y+z after' + ) + return self._export(f'{body}', themed=themed) + + def _import(self, source: ET.Element, *, strict: bool = False): + path = self.root / 'source.pptx' + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('ppt/presentation.xml', ( + f'' + '' + '' + )) + archive.writestr('ppt/_rels/presentation.xml.rels', ( + '' + f'' + '' + )) + archive.writestr('ppt/slides/slide1.xml', ET.tostring(source)) + diagnostics = [] + with OoxmlPackage(path) as package: + svg, _ = assemble_slide( + package, next(package.iter_slides()), self.palette, + strict=strict, diagnostics=diagnostics, + ) + return ET.fromstring(svg), diagnostics + + def _zone(self, slide: ET.Element) -> ET.Element: + zones = slide.findall('.//a14:m', NS) + self.assertEqual(len(zones), 1) + return zones[0] + + def _colors(self, slide: ET.Element) -> list[tuple]: + result = [] + for owner in self._zone(slide).iter(): + if owner.tag not in {f'{{{NS["m"]}}}r', f'{{{NS["m"]}}}ctrlPr'}: + continue + fill = owner.find('a:rPr/a:solidFill', NS) + self.assertIsNotNone(fill) + rgb, alpha = resolve_color(list(fill)[0], self.palette, strict=True) + text = owner.find('m:t', NS) + result.append((owner.tag, text.text if text is not None else '', rgb, alpha)) + return result + + def _assert_reexport(self, source: ET.Element, *, display: bool = False) -> ET.Element: + imported, diagnostics = self._import(source) + self.assertFalse(diagnostics) + attribute = 'data-pptx-replace-with' if display else 'data-pptx-inline-formula' + self.assertEqual(len([node for node in imported.iter() if node.get(attribute)]), 1) + rebuilt = self._export(ET.tostring(imported, encoding='unicode'), themed=True) + self.assertEqual(self._colors(source), self._colors(rebuilt)) + return imported + + def test_srgb_inline_and_block_roundtrip(self) -> None: + for display in (False, True): + with self.subTest(display=display): + self._assert_reexport(self._source(display=display), display=display) + + def test_scheme_colors_include_control_properties(self) -> None: + source = self._source(themed=True) + self.assertIsNotNone(source.find('.//m:ctrlPr/a:rPr/a:solidFill/a:schemeClr', NS)) + self._assert_reexport(source) + + def test_block_scheme_color_uses_metadata_rgb(self) -> None: + source = self._source(display=True) + for color in self._zone(source).iter(f'{{{NS["a"]}}}srgbClr'): + color.tag = f'{{{NS["a"]}}}schemeClr' + color.set('val', 'dk1') + imported = self._assert_reexport(source, display=True) + metadata = imported.find('.//s:g[@data-pptx-replace-with="formula"]/s:metadata', NS) + self.assertEqual(json.loads(metadata.text)['color'], '#173B57') + + def test_current_palette_color_map_is_honored(self) -> None: + source = self._source(themed=True) + self.palette.clr_map['tx1'] = 'accent2' + for color in self._zone(source).iter(f'{{{NS["a"]}}}schemeClr'): + color.set('val', 'tx1') + imported = self._assert_reexport(source) + marker = imported.find('.//s:tspan[@data-pptx-inline-formula]', NS) + self.assertEqual(marker.get('fill'), '#C0504D') + + def test_uniform_alpha_and_surrounding_opaque_runs(self) -> None: + for alpha in ('0', '0.43217', '1'): + for prefix in ('before ', ''): + with self.subTest(alpha=alpha, prefix=prefix): + source = self._source(style=f'opacity="{alpha}"', prefix=prefix) + imported = self._assert_reexport(source) + marker = imported.find('.//s:tspan[@data-pptx-inline-formula]', NS) + self.assertEqual(float(marker.get('fill-opacity', '1')), float(alpha)) + rebuilt = self._export(ET.tostring(imported, encoding='unicode')) + for run in rebuilt.findall('.//a14:m/../a:r', NS): + fill = run.find('a:rPr/a:solidFill', NS) + self.assertEqual(resolve_color(list(fill)[0], self.palette)[1], 1.0) + + def test_formula_opacity_overrides_translucent_parent_text(self) -> None: + source = self._source(prefix='before ') + imported = self._assert_reexport(source) + rebuilt = self._export(ET.tostring(imported, encoding='unicode')) + runs = rebuilt.findall('.//a14:m/../a:r', NS) + self.assertEqual( + [resolve_color(list(run.find('a:rPr/a:solidFill', NS))[0], self.palette)[1] for run in runs], + [0.25, 1.0], + ) + + def test_local_colors_full_partial_and_nested_controls(self) -> None: + for latex in ( + r'\color{red}\frac{x}{y}', + r'\textcolor{red}{x}+\frac{y}{z}', + r'\color{red}\frac{x}{\textcolor{blue}{\sqrt{y}}}+\textcolor{green}{z}', + ): + for display, alpha in ((False, '1'), (False, '0.43217'), (True, '1')): + with self.subTest(latex=latex, display=display, alpha=alpha): + self._assert_reexport(self._source( + latex, display=display, themed=True, style=f'fill-opacity="{alpha}"', + ), display=display) + + def _marker_latex(self, imported: ET.Element, *, display: bool) -> str: + if display: + metadata = imported.find('.//s:g[@data-pptx-replace-with="formula"]/s:metadata', NS) + return json.loads(metadata.text)['latex'] + return imported.find('.//s:tspan[@data-pptx-inline-formula]', NS).get('data-pptx-inline-formula') + + def test_default_colors_do_not_appear_in_latex(self) -> None: + for latex in (r'\frac{x}{y}+z', r'\int_0^1 x', r'\sum_{n=1}^N \left(x_n\right)'): + for display in (False, True): + for themed in (False, True): + with self.subTest(latex=latex, display=display, themed=themed): + imported = self._assert_reexport( + self._source(latex, display=display, themed=themed), display=display, + ) + self.assertNotIn(r'\textcolor', self._marker_latex(imported, display=display)) + + def test_partial_local_color_emits_only_one_scope(self) -> None: + for local in ('x', r'\frac{x}{y}'): + for display in (False, True): + with self.subTest(local=local, display=display): + source = self._source( + r'a+b+c+\textcolor{red}{' + local + '}', display=display, themed=True, + ) + imported = self._assert_reexport(source, display=display) + latex = self._marker_latex(imported, display=display) + self.assertEqual(latex.count(r'\textcolor'), 1) + self.assertIn(r'\textcolor{#FF0000}{' + local + '}', latex) + self.assertNotIn('#173B57', latex) + + def test_nested_controls_share_local_color_scope(self) -> None: + for display in (False, True): + with self.subTest(display=display): + source = self._source( + r'a+b+c+d+\textcolor{red}{\frac{x}{\sqrt{y}}}', display=display, themed=True, + ) + imported = self._assert_reexport(source, display=display) + latex = self._marker_latex(imported, display=display) + self.assertEqual(latex.count(r'\textcolor'), 1) + self.assertIn(r'\textcolor{#FF0000}{\frac{x}{\sqrt{y}}}', latex) + + def test_nested_local_scope_can_restore_base_color(self) -> None: + for display in (False, True): + with self.subTest(display=display): + source = self._source( + r'a+b+c+\textcolor{red}{\frac{x}{\textcolor{#173B57}{\sqrt{y}}}}+d', + display=display, themed=True, + ) + imported = self._assert_reexport(source, display=display) + latex = self._marker_latex(imported, display=display) + self.assertEqual(latex.count(r'\textcolor'), 2) + self.assertIn(r'\textcolor{#173B57}{\sqrt{y}}', latex) + self.assertTrue(latex.endswith(r'\mathrm{+}d')) + + def test_structural_color_different_from_runs_is_preserved(self) -> None: + for display in (False, True): + with self.subTest(display=display): + source = self._source(display=display) + control = self._zone(source).find('.//m:ctrlPr/a:rPr/a:solidFill/a:srgbClr', NS) + control.set('val', 'FF0000') + imported = self._assert_reexport(source, display=display) + latex = self._marker_latex(imported, display=display) + self.assertIn( + r'\textcolor{#FF0000}{\frac{\textcolor{#173B57}{x}}{\textcolor{#173B57}{y}}}', + latex, + ) + self.assertTrue(latex.endswith(r'\mathrm{+}z')) + + def _assert_rejected(self, source: ET.Element, *, display: bool = False) -> None: + before = ET.tostring(source) + with self.assertRaises(FormulaImportError): + import_formula(self._zone(source), display=display, palette=self.palette) + imported, diagnostics = self._import(source) + self.assertIn('formula-not-reconstructed', [item.code for item in diagnostics]) + self.assertFalse(any(node.get('data-pptx-inline-formula') for node in imported.iter())) + self.assertFalse(any(node.get('data-pptx-replace-with') == 'formula' for node in imported.iter())) + metadata = imported.find('.//s:metadata[@data-pptx-part="txbody"]', NS) + self.assertIsNotNone(metadata) + retained = ET.fromstring(base64.b64decode(metadata.text)) + self.assertEqual(ET.tostring(retained).strip(), ET.tostring(source.find('.//p:txBody', NS)).strip()) + self.assertEqual(ET.tostring(source), before) + with self.assertRaisesRegex(ValueError, 'Office Math was not reconstructed'): + self._import(source, strict=True) + + def test_reject_multiple_colors(self) -> None: + source = self._source() + fill = self._zone(source).find('.//a:solidFill', NS) + ET.SubElement(fill, f'{{{NS["a"]}}}schemeClr', {'val': 'accent2'}) + self._assert_rejected(source) + + def test_reject_unresolved_theme_color(self) -> None: + source = self._source(themed=True) + self.palette.scheme.clear() + self._assert_rejected(source) + + def test_reject_wrong_namespaces_unknown_math_and_bad_modifiers(self) -> None: + for invalid in ('color-namespace', 'alpha-namespace', 'unknown-math', 'bad-alpha', 'unknown-modifier'): + with self.subTest(invalid=invalid): + source = self._source() + zone = self._zone(source) + color = zone.find('.//a:srgbClr', NS) + if invalid == 'color-namespace': + color.tag = '{urn:wrong}srgbClr' + elif invalid == 'alpha-namespace': + ET.SubElement(color, '{urn:wrong}alphaMod', {'val': '50000'}) + elif invalid == 'unknown-math': + ET.SubElement(zone[0], f'{{{NS["m"]}}}unknown') + elif invalid == 'bad-alpha': + ET.SubElement(color, f'{{{NS["a"]}}}alphaMod', {'val': 'no'}) + else: + ET.SubElement(color, f'{{{NS["a"]}}}unknown', {'val': '50000'}) + self._assert_rejected(source) + + def test_reject_mixed_run_or_control_alpha(self) -> None: + for target in ('.//m:r/a:rPr/a:solidFill/*', './/m:ctrlPr/a:rPr/a:solidFill/*'): + with self.subTest(target=target): + source = self._source(style='opacity="0.5"') + color = self._zone(source).find(target, NS) + color[0].set('val', '25000') + self._assert_rejected(source) + + def test_reject_block_alpha_that_metadata_cannot_reexport(self) -> None: + source = self._source(display=True) + for color in self._zone(source).iter(f'{{{NS["a"]}}}srgbClr'): + ET.SubElement(color, f'{{{NS["a"]}}}alphaMod', {'val': '50000'}) + self._assert_rejected(source, display=True) + + def test_normalization_does_not_mutate_source_or_relax_compiler(self) -> None: + for options in ({'themed': True}, {'style': 'opacity="0.5"'}): + with self.subTest(options=options): + zone = self._zone(self._source(**options)) + before = ET.tostring(zone) + import_formula(zone, display=False, palette=self.palette) + self.assertEqual(ET.tostring(zone), before) + with self.assertRaises(FormulaCompileError): + validate_omml_fragment(ET.tostring(zone[0], encoding='unicode')) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_roundtrip_identity.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_roundtrip_identity.py new file mode 100644 index 00000000..c01ddb56 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_formula_roundtrip_identity.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +PPT Master - Formula Round-trip Identity Regression Tests + +Keep imported formula identities on the editable Choice while preview Fallback +shapes allocate fresh ids through the public SVG converter. + +Usage: + python3 -m unittest tests.test_formula_roundtrip_identity + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local SVG-to-PPTX converter. +""" + +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET + +from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes + + +NS = { + "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", +} +PREVIEW = 'x = 1' + + +def _block_formula(attributes: str = "", preview: str = PREVIEW) -> str: + return ( + '' + '' + '{"latex":"x=1","display":"block","font_size":28,"name":"!!formula"}' + f'{preview}' + ) + + +class FormulaRoundtripIdentityTests(unittest.TestCase): + def _export(self, body: str) -> ET.Element: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + svg = root / "formula.svg" + svg.write_text( + '' + f'{body}', + encoding="utf-8", + ) + xml, *_rest = convert_svg_to_slide_shapes(svg, resource_root=root) + return ET.fromstring(xml) + + def _assert_unique_ids(self, slide: ET.Element) -> None: + ids = [node.get("id") for node in slide.findall(".//p:cNvPr", NS)] + self.assertEqual(len(ids), len(set(ids))) + + def test_imported_block_choice_keeps_source_id_and_morph_name(self) -> None: + previews = ( + PREVIEW, + PREVIEW + '', + ) + for preview in previews: + with self.subTest(multipart=preview != PREVIEW): + slide = self._export( + _block_formula( + 'data-pptx-shape-id="29" data-pptx-shape-scope="slide"', + preview, + ) + + '' + ) + choice = slide.find(".//mc:Choice/p:sp/p:nvSpPr/p:cNvPr", NS) + self.assertEqual(choice.get("id"), "29") + self.assertEqual(choice.get("name"), "!!formula") + fallback_ids = { + node.get("id") for node in slide.findall(".//mc:Fallback//p:cNvPr", NS) + } + self.assertTrue(fallback_ids) + self.assertTrue(fallback_ids.isdisjoint({"2", "29"})) + self._assert_unique_ids(slide) + + def test_block_identity_uses_source_scope(self) -> None: + slide = self._export( + '' + + _block_formula('data-pptx-shape-id="29" data-pptx-shape-scope="layout1"') + ) + choice = slide.find(".//mc:Choice/p:sp/p:nvSpPr/p:cNvPr", NS) + self.assertEqual(choice.get("id"), "30") + self._assert_unique_ids(slide) + + def test_block_without_source_id_keeps_sequential_allocation(self) -> None: + slide = self._export( + _block_formula() + '' + ) + choice = slide.find(".//mc:Choice/p:sp/p:nvSpPr/p:cNvPr", NS) + fallback = slide.find(".//mc:Fallback/p:sp/p:nvSpPr/p:cNvPr", NS) + self.assertEqual(choice.get("id"), "2") + self.assertEqual(fallback.get("id"), "3") + self.assertEqual( + [node.get("id") for node in slide.findall(".//p:cNvPr", NS)], + ["1", "2", "3", "4"], + ) + + def test_inline_fallback_does_not_claim_source_id(self) -> None: + slide = self._export( + '' + 'value x' + ) + choice = slide.find(".//mc:Choice/p:sp/p:nvSpPr/p:cNvPr", NS) + fallback = slide.find(".//mc:Fallback/p:sp/p:nvSpPr/p:cNvPr", NS) + self.assertEqual(choice.get("id"), "29") + self.assertEqual(choice.get("name"), "!!inline") + self.assertEqual(fallback.get("id"), "2") + self._assert_unique_ids(slide) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_part_allocation.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_part_allocation.py new file mode 100644 index 00000000..509df647 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_part_allocation.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Chart Part Allocation Regression Tests + +Exercise source preservation, chart-family allocation and collision errors +through the public SVG converter, with fixtures constructed in this module. + +Usage: + python3 -m unittest tests.test_native_chart_part_allocation + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local SVG-to-PPTX converter. +""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import posixpath +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from xml.etree import ElementTree as ET +from zipfile import ZipFile + +from pptx_ooxml.package import PackagePartRegistry +from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes + + +NS = { + "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", + "cx": "http://schemas.microsoft.com/office/drawing/2014/chartex", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "rel": "http://schemas.openxmlformats.org/package/2006/relationships", + "x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", +} +SVG_NS = "http://www.w3.org/2000/svg" + + +def _chart(name: str, *, values: tuple = (3, 2), source: dict | None = None, + chartex: bool = False) -> str: + elem = ET.Element(f"{{{SVG_NS}}}g", { + "id": name, + "data-pptx-replace-with": "chart", + "data-pptx-native-authority": "json", + }) + payload = { + "type": "column", "name": name, + "x": 100, "y": 100, "width": 600, "height": 400, + "categories": ["A", "B"], "series": [{"name": "Values", "values": values}], + } + if source is not None: + payload["source_package"] = source + if chartex: + payload.update(type="treemap", levels=[["A", "B"]], values=values) + ET.SubElement(elem, f"{{{SVG_NS}}}metadata", {"type": "application/json"}).text = json.dumps(payload) + return ET.tostring(elem, encoding="unicode") + + +def _blob(data: bytes) -> dict: + return {"encoding": "base64", "payload": base64.b64encode(data).decode("ascii"), + "sha256": hashlib.sha256(data).hexdigest()} + + +def _export(body: str, **kwargs: object) -> tuple: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + svg = root / "chart.svg" + svg.write_text(f'{body}', encoding="utf-8") + xml, _media, rels, _targets, parts, types = convert_svg_to_slide_shapes( + svg, resource_root=root, native_objects=True, **kwargs, + ) + return ET.fromstring(xml), rels, parts, types + + +def _source(chart_index: int = 102, workbook_index: int = 102, + *, values: tuple = (11, 12), extras: dict | None = None) -> dict: + slide, _rels, parts, types = _export(_chart("source", values=values)) + renamed = {} + renamed_types = {} + for name, data in parts.items(): + new_name = name.replace("chart101.xml", f"chart{chart_index}.xml").replace( + "Sheet101.xlsx", f"Sheet{workbook_index}.xlsx", + ) + if name.endswith(".rels"): + data = data.replace(b"Sheet101.xlsx", f"Sheet{workbook_index}.xlsx".encode()) + renamed[new_name] = data + if name in types: + renamed_types[new_name] = types[name] + renamed.update(extras or {}) + return { + "chart_part": f"ppt/charts/chart{chart_index}.xml", + "frame": _blob(ET.tostring(slide.find(".//p:graphicFrame", NS))), + "parts": [{"name": name, **_blob(data), "content_type": renamed_types.get(name)} + for name, data in renamed.items()], + } + + +def _chart_rows(slide: ET.Element, rels: list, parts: dict) -> list[dict]: + rows = [] + for frame in slide.findall(".//p:graphicFrame", NS): + reference = next(iter(frame.findall(".//c:chart", NS) + frame.findall(".//cx:chart", NS))) + relation = next(rel for rel in rels if rel["id"] == reference.get(f"{{{NS['r']}}}id")) + chart_part = posixpath.normpath(posixpath.join("ppt/slides", relation["target"])) + rels_part = posixpath.join("ppt/charts/_rels", posixpath.basename(chart_part) + ".rels") + chart_rels = ET.fromstring(parts[rels_part]) + workbook_rel = next(rel for rel in chart_rels if rel.get("Type").endswith("/package")) + workbook_part = posixpath.normpath(posixpath.join("ppt/charts", workbook_rel.get("Target"))) + chart = ET.fromstring(parts[chart_part]) + values = [node.text for node in chart.findall(".//c:ser/c:val/c:numRef/c:numCache/c:pt/c:v", NS)] + if reference.tag == f"{{{NS['cx']}}}chart": + values = [node.text for node in chart.findall(".//cx:numDim/cx:lvl/cx:pt", NS)] + with ZipFile(io.BytesIO(parts[workbook_part])) as workbook: + sheet = ET.fromstring(workbook.read("xl/worksheets/sheet1.xml")) + workbook_values = [sheet.find(f".//x:c[@r='B{row}']/x:v", NS).text for row in (2, 3)] + rows.append({"chart": chart_part, "rels": rels_part, "workbook": workbook_part, + "values": values, "workbook_values": workbook_values}) + return rows + + +class NativeChartPartAllocationTests(unittest.TestCase): + def assert_source_unchanged(self, source: dict, parts: dict, types: dict) -> None: + for item in source["parts"]: + self.assertEqual(parts[item["name"]], base64.b64decode(item["payload"])) + if item.get("content_type"): + self.assertEqual(types[item["name"]], item["content_type"]) + + def test_preserved_and_rebuilt_charts_keep_independent_data_in_both_orders(self) -> None: + for source_index in (101, 102): + for source_first in (True, False): + with self.subTest(source_index=source_index, source_first=source_first): + source = _source(source_index, source_index) + markers = [_chart("preserved", source=source), _chart("rebuilt")] + if not source_first: + markers.reverse() + slide, rels, parts, types = _export("".join(markers)) + rows = _chart_rows(slide, rels, parts) + for key in ("chart", "rels", "workbook"): + self.assertEqual(len({row[key] for row in rows}), 2) + expected = [["11", "12"], ["3", "2"]] + if not source_first: + expected.reverse() + self.assertEqual([row["values"] for row in rows], expected) + self.assertEqual([row["workbook_values"] for row in rows], expected) + self.assert_source_unchanged(source, parts, types) + + def test_workbook_collision_alone_advances_the_whole_family(self) -> None: + source = _source(7, 102) + slide, rels, parts, types = _export(_chart("preserved", source=source) + _chart("rebuilt")) + rows = _chart_rows(slide, rels, parts) + self.assertEqual(rows[1]["chart"], "ppt/charts/chart103.xml") + self.assertEqual(rows[1]["workbook_values"], ["3", "2"]) + self.assert_source_unchanged(source, parts, types) + + def test_every_existing_family_member_and_content_type_reserves_the_number(self) -> None: + for chartex in (False, True): + prefix = "chartEx" if chartex else "chart" + names = [f"ppt/charts/{prefix}101.xml", f"ppt/charts/_rels/{prefix}101.xml.rels", + "ppt/embeddings/Microsoft_Excel_Sheet101.xlsx"] + if chartex: + names += ["ppt/charts/style101.xml", "ppt/charts/colors101.xml"] + for name in names: + for override_only in (False, True): + with self.subTest(name=name, override_only=override_only): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + if not override_only: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"source bytes") + registry = PackagePartRegistry(root, {name: "source/content-type"}) + slide, rels, parts, _types = _export( + _chart("rebuilt", chartex=chartex), package_parts=registry, + ) + row = _chart_rows(slide, rels, parts)[0] + self.assertEqual(row["chart"], f"ppt/charts/{prefix}102.xml") + self.assertEqual(row["workbook_values"], ["3", "2"]) + self.assertNotIn(name, parts) + + def test_chartex_skips_preserved_style_and_colors(self) -> None: + for name in ("style102.xml", "colors102.xml", "_rels/chartEx102.xml.rels"): + with self.subTest(name=name): + source = _source(7, 7, extras={ + f"ppt/charts/{name}": f''.encode(), + }) + slide, rels, parts, types = _export( + _chart("preserved", source=source) + _chart("rebuilt", chartex=True), + ) + rows = _chart_rows(slide, rels, parts) + self.assertEqual(rows[1]["chart"], "ppt/charts/chartEx103.xml") + self.assertEqual(rows[1]["values"], ["3", "2"]) + self.assert_source_unchanged(source, parts, types) + + def test_legacy_numbers_stay_unchanged_without_collisions(self) -> None: + slide, rels, parts, _types = _export( + _chart("first") + _chart("second", chartex=True) + _chart("third"), + ) + self.assertEqual([row["chart"] for row in _chart_rows(slide, rels, parts)], + ["ppt/charts/chart101.xml", "ppt/charts/chartEx102.xml", "ppt/charts/chart103.xml"]) + + def test_more_than_one_hundred_charts_cannot_collide_with_the_next_slide(self) -> None: + registry = PackagePartRegistry() + _export("".join(_chart(f"chart-{index}") for index in range(101)), package_parts=registry) + slide, rels, parts, _types = _export(_chart("next-page"), slide_num=2, package_parts=registry) + self.assertEqual(_chart_rows(slide, rels, parts)[0]["chart"], "ppt/charts/chart202.xml") + + def test_nested_charts_share_the_package_registry(self) -> None: + source = _source() + slide, rels, parts, _types = _export( + f'{_chart("preserved", source=source)}' + f'{_chart("rebuilt")}', + ) + self.assertEqual([row["values"] for row in _chart_rows(slide, rels, parts)], + [["11", "12"], ["3", "2"]]) + + def test_duplicate_new_writes_fail_even_when_the_bytes_match(self) -> None: + with patch.object(PackagePartRegistry, "next_index", return_value=101): + with self.assertRaisesRegex(RuntimeError, r"part collision: .*ppt/.*first.*second"): + _export(_chart("first") + _chart("second")) + + def test_conflicting_source_bytes_name_both_objects(self) -> None: + with self.assertRaisesRegex(RuntimeError, r"part collision: .*ppt/.*first.*second"): + _export(_chart("first", source=_source()) + _chart("second", source=_source(values=(8, 9)))) + + def test_conflicting_source_content_types_fail_before_registration(self) -> None: + source = _source() + changed = json.loads(json.dumps(source)) + changed["parts"][0]["content_type"] = "conflicting/content-type" + with self.assertRaisesRegex(RuntimeError, r"part collision: .*chart102.xml.*first.*second"): + _export(_chart("first", source=source) + _chart("second", source=changed)) + + def test_identical_source_dependencies_can_be_reused(self) -> None: + source = _source() + _slide, _rels, parts, types = _export(_chart("first", source=source) + _chart("second", source=source)) + self.assertEqual(len(parts), 3) + self.assert_source_unchanged(source, parts, types) + + def test_source_cannot_overwrite_a_template_part(self) -> None: + source = _source() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + part = root / source["chart_part"] + part.parent.mkdir(parents=True) + part.write_bytes(b"template bytes") + with self.assertRaisesRegex(RuntimeError, r"part collision: .*chart102.xml.*template/source.*source"): + _export(_chart("source", source=source), package_parts=PackagePartRegistry(root)) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_roundtrip_identity.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_roundtrip_identity.py new file mode 100644 index 00000000..7ad00f6f --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_roundtrip_identity.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +PPT Master - Native Chart Round-trip Identity Regression Tests + +Keep imported chart identities on the graphicFrame while companion shapes +remain siblings with fresh ids through the public SVG converter. + +Usage: + python3 -m unittest tests.test_native_chart_roundtrip_identity + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local SVG-to-PPTX converter. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import posixpath +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET +from zipfile import ZipFile + +from pptx_transitions import ( + MorphPairExpectation, + create_transition_xml, + validate_pptx_morph_pairs, +) +from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes + + +NS = { + "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", + "cx": "http://schemas.microsoft.com/office/drawing/2014/chartex", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", +} +SVG_NS = "http://www.w3.org/2000/svg" + + +def _chart(attributes: dict | None = None, **extra: object) -> str: + marker = ET.Element(f"{{{SVG_NS}}}g", { + "id": "chart", + "data-pptx-replace-with": "chart", + "data-pptx-native-authority": "json", + **(attributes or {}), + }) + ET.SubElement(marker, f"{{{SVG_NS}}}metadata", { + "type": "application/json", + }).text = json.dumps({ + "type": "column", + "x": 100, "y": 100, "width": 600, "height": 400, + "categories": ["A", "B"], + "series": [{"name": "Values", "values": [3, 2]}], + "name": "Payload chart", + **extra, + }) + return ET.tostring(marker, encoding="unicode") + + +def _blob(data: bytes) -> dict: + return { + "encoding": "base64", + "payload": base64.b64encode(data).decode("ascii"), + "sha256": hashlib.sha256(data).hexdigest(), + } + + +class NativeChartRoundtripIdentityTests(unittest.TestCase): + def _export(self, body: str, *, trace_out: list | None = None) -> tuple: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + svg = root / "chart.svg" + svg.write_text( + f'{body}', + encoding="utf-8", + ) + xml, _media, rels, targets, parts, content_types = convert_svg_to_slide_shapes( + svg, resource_root=root, native_objects=True, trace_out=trace_out, + ) + return ET.fromstring(xml), rels, parts, content_types, targets + + def _assert_unique_ids(self, slide: ET.Element) -> None: + ids = [node.get("id") for node in slide.findall(".//p:cNvPr", NS)] + self.assertEqual(len(ids), len(set(ids))) + + def test_rebuilt_chart_keeps_id_and_morph_name_with_fresh_companions(self) -> None: + chart_types = ({}, {"type": "treemap", "levels": [["A", "B"]], "values": [3, 2]}) + for chart_type in chart_types: + for companions in ( + {}, {"source": "Source note"}, {"notes": ["Chart note"]}, {"caption": "Caption"}, + {"title": {"text": "Title", "x": 100, "y": 50, "width": 600, "height": 40}}, + ): + with self.subTest(chart_type=chart_type, companions=companions): + slide, *_rest = self._export( + _chart({ + "data-pptx-shape-id": "29", + "data-pptx-shape-scope": "slide", + "data-pptx-shape-name": '!!Revenue & "margin"', + }, **chart_type, **companions) + + '' + ) + tree = slide.find("p:cSld/p:spTree", NS) + self.assertIsNone(tree.find("p:grpSp", NS)) + frame = tree.find("p:graphicFrame/p:nvGraphicFramePr/p:cNvPr", NS) + self.assertIsNotNone(frame) + self.assertEqual(frame.get("id"), "29") + self.assertEqual(frame.get("name"), '!!Revenue & "margin"') + self.assertEqual( + [n.get("id") for n in slide.findall(".//p:cNvPr", NS) + if n.get("name", "").startswith("!!")], + ["29"], + ) + if companions: + companion_ids = [ + n.get("id") for n in tree.findall("p:sp/p:nvSpPr/p:cNvPr", NS) + if n.get("id") != "2" + ] + self.assertEqual(len(companion_ids), 1) + self.assertTrue(set(companion_ids).isdisjoint({"2", "29"})) + self._assert_unique_ids(slide) + + def test_companion_union_bounds_and_animation_trace_keep_frame_first(self) -> None: + trace = [] + slide, _rels, _parts, _types, targets = self._export(_chart( + {"data-pptx-shape-id": "29"}, + source={"text": "Source note", "x": 750, "y": 550, "width": 200, "height": 30}, + ), trace_out=trace) + tree = slide.find("p:cSld/p:spTree", NS) + shape_tags = {f"{{{NS['p']}}}{tag}" for tag in ("graphicFrame", "sp", "grpSp")} + shapes = [node for node in tree if node.tag in shape_tags] + self.assertEqual([node.tag for node in shapes], [f"{{{NS['p']}}}graphicFrame", f"{{{NS['p']}}}sp"]) + self.assertEqual(targets, [(29, "chart")]) + event = next(event for event in trace[0]["events"] if event.get("id") == "chart") + self.assertEqual(event["shape_id"], 29) + self.assertEqual(event["bounds_emu"], [value * 9525 for value in (100, 100, 950, 580)]) + + def test_chartex_title_keeps_top_level_morph_pair(self) -> None: + attributes = {"data-pptx-shape-id": "29", "data-pptx-shape-name": "!!chart"} + payload = {"type": "treemap", "levels": [["A", "B"]], "values": [3, 2]} + source, *_rest = self._export(_chart(attributes, **payload)) + destination, *_rest = self._export(_chart(attributes, title="Chart title", **payload)) + namespaces = " ".join(f'xmlns:{key}="{value}"' for key, value in NS.items()) + transitions = ET.fromstring(f'{create_transition_xml("morph")}') + destination.extend(transitions) + with tempfile.TemporaryDirectory() as temporary: + pptx = Path(temporary) / "morph.pptx" + with ZipFile(pptx, "w") as archive: + archive.writestr("ppt/slides/slide1.xml", ET.tostring(source)) + archive.writestr("ppt/slides/slide2.xml", ET.tostring(destination)) + validate_pptx_morph_pairs(pptx, [MorphPairExpectation(1, 2, "chart")]) + + def test_rebuilt_chart_respects_source_scope(self) -> None: + slide, *_rest = self._export( + '' + + _chart({"data-pptx-shape-id": "29", "data-pptx-shape-scope": "layout1"}) + ) + frame = slide.find(".//p:graphicFrame/p:nvGraphicFramePr/p:cNvPr", NS) + self.assertEqual(frame.get("id"), "30") + self._assert_unique_ids(slide) + + def test_payload_morph_name_stays_on_frame_without_source_name(self) -> None: + slide, *_rest = self._export(_chart( + {"data-pptx-shape-id": "29"}, name="!!chart", notes=["Chart note"], + )) + self.assertEqual( + [(n.get("id"), n.get("name")) for n in slide.findall(".//p:cNvPr", NS) + if n.get("name", "").startswith("!!")], + [("29", "!!chart")], + ) + + def test_without_source_id_keeps_allocation_and_name_precedence(self) -> None: + for companions, expected_ids in (({}, ["1", "2", "3"]), + ({"notes": ["Note"]}, ["1", "4", "2", "3", "5"])): + for name, expected_name in (("Payload chart", "Payload chart"), ("", "chart")): + with self.subTest(companions=companions, name=name): + slide, *_rest = self._export( + _chart({"data-pptx-shape-name": "Ignored without source id"}, + name=name, **companions) + + '' + ) + self.assertEqual( + [n.get("id") for n in slide.findall(".//p:cNvPr", NS)], expected_ids, + ) + frame = slide.find(".//p:graphicFrame/p:nvGraphicFramePr/p:cNvPr", NS) + self.assertEqual(frame.get("name"), expected_name) + group_name = slide.find(".//p:grpSp/p:nvGrpSpPr/p:cNvPr", NS) + if companions: + self.assertEqual(group_name.get("name"), expected_name) + + def test_rebuilt_charts_keep_distinct_relationships_and_parts(self) -> None: + slide, rels, parts, _types, _targets = self._export( + _chart({"id": "first", "data-pptx-shape-id": "29"}) + + _chart({"id": "second", "data-pptx-shape-id": "30"}, + type="treemap", levels=[["A", "B"]], values=[3, 2]) + ) + references = slide.findall(".//c:chart", NS) + slide.findall(".//cx:chart", NS) + rel_ids = [node.get(f"{{{NS['r']}}}id") for node in references] + self.assertEqual(len(set(rel_ids)), 2) + targets = [] + for rel_id in rel_ids: + rel = next(item for item in rels if item["id"] == rel_id) + target = posixpath.normpath(posixpath.join("ppt/slides", rel["target"])) + self.assertIn(target, parts) + targets.append(target) + self.assertEqual(len(set(targets)), 2) + self._assert_unique_ids(slide) + + def test_source_package_keeps_frame_name_and_exact_parts(self) -> None: + source, rels, parts, content_types, _targets = self._export(_chart(name="!!source-chart")) + frame = source.find(".//p:graphicFrame", NS) + frame.find("p:nvGraphicFramePr/p:cNvPr", NS).set("id", "29") + chart_part = posixpath.normpath(posixpath.join("ppt/slides", rels[0]["target"])) + package = { + "chart_part": chart_part, + "frame": _blob(ET.tostring(frame)), + "parts": [{ + "name": name, **_blob(data), "content_type": content_types.get(name), + } for name, data in parts.items()], + } + slide, result_rels, result_parts, result_types, _targets = self._export(_chart( + {"data-pptx-shape-id": "29"}, source_package=package, + notes=["Must not be synthesized"], + )) + result_frame = slide.find(".//p:graphicFrame", NS) + self.assertEqual(ET.tostring(result_frame), ET.tostring(frame)) + self.assertEqual(result_rels, rels) + self.assertEqual(result_parts, parts) + self.assertEqual(result_types, content_types) + self.assertIsNone(slide.find(".//p:grpSp", NS)) + self._assert_unique_ids(slide) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_morph_passthrough.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_morph_passthrough.py new file mode 100644 index 00000000..21e4afa0 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_morph_passthrough.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""PPT Master - Round-trip Morph Passthrough Regression Tests + +Exercise explicit pairing through the public exporter with preserved endpoints. + +Usage: + python3 -m unittest tests.test_roundtrip_morph_passthrough + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local PPTX/SVG converters. +""" + +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from xml.etree import ElementTree as ET +from xml.sax.saxutils import escape +from zipfile import ZIP_DEFLATED, ZipFile + +from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg +from pptx_transitions import MorphPairExpectation, validate_pptx_morph_pairs +from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg +from svg_to_pptx.pptx_package.cli import main + + +NS = { + 's': 'http://www.w3.org/2000/svg', + 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', +} + + +class RoundtripMorphPassthroughTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.workspace = self.root / 'workspace' + self.key = 'hero' + + def _import( + self, *, count: int = 2, formatted: bool = False, encoding: str = 'utf-8', + transition: str | None = 'morph', collision: bool = False, + ) -> None: + files = [] + for index in range(1, count + 1): + path = self.root / f'page_{index:02}.svg' + path.write_text( + f'' + '' + '' + '' + '' + '' + '' + '', encoding='utf-8', + ) + files.append(path) + source = self.root / 'source.pptx' + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + self.assertTrue(create_pptx_with_native_svg( + files, source, resource_root=self.root, pptx_structure='flat', + use_compat_mode=False, transition=transition, workers=1, verbose=False, + )) + with ZipFile(source) as archive: + parts = {name: archive.read(name) for name in archive.namelist()} + for index in range(1, count + 1): + part = f'ppt/slides/slide{index}.xml' + xml = parts[part].decode('utf-8') + original_id = next(node.get('id') for node in ET.fromstring(xml).findall('.//p:cNvPr', NS) + if node.get('name') == 'hero') + xml = xml.replace(f'id="{original_id}" name="hero"', 'id="29" name="hero"') + if collision: + xml = xml.replace('name="other"', 'name="!!hero"') + if formatted: + xml = xml.replace('xmlns:p=', 'xmlns:z=').replace('', '') + xml = xml.replace('><', '>\r\n<') + if encoding != 'utf-8': + xml = xml.replace('encoding="UTF-8"', f'encoding="{encoding}"') + xml = xml.replace("encoding='utf-8'", f"encoding='{encoding}'") + parts[part] = xml.encode(encoding) + with ZipFile(source, 'w', ZIP_DEFLATED) as archive: + for name, payload in parts.items(): + archive.writestr(name, payload) + self.source_parts = parts + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + convert_pptx_to_svg( + source, self.workspace, ConvertOptions(inheritance_mode='both', roundtrip=True), + ) + + def _pair(self, *, count: int = 2, stems: list[str] | None = None) -> None: + stems = stems or [f'slide_{index:02}' for index in range(1, count + 1)] + path = self.workspace / 'animations.json' + config = json.loads(path.read_text(encoding='utf-8')) + for source, destination in zip(stems, stems[1:]): + row = config['slides'].setdefault(destination, {}) + row.setdefault('transition', {'effect': 'morph', 'duration': 0.6}) + row['morph'] = {'from': source, 'pairs': {self.key: {'from': 'shape-29', 'to': 'shape-29'}}} + path.write_text(json.dumps(config), encoding='utf-8') + + def _edit(self, index: int) -> None: + path = self.workspace / 'authoring-svg-flat' / f'slide_{index:02}.svg' + source = path.read_text(encoding='utf-8') + self.assertIn('#123456', source) + path.write_text(source.replace('#123456', '#135790'), encoding='utf-8') + + def _export(self, *flags: str, expected_exit: int = 0) -> dict[str, bytes]: + path = self.root / 'output.pptx' + stdout, stderr = io.StringIO(), io.StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + result = main([str(self.workspace), '--roundtrip', '-o', str(path), *flags]) + self.stdout, self.stderr = stdout.getvalue(), stderr.getvalue() + self.assertEqual(result, expected_exit, self.stdout + self.stderr) + self.assertNotIn('Traceback', self.stderr) + if expected_exit: + self.assertFalse(path.exists()) + return {} + self.output = path + report = json.loads((self.workspace / 'validation/output.report.json').read_text(encoding='utf-8')) + self.summary = report['source']['authoring_roundtrip']['export_summary'] + with ZipFile(path) as archive: + return {name: archive.read(name) for name in archive.namelist()} + + def _assert_names(self, parts: dict[str, bytes], count: int = 2) -> None: + for index in range(1, count + 1): + root = ET.fromstring(parts[f'ppt/slides/slide{index}.xml']) + names = [node.get('name') for node in root.findall('.//p:cNvPr', NS) if node.get('id') == '29'] + self.assertEqual(names, ['!!' + self.key]) + validate_pptx_morph_pairs(self.output, [ + MorphPairExpectation(index, index + 1, self.key) for index in range(1, count) + ]) + + def _assert_only_name_changed( + self, parts: dict[str, bytes], index: int, *, formatted: bool = False, encoding: str = 'utf-8', + ) -> None: + part = f'ppt/slides/slide{index}.xml' + old = "name = 'hero'" if formatted else 'name="hero"' + quoted = escape('!!' + self.key, {'"': '"', "'": '''}) + new = f"name = '{quoted}'" if formatted else f'name="{quoted}"' + # Replace the known fixture attribute, without reserializing either side. + codec = 'utf-16-le' if encoding == 'UTF-16' else encoding + self.assertEqual(parts[part], self.source_parts[part].replace(old.encode(codec), new.encode(codec))) + rels = f'ppt/slides/_rels/slide{index}.xml.rels' + self.assertEqual(parts[rels], self.source_parts[rels]) + + def test_preserved_source_and_rebuilt_destination(self) -> None: + self._import() + self._edit(2) + self._pair() + parts = self._export() + self._assert_names(parts) + self._assert_only_name_changed(parts, 1) + self.assertEqual(self.summary, { + 'output_pages': 2, 'passthrough': 0, 'cloned_passthrough': 0, 'patched': 1, 'rebuilt': 1, + }) + self.assertIn('patched=1 rebuilt=1', self.stdout) + + def test_rebuilt_source_and_preserved_destination(self) -> None: + self._import() + self._edit(1) + self._pair() + parts = self._export() + self._assert_names(parts) + self._assert_only_name_changed(parts, 2) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (1, 1)) + + def test_both_preserved_with_escaped_name_and_original_markup(self) -> None: + self._import(formatted=True) + self.key = '身份 & "quote" \'single\'' + self._pair() + parts = self._export() + self._assert_names(parts) + for index in (1, 2): + self._assert_only_name_changed(parts, index, formatted=True) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (2, 0)) + + def test_preserved_utf16_xml_keeps_encoding_and_bytes(self) -> None: + self._import(encoding='UTF-16') + self._pair() + parts = self._export() + self._assert_names(parts) + for index in (1, 2): + self._assert_only_name_changed(parts, index, encoding='UTF-16') + + def test_chain_patches_middle_endpoint_once(self) -> None: + self._import(count=3) + self._edit(3) + self._pair(count=3) + parts = self._export() + self._assert_names(parts, count=3) + for index in (1, 2): + self._assert_only_name_changed(parts, index) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (2, 1)) + + def test_planned_copy_is_patched_by_output_identity(self) -> None: + self._import() + directory = self.workspace / 'authoring-svg-flat' + (directory / 'copy.svg').write_bytes((directory / 'slide_01.svg').read_bytes()) + (self.workspace / 'page_plan.json').write_text(json.dumps({ + 'schema': 'ppt-master.roundtrip-page-plan.v1', + 'pages': [{'source_slide': 1, 'svg': 'copy.svg'}, {'source_slide': 2}], + }), encoding='utf-8') + self._pair(stems=['copy', 'slide_02']) + self._edit(2) + parts = self._export() + self._assert_names(parts) + # Clone relationship targets have their own names; slide XML is still exact. + self.assertEqual(parts['ppt/slides/slide1.xml'], self.source_parts['ppt/slides/slide1.xml'].replace( + b'name="hero"', b'name="!!hero"', + )) + self.assertEqual((self.summary['cloned_passthrough'], self.summary['patched']), (0, 1)) + + def test_no_explicit_morph_preserves_source_xml(self) -> None: + self._import(transition=None) + parts = self._export() + for index in (1, 2): + part = f'ppt/slides/slide{index}.xml' + self.assertEqual(parts[part], self.source_parts[part]) + self.assertEqual((self.summary['passthrough'], self.summary['patched']), (2, 0)) + + def test_new_transition_preserves_source_endpoint_bytes(self) -> None: + self._import(transition=None) + self._pair() + parts = self._export() + self._assert_names(parts) + self._assert_only_name_changed(parts, 1) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (2, 0)) + + def test_notes_overlay_and_pairing_keep_slide_bytes(self) -> None: + self._import() + self._pair() + notes = self.workspace / 'notes' + notes.mkdir(exist_ok=True) + (notes / 'slide_01.md').write_text('New speaker note.', encoding='utf-8') + parts = self._export() + self._assert_names(parts) + self.assertEqual(parts['ppt/slides/slide1.xml'], self.source_parts['ppt/slides/slide1.xml'].replace( + b'name="hero"', b'name="!!hero"', + )) + self.assertTrue(any(b'New speaker note.' in payload for name, payload in parts.items() + if name.startswith('ppt/notesSlides/'))) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (2, 0)) + + def test_name_collision_fails_without_traceback_or_delivery(self) -> None: + self._import(collision=True) + self._pair() + self._export(expected_exit=1) + self.assertIn('!!hero', self.stderr) + self.assertIn('unmapped object on slide 1', self.stderr) + + def test_both_rebuilt_remain_supported(self) -> None: + self._import() + self._edit(1) + self._edit(2) + self._pair() + parts = self._export() + self._assert_names(parts) + self.assertEqual((self.summary['patched'], self.summary['rebuilt']), (0, 2)) + + def test_invalid_pair_fails_without_traceback_or_delivery(self) -> None: + self._import() + self._pair() + path = self.workspace / 'animations.json' + config = json.loads(path.read_text(encoding='utf-8')) + config['slides']['slide_02']['morph']['pairs'][self.key]['from'] = 'missing' + path.write_text(json.dumps(config), encoding='utf-8') + self._export(expected_exit=1) + self.assertIn('slide_01', self.stderr) + self.assertIn('missing', self.stderr) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_text_body_move.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_text_body_move.py new file mode 100644 index 00000000..84caca4e --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_roundtrip_text_body_move.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""PPT Master - Round-trip Text Body Translation Regression Tests + +Exercise native text retention through import, materialization, and conversion. + +Usage: + python3 -m unittest tests.test_roundtrip_text_body_move + +Examples: + Run the module from the scripts directory. + +Dependencies: + Standard library and the local PPTX/SVG converters. +""" + +from __future__ import annotations + +import base64 +import io +import re +import tempfile +import unittest +import zipfile +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch +from xml.etree import ElementTree as ET + +import authoring_roundtrip +from authoring_roundtrip import materialize_flat_authoring_roundtrip +from pptx_shapes import svg_text_fingerprint +from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg +from svg_to_pptx.drawingml.converter import SvgNativeConversionError, convert_svg_to_slide_shapes +from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg + + +NS = { + "s": "http://www.w3.org/2000/svg", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "a14": "http://schemas.microsoft.com/office/drawing/2010/main", + "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", +} + + +def _normalized(element: ET.Element) -> str: + return ET.canonicalize(ET.tostring(element, encoding="unicode"), rewrite_prefixes=True) + + +def _body(*, formula: bool, reversible: bool = False, multiline: bool = False) -> str: + # System colors are valid native math but outside the reverse-import subset. + color = '' if reversible else '' + math = ( + '' + f'{color}' + 'x' + ) if formula else "" + second_line = 'Second line' if multiline else "" + return ( + f'' + '' + '' + '' + '' + 'Bonjour ' + f'{math}{math}' + f'{second_line}' + ) + + +class RoundtripTextBodyMoveTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.workspace = self.root / "workspace" + + def _import( + self, *, formula: bool = True, reversible: bool = False, multiline: bool = False, transform: str = "", + ) -> None: + svg = ET.fromstring( + f'' + '' + '' + 'Bonjour xx' + ) + carrier = svg.find("s:g", NS) + if transform: + carrier.set("transform", transform) + metadata = ET.SubElement(carrier, f'{{{NS["s"]}}}metadata', { + "data-pptx-part": "txbody", + "data-pptx-encoding": "base64", + "data-pptx-text-sha256": svg_text_fingerprint(carrier), + }) + body = _body(formula=formula, reversible=reversible, multiline=multiline) + metadata.text = base64.b64encode(body.encode()).decode("ascii") + svg_path = self.root / "slide.svg" + svg_path.write_bytes(ET.tostring(svg)) + source = self.root / "source.pptx" + log = io.StringIO() + with redirect_stdout(log), redirect_stderr(log): + success = create_pptx_with_native_svg( + [svg_path], source, resource_root=self.root, pptx_structure="flat", + use_compat_mode=False, transition=None, workers=1, verbose=False, + ) + self.assertTrue(success, log.getvalue()) + convert_pptx_to_svg( + source, self.workspace, ConvertOptions(inheritance_mode="both", roundtrip=True), + ) + with zipfile.ZipFile(source) as archive: + self.original = ET.fromstring(archive.read("ppt/slides/slide1.xml")) + self.original = next( + shape for shape in self.original.findall(".//p:sp", NS) + if shape.find("p:nvSpPr/p:cNvPr", NS).get("id") == "7" + ) + self.path = self.workspace / "authoring-svg-flat" / "slide_01.svg" + self.svg = ET.parse(self.path).getroot() + self.carrier = next( + node for node in self.svg.iter() if node.get("data-pptx-source-ref") == "slide:7" + ) + if formula and not reversible: + self.assertFalse(any(node.get("data-pptx-inline-formula") for node in self.carrier.iter())) + + def _move_frame(self, index: int = 0, delta: float = 20) -> None: + frame = self.carrier.get("data-pptx-frame").split() + frame[index] = str(float(frame[index]) + delta) + self.carrier.set("data-pptx-frame", " ".join(frame)) + + def _move_coordinates(self, dx: float, dy: float) -> None: + self._move_frame(0, dx) + self._move_frame(1, dy) + x, y, width, height = map(float, self.carrier.get("data-pptx-frame").split()) + self.carrier.find('*[@data-pptx-part="geometry"]').set( + "d", f"M {x:g} {y:g} L {x + width:g} {y:g} L {x + width:g} {y + height:g} L {x:g} {y + height:g} Z", + ) + for node in self.carrier.iter(): + if node.tag not in {f'{{{NS["s"]}}}text', f'{{{NS["s"]}}}tspan'}: + continue + for axis, delta in (("x", dx), ("y", dy)): + if node.get(axis) is not None: + node.set(axis, " ".join( + f"{float(value) + delta:.12g}" for value in re.split(r"[\s,]+", node.get(axis).strip()) + )) + + def _materialize(self): + self.path.write_bytes(ET.tostring(self.svg)) + stderr = io.StringIO() + with redirect_stdout(io.StringIO()), redirect_stderr(stderr): + materialized = materialize_flat_authoring_roundtrip( + self.workspace, self.path.parent, self.workspace / "materialized", + ) + self.materialized = ET.parse(materialized.svg_files[0]).getroot() + self.warning_output = stderr.getvalue() + self.warnings = materialized.report["documents"][0]["warnings"] + return materialized.svg_files[0] + + def _convert(self) -> ET.Element: + path = self._materialize() + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + xml, *_ = convert_svg_to_slide_shapes(path, resource_root=self.workspace, text_flow="preserve") + return ET.fromstring(xml) + + def _export(self) -> ET.Element: + return next( + shape for shape in self._convert().findall(".//p:sp", NS) + if shape.find("p:nvSpPr/p:cNvPr", NS).get("id") == "7" + ) + + def _wrap_carrier(self, **attributes) -> None: + parent = next(node for node in self.svg.iter() if self.carrier in list(node)) + index = list(parent).index(self.carrier) + parent.remove(self.carrier) + wrapper = ET.Element(f'{{{NS["s"]}}}g', attributes) + wrapper.append(self.carrier) + parent.insert(index, wrapper) + + def _assert_preserved(self, result: ET.Element, *, dx: int = 20, dy: int = 0) -> None: + self.assertEqual(_normalized(result.find("p:txBody", NS)), + _normalized(self.original.find("p:txBody", NS))) + before = self.original.find("p:spPr/a:xfrm", NS) + after = result.find("p:spPr/a:xfrm", NS) + self.assertEqual(after.attrib, before.attrib) + self.assertEqual(after.find("a:ext", NS).attrib, before.find("a:ext", NS).attrib) + for axis, delta in (("x", dx), ("y", dy)): + self.assertEqual(int(after.find("a:off", NS).get(axis)), + int(before.find("a:off", NS).get(axis)) + delta * 9525) + self.assertEqual(self.warnings, []) + self.assertNotIn("native-formula-to-text", self.warning_output) + + def test_frame_translation_preserves_complete_txbody_and_math(self) -> None: + self._import() + self._move_frame() + self._move_frame(1, -10) + result = self._export() + self.assertEqual(len(result.findall(".//a14:m", NS)), 2) + self._assert_preserved(result, dy=-10) + + def test_coordinate_translation_preserves_complete_txbody_and_math(self) -> None: + self._import(multiline=True) + self._move_coordinates(20, -10) + result = self._export() + self.assertEqual(len(result.findall(".//a14:m", NS)), 2) + self._assert_preserved(result, dy=-10) + + def test_coordinate_translation_includes_explicit_tspan_positions(self) -> None: + self._import() + parse_svg = authoring_roundtrip._parse_svg + + def with_run_positions(path): + # Exercise equivalent imported projections with explicit run origins. + root = parse_svg(path) + for text in root.iter(f'{{{NS["s"]}}}text'): + span = text.find("s:tspan", NS) + if span is not None: + for axis in ("x", "y"): + if span.get(axis) is None: + span.set(axis, text.get(axis)) + return root + + self.svg = with_run_positions(self.path) + self.carrier = next(node for node in self.svg.iter() if node.get("data-pptx-source-ref") == "slide:7") + self._move_coordinates(20, -10) + with patch("authoring_roundtrip._parse_svg", side_effect=with_run_positions): + result = self._export() + self._assert_preserved(result, dy=-10) + + def test_moving_only_one_line_does_not_restore_and_warns(self) -> None: + self._import(multiline=True) + self._move_coordinates(20, -10) + line = next(node for node in self.carrier.iter() if node.text == "Second line") + line.set("x", str(float(self.carrier.find(".//s:text", NS).get("x")) + 5)) + self._materialize() + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + self.assertIn("reason=text-edited", self.warning_output) + + def test_object_translation_preserves_complete_txbody(self) -> None: + self._import() + self.carrier.set("transform", "translate(20 0)") + self._assert_preserved(self._export()) + + def test_ancestor_translation_preserves_complete_txbody(self) -> None: + self._import() + self._wrap_carrier(transform="translate(20 0)") + self._assert_preserved(self._export()) + + def test_translation_keeps_existing_rotation(self) -> None: + self._import(transform="rotate(15 330 160)") + self._wrap_carrier(transform="translate(20 0)") + self._assert_preserved(self._export()) + + def test_width_change_warns_and_does_not_restore(self) -> None: + self._import() + self._move_frame(2, 100) + result = self._export() + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + self.assertEqual(len(self.warnings), 1) + self.assertEqual(self.warnings[0]["code"], "native-formula-to-text") + self.assertEqual(self.warnings[0]["output_page"], 1) + self.assertEqual(self.warnings[0]["source_ref"], "slide:7") + for value in ("page=1", "slide_01.svg", "object=shape-7", "native formulas will become plain text", + "reason=size/transform-changed"): + self.assertIn(value, self.warning_output) + + def test_rotation_and_flip_warn_and_do_not_restore(self) -> None: + for transform in ("rotate(15 330 160)", "translate(660 0) scale(-1 1)"): + with self.subTest(transform=transform), tempfile.TemporaryDirectory() as directory: + self.root = Path(directory) + self.workspace = self.root / "workspace" + self._import() + self.carrier.set("transform", transform) + result = self._export() + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + self.assertEqual(len(self.warnings), 1) + + def test_scale_warns_and_does_not_restore_at_materialization(self) -> None: + self._import() + self._wrap_carrier(transform="scale(1.1 1)") + self._materialize() + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + self.assertEqual(len(self.warnings), 1) + + def test_edited_text_is_not_overwritten_after_move(self) -> None: + self._import() + self._move_frame() + run = next(node for node in self.carrier.iter() if node.text and "Bonjour" in node.text) + run.text = run.text.replace("Bonjour", "Bonsoir") + result = self._export() + text = "".join(node.text or "" for node in result.findall(".//a:t", NS)) + self.assertIn("Bonsoir", text) + self.assertNotIn("Bonjour", text) + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + self.assertIn("reason=text-edited", self.warning_output) + + def test_text_edit_without_move_warns(self) -> None: + self._import() + run = next(node for node in self.carrier.iter() if node.text and "Bonjour" in node.text) + run.text = run.text.replace("Bonjour", "Bonsoir") + result = self._export() + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + self.assertIn("reason=text-edited", self.warning_output) + + def test_edited_style_is_not_overwritten_after_move(self) -> None: + self._import() + self._move_frame() + self.carrier.find(".//s:text", NS).set("font-size", "32") + result = self._export() + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + self.assertIsNotNone(result.find('.//a:rPr[@sz="2400"]', NS)) + self.assertIn("reason=text-edited", self.warning_output) + + def test_local_text_translation_does_not_restore(self) -> None: + self._import() + self._move_frame() + self.carrier.find(".//s:text", NS).set("transform", "translate(5 0)") + self._export() + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + + def test_plain_text_translation_preserves_complete_txbody(self) -> None: + self._import(formula=False) + self._move_frame() + self._assert_preserved(self._export()) + + def test_plain_text_resize_has_no_formula_warning(self) -> None: + self._import(formula=False) + self._move_frame(2, 100) + self._export() + self.assertEqual(self.warnings, []) + self.assertNotIn("native formulas will become plain text", self.warning_output) + + def test_native_markers_rebuild_after_resize_without_loss_warning(self) -> None: + self._import(reversible=True) + self.assertEqual(sum(bool(node.get("data-pptx-inline-formula")) for node in self.carrier.iter()), 2) + self._move_frame(2, 100) + result = self._convert() + self.assertEqual(len(result.findall(".//a14:m", NS)), 2) + self.assertEqual(self.warnings, []) + + def test_ancestor_opacity_edit_is_not_overwritten_after_move(self) -> None: + self._import() + self._wrap_carrier(transform="translate(20 0)", opacity="0.5") + result = self._export() + self.assertIsNone(self.materialized.find('.//s:metadata[@data-pptx-part="txbody"]', NS)) + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + + def test_bad_payload_does_not_raise_during_loss_diagnosis(self) -> None: + for payload in ("not base64!", base64.b64encode(b"").decode("ascii")): + for edit in ("size", "text"): + with self.subTest(payload=payload, edit=edit), tempfile.TemporaryDirectory() as directory: + self.root = Path(directory) + self.workspace = self.root / "workspace" + self._import() + if edit == "size": + self._move_frame(2, 100) + else: + self.carrier.find(".//s:text/s:tspan", NS).text = "Edited" + parse_svg = authoring_roundtrip._parse_svg + corrupted = [] + + def corrupt_payload(path): + root = parse_svg(path) + metadata = root.find('.//s:metadata[@data-pptx-part="txbody"]', NS) + if metadata is not None: + metadata.text = payload + corrupted.append(path) + return root + + with patch("authoring_roundtrip._parse_svg", side_effect=corrupt_payload): + result = self._export() + self.assertTrue(corrupted) + self.assertEqual(len(result.findall(".//a14:m", NS)), 0) + self.assertEqual(self.warnings, []) + + def test_restored_bad_payload_keeps_converter_validation(self) -> None: + self._import() + self._move_frame() + parse_svg = authoring_roundtrip._parse_svg + corrupted = [] + + def corrupt_payload(path): + root = parse_svg(path) + metadata = root.find('.//s:metadata[@data-pptx-part="txbody"]', NS) + if metadata is not None: + metadata.text = "not base64!" + corrupted.append(path) + return root + + with patch("authoring_roundtrip._parse_svg", side_effect=corrupt_payload): + with self.assertRaisesRegex(SvgNativeConversionError, "Invalid txbody metadata"): + self._export() + self.assertTrue(corrupted) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_pptx.py index cc3c5277..5fb2304d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_pptx.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_pptx.py @@ -12,6 +12,7 @@ import unittest from contextlib import redirect_stdout from pathlib import Path +from lxml import etree from pptx import Presentation from pptx.oxml.xmlchemy import OxmlElement from pptx.util import Inches @@ -169,3 +170,17 @@ class PptxContentLossTests(unittest.TestCase): md = self._convert(prs) self.assertIn('| First
Second |', md) self.assertNotIn('

', md) + + def test_text_beside_office_math_survives_alternate_content(self) -> None: + prs, shape = self._presentation() + shape.text = 'ratio holds' + sp = shape._element + mc = 'http://schemas.openxmlformats.org/markup-compatibility/2006' + wrapper = etree.Element(f'{{{mc}}}AlternateContent', nsmap={'mc': mc}) + choice = etree.SubElement(wrapper, f'{{{mc}}}Choice', Requires='a14') + etree.SubElement(wrapper, f'{{{mc}}}Fallback') + parent = sp.getparent() + parent.replace(sp, wrapper) + choice.append(sp) + md = self._convert(prs) + self.assertIn('ratio holds', md) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_baseline.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_baseline.py index 2ed92d96..b450879c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_baseline.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_text_baseline.py @@ -34,6 +34,7 @@ from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: NS = { 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', 'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math', + 'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', } EMU_PER_PX = 9525 @@ -333,6 +334,8 @@ class TextBaselineTests(unittest.TestCase): '' 'Hgxpq' ) - shape = self._single_shape(slide) + shapes = slide.findall('.//mc:Choice/p:sp', NS) + self.assertEqual(len(shapes), 1) + shape = shapes[0] self._assert_top(shape, 166) self.assertIsNotNone(shape.find('.//m:oMath', NS))