Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
1 parent
64087ca43c
commit
949a960835
32 files changed
+2762
-162
No files matched your search
@@ -168,8 +168,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "cc8b722a24e9fb540e67c2d58a9ea3da534cd682",
|
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-21T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grill-me",
|
"id": "grill-me",
|
||||||
@@ -258,8 +258,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "72895219d44df111ee6ac81e4eac10d81a5e5cbf",
|
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-21T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-10-21T06:51:10Z"
|
"syncedAt": "2026-10-21T16:02:37Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "72895219d44df111ee6ac81e4eac10d81a5e5cbf",
|
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-21T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ppt-master",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "cc8b722a24e9fb540e67c2d58a9ea3da534cd682",
|
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-10-21T06:48:34Z"
|
"syncedAt": "2026-10-21T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -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 `<metadata type="application/json">` 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 — block metadata is truth**: one direct `<metadata type="application/json">` 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 `<image>`, `<foreignObject>`, 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 `<image>`, `<foreignObject>`, 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.
|
**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.
|
||||||
+122
-14
@@ -16,11 +16,15 @@ Dependencies:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -52,12 +56,13 @@ from svg_authoring_view import (
|
|||||||
SOURCE_PROXY_ATTRIBUTE,
|
SOURCE_PROXY_ATTRIBUTE,
|
||||||
SOURCE_PROXY_KIND,
|
SOURCE_PROXY_KIND,
|
||||||
_element_chain,
|
_element_chain,
|
||||||
|
_format_transform_number,
|
||||||
_materialize_adopted_context,
|
_materialize_adopted_context,
|
||||||
project_svg_batch,
|
project_svg_batch,
|
||||||
semantic_subtree_sha256,
|
semantic_subtree_sha256,
|
||||||
)
|
)
|
||||||
from svg_compatibility import normalize_single_child_group_filters
|
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"
|
SVG_NS = "http://www.w3.org/2000/svg"
|
||||||
@@ -1538,25 +1543,109 @@ def _restore_preserved_effect_metadata(
|
|||||||
item.set(name, value)
|
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(
|
def _restore_unchanged_text_body(
|
||||||
target: ET.Element,
|
target: ET.Element,
|
||||||
source: ET.Element,
|
source: ET.Element,
|
||||||
current: RefOccurrence,
|
current: RefOccurrence,
|
||||||
baseline: RefOccurrence,
|
baseline: RefOccurrence,
|
||||||
) -> None:
|
) -> str | None:
|
||||||
"""Recover native text only while its projected text and context agree."""
|
"""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)
|
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"):
|
if metadata is None:
|
||||||
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):
|
|
||||||
return
|
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)
|
restored = copy.deepcopy(metadata)
|
||||||
# Projection compacts text runs and paragraphs. Validate that projection
|
# Projection compacts text runs and paragraphs. Validate that projection
|
||||||
# above, then let the existing decoder validate the recovered payload.
|
# above, then let the existing decoder validate the recovered payload.
|
||||||
@@ -1798,6 +1887,8 @@ def _materialize_document(
|
|||||||
"picture relationship"
|
"picture relationship"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
warnings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def restore_nodes(
|
def restore_nodes(
|
||||||
element: ET.Element,
|
element: ET.Element,
|
||||||
source_dir: Path | None = None,
|
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:
|
if clone.get(name) is None and source.get(name) is not None:
|
||||||
clone.set(name, str(source.get(name)))
|
clone.set(name, str(source.get(name)))
|
||||||
_restore_preserved_effect_metadata(clone, source)
|
_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],
|
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 (
|
if (
|
||||||
_local_name(clone.tag) == "g" and source_ref is None
|
_local_name(clone.tag) == "g" and source_ref is None
|
||||||
and set(clone.attrib) <= {*INHERITABLE_ATTRS, "style", "opacity", "transform"}
|
and set(clone.attrib) <= {*INHERITABLE_ATTRS, "style", "opacity", "transform"}
|
||||||
@@ -2022,6 +2129,7 @@ def _materialize_document(
|
|||||||
"authored_unreferenced": authored_unreferenced,
|
"authored_unreferenced": authored_unreferenced,
|
||||||
"expanded_vector_uses": expanded_vector_uses,
|
"expanded_vector_uses": expanded_vector_uses,
|
||||||
"defs_changed": defs_changed,
|
"defs_changed": defs_changed,
|
||||||
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -376,9 +376,10 @@ replacement timing tree.
|
|||||||
|
|
||||||
### Native formula reverse import
|
### Native formula reverse import
|
||||||
|
|
||||||
The importer reconstructs formulas only from the closed OMML vocabulary owned
|
The importer reconstructs formulas from the native compiler's closed OMML
|
||||||
by the native formula compiler. One formula-only `a14:m > m:oMathPara` text
|
vocabulary after the import-only color normalization below. One formula-only
|
||||||
shape becomes a bounded `<g data-pptx-replace-with="formula">` with canonical
|
`a14:m > m:oMathPara` text shape becomes a bounded
|
||||||
|
`<g data-pptx-replace-with="formula">` with canonical
|
||||||
LaTeX JSON and a visible linear SVG preview when its carrier is an ungrouped,
|
LaTeX JSON and a visible linear SVG preview when its carrier is an ungrouped,
|
||||||
unstyled, unrotated rectangular formula shape. Carrier styling, effects,
|
unstyled, unrotated rectangular formula shape. Carrier styling, effects,
|
||||||
hyperlinks, or placeholder ownership force diagnosed fallback rather than
|
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
|
their surrounding text runs. The generated markers pass the same native-object
|
||||||
and inline-formula validators used by SVG-to-PPTX export.
|
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
|
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
|
pass the compiler's namespace, element, attribute, structure, size, and depth
|
||||||
gates, and the reconstructed LaTeX must compile again under the same profile.
|
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
|
If any formula in one text body falls outside that boundary, tolerant import
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ failure rather than a silent downgrade.
|
|||||||
|
|
||||||
### 3.2 Deterministic Morph Identity
|
### 3.2 Deterministic Morph Identity
|
||||||
|
|
||||||
The generated route may add an explicit `slides.<destination>.morph` block to
|
Generate and Edit Native PPTX may add an explicit `slides.<destination>.morph` block to
|
||||||
bind direct-root SVG groups across adjacent slides. The sidecar stable key is
|
bind direct-root SVG groups across adjacent slides. The sidecar stable key is
|
||||||
lowered to the same top-level `p:cNvPr@name="!!<key>"` on both final
|
lowered to the same top-level `p:cNvPr@name="!!<key>"` on both final
|
||||||
Slide-local objects. This does not create an Animation Pane row and does not
|
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.
|
undeclared shared `!!` name on a Morph edge.
|
||||||
|
|
||||||
Morph without an explicit pair block retains PowerPoint's automatic matching
|
Morph without an explicit pair block retains PowerPoint's automatic matching
|
||||||
behavior. Explicit pairing is generated-route authoring; source-preserving
|
behavior. Round-trip export preserves source names unless an explicit pair
|
||||||
round-trip export keeps existing object names and transition XML.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
content without rewriting the bundle. Unchanged slides and resources pass
|
||||||
through byte-for-byte. Object comparison includes effective ancestor transforms,
|
through byte-for-byte. Object comparison includes effective ancestor transforms,
|
||||||
opacity, inherited styles, and enclosing links; changing a proxy's context also
|
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,
|
fails. When an edited shape's text and effective text style still match the
|
||||||
the materializer restores its relationship-free source `txBody` through the
|
baseline, unchanged geometry or a pure translation retains its relationship-free
|
||||||
existing fingerprint/relationship checks, preserving fields, numbering,
|
source `txBody` through the existing fingerprint/relationship checks. This
|
||||||
autofit, language, and text effects. Edited text follows normal conversion.
|
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
|
A page edit rebuilds that output page; a changed
|
||||||
materialized or derived resource rebuilds every output page that references it.
|
materialized or derived resource rebuilds every output page that references it.
|
||||||
Changed materialized bytes must still match the source package part's extension
|
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
|
Matrices, multiline derivations, and other high-structure expressions remain
|
||||||
blocks. Formula replacement is always active, independent of
|
blocks. Formula replacement is always active, independent of
|
||||||
`--native-charts-and-tables`: export replaces only the registered SVG preview
|
`--native-charts-and-tables`: export replaces only the registered SVG preview
|
||||||
and writes editable PowerPoint 2010+ Office Math. It emits no formula PNG, media
|
and writes editable PowerPoint 2010+ Office Math in `mc:Choice Requires="a14"`.
|
||||||
relationship, or compatibility fallback, and makes no rendering/editability
|
The same `mc:AlternateContent` carries an `mc:Fallback` built from the SVG
|
||||||
promise for Keynote, WPS, LibreOffice, or another non-PowerPoint client.
|
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
|
```bash
|
||||||
python3 scripts/svg_to_pptx.py <project_path>
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
@@ -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.
|
**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.
|
**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`
|
## `visual_review.py`
|
||||||
|
|
||||||
|
|||||||
@@ -254,9 +254,17 @@ def _container_geometry(
|
|||||||
|
|
||||||
|
|
||||||
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
|
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] = []
|
containers: list[ET.Element] = []
|
||||||
for tag in ("p:sp", "p:graphicFrame"):
|
for tag in ("p:sp", "p:graphicFrame"):
|
||||||
for element in slide_root.findall(f".//{tag}", NS):
|
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):
|
if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS):
|
||||||
containers.append(element)
|
containers.append(element)
|
||||||
return containers
|
return containers
|
||||||
|
|||||||
@@ -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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.etree import ElementTree as ET
|
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(
|
def unreferenced_slide_relationships(
|
||||||
slide: ET.Element,
|
slide: ET.Element,
|
||||||
relationships: ET.Element,
|
relationships: ET.Element,
|
||||||
|
|||||||
+87
-6
@@ -1,9 +1,10 @@
|
|||||||
"""Import PPT Master-owned Office Math into canonical SVG formula markers.
|
"""Import PPT Master-owned Office Math into canonical SVG formula markers.
|
||||||
|
|
||||||
The reverse contract is deliberately narrow: accept only the closed OMML
|
The reverse contract is deliberately narrow: normalize supported DrawingML
|
||||||
vocabulary already validated by the native formula compiler, serialize that
|
colors, validate the compiler's closed OMML vocabulary, serialize that structure
|
||||||
structure to compiler-accepted LaTeX, and build a plain-text SVG preview.
|
to compiler-accepted LaTeX, and build a plain-text SVG preview.
|
||||||
Unknown third-party OMML remains outside this module's reconstruction claim.
|
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
|
from __future__ import annotations
|
||||||
@@ -22,6 +23,10 @@ from svg_to_pptx.native_objects.formula_profile import (
|
|||||||
ACCENT_COMMANDS,
|
ACCENT_COMMANDS,
|
||||||
NARY_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"
|
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
|
||||||
@@ -89,9 +94,15 @@ class FormulaImport:
|
|||||||
color: str
|
color: str
|
||||||
align: str
|
align: str
|
||||||
language: 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."""
|
"""Validate and reconstruct one ``a14:m`` formula zone."""
|
||||||
if math_zone.tag != _A14_M:
|
if math_zone.tag != _A14_M:
|
||||||
raise FormulaImportError("formula carrier must be one a14:m element")
|
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:
|
try:
|
||||||
canonical = validate_omml_fragment(
|
bounded = validate_omml_resource_limits(
|
||||||
ET.tostring(root, encoding="unicode", short_empty_elements=True)
|
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)
|
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)
|
latex = _serialize_root(validated_root)
|
||||||
if display:
|
if display:
|
||||||
compile_latex_to_omml(latex)
|
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()
|
preview = _preview_root(validated_root).strip()
|
||||||
if not latex.strip() or not preview:
|
if not latex.strip() or not preview:
|
||||||
raise FormulaImportError("formula reconstruction produced empty content")
|
raise FormulaImportError("formula reconstruction produced empty content")
|
||||||
font_size_px, color, language = _dominant_run_style(validated_root)
|
|
||||||
return FormulaImport(
|
return FormulaImport(
|
||||||
latex=latex,
|
latex=latex,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
@@ -130,9 +147,47 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
|
|||||||
color=color,
|
color=color,
|
||||||
align=_formula_alignment(validated_root),
|
align=_formula_alignment(validated_root),
|
||||||
language=language,
|
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:
|
def opaque_formula_preview(math_zone: ET.Element) -> str:
|
||||||
"""Return readable source text without claiming native reconstruction."""
|
"""Return readable source text without claiming native reconstruction."""
|
||||||
text = "".join(
|
text = "".join(
|
||||||
@@ -142,6 +197,23 @@ def opaque_formula_preview(math_zone: ET.Element) -> str:
|
|||||||
return text or "[unsupported formula]"
|
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:
|
def _serialize_root(root: ET.Element) -> str:
|
||||||
if root.tag == f"{_M}oMathPara":
|
if root.tag == f"{_M}oMathPara":
|
||||||
math = root.find(f"{_M}oMath")
|
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:
|
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)
|
name = _local_name(element)
|
||||||
if name == "r":
|
if name == "r":
|
||||||
return _serialize_run(element, alignment=alignment)
|
return _serialize_run(element, alignment=alignment)
|
||||||
|
|||||||
+4
-4
@@ -604,7 +604,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
|||||||
)
|
)
|
||||||
if carrier_error is not None:
|
if carrier_error is not None:
|
||||||
raise FormulaImportError(carrier_error)
|
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:
|
except FormulaImportError as exc:
|
||||||
_diagnose_formula_fallback(ctx, exc)
|
_diagnose_formula_fallback(ctx, exc)
|
||||||
block_formula_failed = True
|
block_formula_failed = True
|
||||||
@@ -1036,18 +1036,18 @@ def _prepare_inline_formula_resolver(
|
|||||||
elif not force_opaque:
|
elif not force_opaque:
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
try:
|
try:
|
||||||
imported[id(zone)] = import_formula(zone, display=False)
|
imported[id(zone)] = import_formula(zone, display=False, palette=ctx.palette)
|
||||||
except FormulaImportError as exc:
|
except FormulaImportError as exc:
|
||||||
failed = True
|
failed = True
|
||||||
_diagnose_formula_fallback(ctx, exc)
|
_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:
|
if failed:
|
||||||
return None, opaque_formula_preview(zone)
|
return None, opaque_formula_preview(zone)
|
||||||
item = imported.get(id(zone))
|
item = imported.get(id(zone))
|
||||||
if item is None:
|
if item is None:
|
||||||
return None, opaque_formula_preview(zone)
|
return None, opaque_formula_preview(zone)
|
||||||
return item.latex, item.preview
|
return item, item.preview
|
||||||
|
|
||||||
return _resolve
|
return _resolve
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -37,6 +37,7 @@ from .emu_units import (
|
|||||||
hundredths_pt_to_px, ooxml_bool,
|
hundredths_pt_to_px, ooxml_bool,
|
||||||
)
|
)
|
||||||
from .fill_to_svg import resolve_fill
|
from .fill_to_svg import resolve_fill
|
||||||
|
from .formula_import import FormulaImport
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -75,7 +76,7 @@ class TextRun:
|
|||||||
|
|
||||||
|
|
||||||
HyperlinkResolver = Callable[[str, str], str | None]
|
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]
|
TextDiagnosticSink = Callable[[str, str, str], None]
|
||||||
|
|
||||||
|
|
||||||
@@ -782,14 +783,17 @@ def _parse_paragraph(
|
|||||||
== "{http://schemas.microsoft.com/office/drawing/2010/main}m"
|
== "{http://schemas.microsoft.com/office/drawing/2010/main}m"
|
||||||
and inline_formula_resolver is not None
|
and inline_formula_resolver is not None
|
||||||
):
|
):
|
||||||
latex, preview = inline_formula_resolver(child)
|
formula, preview = inline_formula_resolver(child)
|
||||||
if preview:
|
if preview:
|
||||||
formula_rpr = next(
|
formula_rpr = next(
|
||||||
child.iter(f"{{{NS['a']}}}rPr"),
|
child.iter(f"{{{NS['a']}}}rPr"),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
run = resolved_run(preview, formula_rpr)
|
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)
|
para.runs.append(run)
|
||||||
|
|
||||||
return para
|
return para
|
||||||
@@ -1700,14 +1704,19 @@ def _emit_paragraph(
|
|||||||
else:
|
else:
|
||||||
spans.append(_run_tspan_markup(run))
|
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:
|
if metrics_baseline:
|
||||||
base_attrs += ' data-pptx-text-baseline="metrics-v1"'
|
base_attrs += ' data-pptx-text-baseline="metrics-v1"'
|
||||||
return f"<text{base_attrs}>{''.join(spans)}</text>"
|
return f"<text{base_attrs}>{''.join(spans)}</text>"
|
||||||
|
|
||||||
|
|
||||||
def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
||||||
text_anchor: str) -> str:
|
text_anchor: str, *, inherit_opacity: bool = True) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
f'x="{fmt_num(x)}"',
|
f'x="{fmt_num(x)}"',
|
||||||
f'y="{fmt_num(y, 8)}"',
|
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-family="{run.font_family}"')
|
||||||
parts.append(f'font-size="{fmt_num(run.font_size_px)}"')
|
parts.append(f'font-size="{fmt_num(run.font_size_px)}"')
|
||||||
parts.append(f'fill="{run.fill}"')
|
parts.append(f'fill="{run.fill}"')
|
||||||
if run.fill_opacity < 1.0:
|
if inherit_opacity and run.fill_opacity < 1.0:
|
||||||
parts.append(
|
parts.append(
|
||||||
f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
|
f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ EMU_PER_INCH = 914400
|
|||||||
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||||
PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||||
RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
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"
|
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
|
||||||
OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
|
OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
|
||||||
IMAGE_EXT_BY_CONTENT_TYPE = {
|
IMAGE_EXT_BY_CONTENT_TYPE = {
|
||||||
@@ -186,6 +187,34 @@ def _safe_position(shape: object, attr: str) -> int:
|
|||||||
return 0
|
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]:
|
def iter_leaf_shapes(shapes: object) -> list[LeafShape]:
|
||||||
"""Return a flattened, reading-order list of shapes."""
|
"""Return a flattened, reading-order list of shapes."""
|
||||||
items: list[LeafShape] = []
|
items: list[LeafShape] = []
|
||||||
@@ -1278,6 +1307,7 @@ def convert_presentation_to_markdown(
|
|||||||
}
|
}
|
||||||
emitted_diagram_ids: set[str] = set()
|
emitted_diagram_ids: set[str] = set()
|
||||||
emitted_chart_relationship_ids: set[str] = set()
|
emitted_chart_relationship_ids: set[str] = set()
|
||||||
|
_expose_math_choice_shapes(slide)
|
||||||
for item in iter_leaf_shapes(slide.shapes):
|
for item in iter_leaf_shapes(slide.shapes):
|
||||||
shape = item.shape
|
shape = item.shape
|
||||||
|
|
||||||
|
|||||||
+4
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from pptx_ooxml.package import PackagePartRegistry
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .theme_colors import ThemeColorSpec
|
from .theme_colors import ThemeColorSpec
|
||||||
from .theme_fonts import ThemeFontSpec
|
from .theme_fonts import ThemeFontSpec
|
||||||
@@ -84,6 +86,7 @@ class ConvertContext:
|
|||||||
rel_entries: list[dict[str, str]] = field(default_factory=list)
|
rel_entries: list[dict[str, str]] = field(default_factory=list)
|
||||||
package_files: dict[str, bytes] = field(default_factory=dict)
|
package_files: dict[str, bytes] = field(default_factory=dict)
|
||||||
content_type_overrides: dict[str, str] = 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
|
rel_id_counter: int = 2 # rId1 reserved for slideLayout
|
||||||
svg_dir: Path | None = None
|
svg_dir: Path | None = None
|
||||||
# Explicit project boundary for deterministic resource resolution. Public
|
# Explicit project boundary for deterministic resource resolution. Public
|
||||||
@@ -266,6 +269,7 @@ class ConvertContext:
|
|||||||
rel_entries=self.rel_entries,
|
rel_entries=self.rel_entries,
|
||||||
package_files=self.package_files,
|
package_files=self.package_files,
|
||||||
content_type_overrides=self.content_type_overrides,
|
content_type_overrides=self.content_type_overrides,
|
||||||
|
package_parts=self.package_parts,
|
||||||
rel_id_counter=self.rel_id_counter,
|
rel_id_counter=self.rel_id_counter,
|
||||||
svg_dir=self.svg_dir,
|
svg_dir=self.svg_dir,
|
||||||
resource_root=self.resource_root,
|
resource_root=self.resource_root,
|
||||||
|
|||||||
+75
-1
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from native_payloads import NativePayloadError, hydrate_native_payload_refs
|
from native_payloads import NativePayloadError, hydrate_native_payload_refs
|
||||||
|
from pptx_ooxml.package import PackagePartRegistry
|
||||||
from hyperlink_contract import (
|
from hyperlink_contract import (
|
||||||
SHAPE_HYPERLINK_ATTR,
|
SHAPE_HYPERLINK_ATTR,
|
||||||
project_hyperlink_errors,
|
project_hyperlink_errors,
|
||||||
@@ -125,8 +126,11 @@ from ..native_objects import (
|
|||||||
native_replacement_kind,
|
native_replacement_kind,
|
||||||
native_marker_transform,
|
native_marker_transform,
|
||||||
require_fresh_native_fallback,
|
require_fresh_native_fallback,
|
||||||
|
reserve_source_chart_parts,
|
||||||
snapshot_native_fallback_freshness,
|
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 ..native_objects.marker_status import native_marker_status_errors
|
||||||
from ..semantic_markers import is_static_page_frame
|
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'<root {_TXBODY_PREFIXES}>{body}</root>')
|
||||||
|
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'<p:cNvPr id="\d+"',
|
||||||
|
f'<p:cNvPr id="{ctx.next_id()}"',
|
||||||
|
geometry.xml,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
carrier_xml = re.sub(r'<p:ph\b[^>]*/>', '', 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:
|
def _make_semantic_geometry_carrier_visible(element: ET.Element) -> None:
|
||||||
"""Remove authoring-only hiding from a semantic geometry carrier clone."""
|
"""Remove authoring-only hiding from a semantic geometry carrier clone."""
|
||||||
for name in ('visibility', 'display', 'pointer-events'):
|
for name in ('visibility', 'display', 'pointer-events'):
|
||||||
@@ -847,7 +905,12 @@ def _convert_semantic_shape(
|
|||||||
'Semantic shape geometry carrier produced no native shape'
|
'Semantic shape geometry carrier produced no native shape'
|
||||||
)
|
)
|
||||||
text_body = _semantic_shape_text_body(shape, ctx)
|
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(
|
def _restore_placeholder_sp_pr(
|
||||||
@@ -1313,6 +1376,11 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
native_text,
|
native_text,
|
||||||
)
|
)
|
||||||
restored = _restore_placeholder_sp_pr(restored, elem)
|
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:
|
if should_animate_group and elem_id:
|
||||||
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
|
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
|
||||||
if shape_match:
|
if shape_match:
|
||||||
@@ -1969,6 +2037,7 @@ def convert_svg_to_slide_shapes(
|
|||||||
promote_background: bool = True,
|
promote_background: bool = True,
|
||||||
text_flow: str | None = None,
|
text_flow: str | None = None,
|
||||||
dangerous_nonconforming_export: bool = False,
|
dangerous_nonconforming_export: bool = False,
|
||||||
|
package_parts: PackagePartRegistry | None = None,
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
str,
|
str,
|
||||||
dict[str, bytes],
|
dict[str, bytes],
|
||||||
@@ -2017,6 +2086,8 @@ def convert_svg_to_slide_shapes(
|
|||||||
Parsing, resource, conversion, and package failures remain blocking.
|
Parsing, resource, conversion, and package failures remain blocking.
|
||||||
resource_root: Explicit project boundary for local images and icons.
|
resource_root: Explicit project boundary for local images and icons.
|
||||||
Omit only for direct library calls that require legacy inference.
|
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:
|
Returns:
|
||||||
(slide_xml, media_files, rel_entries, anim_targets,
|
(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_font_spec=theme_font_spec,
|
||||||
theme_color_spec=theme_color_spec,
|
theme_color_spec=theme_color_spec,
|
||||||
primary_language=primary_language,
|
primary_language=primary_language,
|
||||||
|
package_parts=package_parts if package_parts is not None else PackagePartRegistry(),
|
||||||
inherited_styles=_extract_inheritable_styles(root),
|
inherited_styles=_extract_inheritable_styles(root),
|
||||||
opacity_multiplier=1.0 if root_opacity is None else root_opacity,
|
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},
|
parent_by_id={id(child): parent for parent in root.iter() for child in parent},
|
||||||
text_font_sizes=text_font_sizes,
|
text_font_sizes=text_font_sizes,
|
||||||
text_letter_spacings=text_letter_spacings,
|
text_letter_spacings=text_letter_spacings,
|
||||||
)
|
)
|
||||||
|
if native_objects:
|
||||||
|
reserve_source_chart_parts(root, ctx)
|
||||||
shapes: list[str] = []
|
shapes: list[str] = []
|
||||||
converted = 0
|
converted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|||||||
+76
-45
@@ -3085,6 +3085,15 @@ def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]:
|
|||||||
return True, a < 0, d < 0
|
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(
|
def _build_run_xml(
|
||||||
run: dict[str, Any],
|
run: dict[str, Any],
|
||||||
default_fonts: dict[str, str],
|
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)
|
shape_id = _claim_element_shape_id(elem, ctx)
|
||||||
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
||||||
|
|
||||||
if paragraph_runs is not None:
|
def _paragraphs_xml(formula_previews: bool) -> str:
|
||||||
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
# The MCE Fallback copy writes each inline formula as its SVG preview
|
||||||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
# text so a reader without a14 math still shows the formula.
|
||||||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
def _runs_xml(line_runs: list[dict[str, Any]]) -> str:
|
||||||
paragraph_xml_chunks = []
|
return '\n'.join(
|
||||||
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
_build_run_xml(
|
||||||
spc_bef_xml = ''
|
_inline_formula_preview_run(r) if formula_previews else r,
|
||||||
if extra_px > 0:
|
fonts,
|
||||||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
ctx,
|
||||||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
text_effect_xml,
|
||||||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
)
|
||||||
first_text_run = next(
|
for r in line_runs
|
||||||
(run for run in line if not run.get('_line_break')),
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
effective_line_spacing = (
|
|
||||||
''
|
if paragraph_runs is not None:
|
||||||
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
|
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
||||||
else ln_spc_xml
|
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||||
)
|
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||||||
p_pr_xml = _paragraph_pr_xml(
|
paragraph_xml_chunks = []
|
||||||
algn=algn,
|
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
||||||
font_size=(
|
spc_bef_xml = ''
|
||||||
float(first_text_run.get('font_size', font_size))
|
if extra_px > 0:
|
||||||
if first_text_run is not None
|
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||||
else font_size
|
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||||||
),
|
runs_inner = _runs_xml(line)
|
||||||
body_xml=f'{effective_line_spacing}{spc_bef_xml}',
|
first_text_run = next(
|
||||||
bullet=bullet,
|
(run for run in line if not run.get('_line_break')),
|
||||||
ctx=ctx,
|
None,
|
||||||
rtl=text_uses_rtl(
|
)
|
||||||
''.join(str(run.get('text', '')) for run in line),
|
effective_line_spacing = (
|
||||||
ctx.primary_language,
|
''
|
||||||
),
|
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
|
||||||
)
|
else ln_spc_xml
|
||||||
paragraph_xml_chunks.append(
|
)
|
||||||
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
p_pr_xml = _paragraph_pr_xml(
|
||||||
)
|
algn=algn,
|
||||||
paragraphs_xml = '\n'.join(paragraph_xml_chunks)
|
font_size=(
|
||||||
else:
|
float(first_text_run.get('font_size', font_size))
|
||||||
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
|
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'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
||||||
|
)
|
||||||
|
return '\n'.join(paragraph_xml_chunks)
|
||||||
|
|
||||||
|
runs_xml = _runs_xml(runs)
|
||||||
p_pr_xml = _paragraph_pr_xml(
|
p_pr_xml = _paragraph_pr_xml(
|
||||||
algn=algn,
|
algn=algn,
|
||||||
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
|
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,
|
ctx=ctx,
|
||||||
rtl=text_uses_rtl(full_text, ctx.primary_language),
|
rtl=text_uses_rtl(full_text, ctx.primary_language),
|
||||||
)
|
)
|
||||||
paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
|
return f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
|
||||||
|
|
||||||
|
paragraphs_xml = _paragraphs_xml(False)
|
||||||
|
|
||||||
off_x = px_to_emu(box_x)
|
off_x = px_to_emu(box_x)
|
||||||
off_y = px_to_emu(box_y)
|
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<a:spAutoFit/>\n</a:bodyPr>'
|
'anchor="t" anchorCtr="0">\n<a:spAutoFit/>\n</a:bodyPr>'
|
||||||
)
|
)
|
||||||
|
|
||||||
shape_xml = f'''<p:sp>
|
def _shape_xml(sp_id: int, sp_paragraphs_xml: str) -> str:
|
||||||
|
return f'''<p:sp>
|
||||||
<p:nvSpPr>
|
<p:nvSpPr>
|
||||||
<p:cNvPr id="{shape_id}" name="{_xml_escape(shape_display_name(elem, f'TextBox {shape_id}'))}"/>
|
<p:cNvPr id="{sp_id}" name="{_xml_escape(shape_display_name(elem, f'TextBox {sp_id}'))}"/>
|
||||||
<p:cNvSpPr txBox="1"/><p:nvPr/>
|
<p:cNvSpPr txBox="1"/><p:nvPr/>
|
||||||
</p:nvSpPr>
|
</p:nvSpPr>
|
||||||
<p:spPr>
|
<p:spPr>
|
||||||
@@ -3718,12 +3744,17 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
<p:txBody>
|
<p:txBody>
|
||||||
{body_pr_xml}
|
{body_pr_xml}
|
||||||
<a:lstStyle/>
|
<a:lstStyle/>
|
||||||
{paragraphs_xml}
|
{sp_paragraphs_xml}
|
||||||
</p:txBody>
|
</p:txBody>
|
||||||
</p:sp>'''
|
</p:sp>'''
|
||||||
|
|
||||||
|
shape_xml = _shape_xml(shape_id, paragraphs_xml)
|
||||||
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs):
|
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs):
|
||||||
from ..native_objects.inline_formula import wrap_inline_formula_shape
|
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(
|
return ShapeResult(
|
||||||
xml=shape_xml,
|
xml=shape_xml,
|
||||||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||||||
|
|||||||
+68
-30
@@ -339,6 +339,23 @@ def _grow_chart_frame_for_axis_labels(
|
|||||||
return off_x, off_y, ext_cx, ext_cy
|
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:
|
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
|
||||||
source_package = _decode_source_chart_package(payload)
|
source_package = _decode_source_chart_package(payload)
|
||||||
chart_data = None if source_package is not None else _chart_data(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,
|
axis_font_px=text_sizes["axis"] / 75,
|
||||||
)
|
)
|
||||||
|
|
||||||
shape_id = (
|
source_id = elem.get("data-pptx-shape-id")
|
||||||
ctx.claim_shape_id(
|
shape_id = ctx.claim_shape_id(
|
||||||
elem.get("data-pptx-shape-id"),
|
source_id,
|
||||||
elem.get("data-pptx-shape-scope"),
|
elem.get("data-pptx-shape-scope"),
|
||||||
)
|
|
||||||
if source_package is not None
|
|
||||||
else ctx.next_id()
|
|
||||||
)
|
)
|
||||||
rel_id = ctx.next_rel_id()
|
rel_id = ctx.next_rel_id()
|
||||||
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
|
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
|
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_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
|
||||||
workbook_part = f"ppt/embeddings/{workbook_name}"
|
workbook_part = f"ppt/embeddings/{workbook_name}"
|
||||||
if source_package is not None:
|
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,
|
"type": CHART_REL_TYPE,
|
||||||
"target": posixpath.relpath(chart_part, "ppt/slides"),
|
"target": posixpath.relpath(chart_part, "ppt/slides"),
|
||||||
})
|
})
|
||||||
for part_name, part_payload in source_parts.items():
|
parts = source_parts
|
||||||
existing = ctx.package_files.get(part_name)
|
content_types = source_content_types
|
||||||
if existing is not None and existing != part_payload:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Native PPTX chart source package part collision: {part_name}"
|
|
||||||
)
|
|
||||||
ctx.package_files[part_name] = part_payload
|
|
||||||
ctx.content_type_overrides.update(source_content_types)
|
|
||||||
elif chart_data is not None and chart_data["kind"] == "chartex":
|
elif chart_data is not None and chart_data["kind"] == "chartex":
|
||||||
chart_name = f"chartEx{part_index}.xml"
|
chart_name = f"chartEx{part_index}.xml"
|
||||||
style_name = f"style{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,
|
"type": CHARTEX_REL_TYPE,
|
||||||
"target": f"../charts/{chart_name}",
|
"target": f"../charts/{chart_name}",
|
||||||
})
|
})
|
||||||
ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
|
parts[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
|
||||||
ctx.package_files[chart_rels_part] = _chart_ex_rels_xml(
|
parts[chart_rels_part] = _chart_ex_rels_xml(
|
||||||
f"../embeddings/{workbook_name}",
|
f"../embeddings/{workbook_name}",
|
||||||
style_name,
|
style_name,
|
||||||
colors_name,
|
colors_name,
|
||||||
)
|
)
|
||||||
ctx.package_files[style_part] = _chart_ex_style_xml()
|
parts[style_part] = _chart_ex_style_xml()
|
||||||
ctx.package_files[colors_part] = _chart_ex_colors_xml(payload)
|
parts[colors_part] = _chart_ex_colors_xml(payload)
|
||||||
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
|
parts[workbook_part] = _minimal_chart_ex_workbook(chart_data)
|
||||||
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
|
content_types[chart_part] = CHARTEX_CONTENT_TYPE
|
||||||
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
|
content_types[style_part] = CHART_STYLE_CONTENT_TYPE
|
||||||
ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
|
content_types[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
|
||||||
else:
|
else:
|
||||||
chart_name = f"chart{part_index}.xml"
|
chart_name = f"chart{part_index}.xml"
|
||||||
chart_part = f"ppt/charts/{chart_name}"
|
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,
|
"type": CHART_REL_TYPE,
|
||||||
"target": f"../charts/{chart_name}",
|
"target": f"../charts/{chart_name}",
|
||||||
})
|
})
|
||||||
ctx.package_files[chart_part] = _chart_xml(
|
parts[chart_part] = _chart_xml(
|
||||||
elem,
|
elem,
|
||||||
payload,
|
payload,
|
||||||
chart_rels_id="rId1",
|
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,
|
primary_language=ctx.primary_language,
|
||||||
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
|
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
|
assert chart_data is not None
|
||||||
if chart_data["kind"] == "xy":
|
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:
|
else:
|
||||||
ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data)
|
parts[workbook_part] = _minimal_category_chart_workbook(chart_data)
|
||||||
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
|
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 = (
|
chart_frame_xml = (
|
||||||
_source_chart_frame_xml(
|
_source_chart_frame_xml(
|
||||||
source_frame,
|
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
|
x0, y0, x1, y1 = companion.bounds_emu
|
||||||
min_x, min_y = min(min_x, x0), min(min_y, y0)
|
min_x, min_y = min(min_x, x0), min(min_y, y0)
|
||||||
max_x, max_y = max(max_x, x1), max(max_y, y1)
|
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
|
group_w, group_h = max_x - min_x, max_y - min_y
|
||||||
validate_ooxml_xfrm(min_x, min_y, group_w, group_h)
|
validate_ooxml_xfrm(min_x, min_y, group_w, group_h)
|
||||||
group_id = ctx.next_id()
|
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 marker owns one selectable/animatable unit. Identity mapping keeps
|
||||||
# the chart and labels at their already resolved slide coordinates.
|
# the chart and labels at their already resolved slide coordinates.
|
||||||
xml = f'''<p:grpSp>
|
xml = f'''<p:grpSp>
|
||||||
|
|||||||
+54
-3
@@ -196,20 +196,71 @@ def _styled_omml(spec: FormulaSpec) -> str:
|
|||||||
return serialize_styled_formula_omml(root)
|
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'''<p:grpSp>
|
||||||
|
<p:nvGrpSpPr>
|
||||||
|
<p:cNvPr id="{ctx.next_id()}" name="{name}"/>
|
||||||
|
<p:cNvGrpSpPr/>
|
||||||
|
<p:nvPr/>
|
||||||
|
</p:nvGrpSpPr>
|
||||||
|
<p:grpSpPr>
|
||||||
|
<a:xfrm><a:off x="{min_x}" y="{min_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/>
|
||||||
|
<a:chOff x="{min_x}" y="{min_y}"/><a:chExt cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||||||
|
</p:grpSpPr>
|
||||||
|
{shapes_xml}
|
||||||
|
</p:grpSp>'''
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
def build_native_formula(
|
||||||
elem: ET.Element,
|
elem: ET.Element,
|
||||||
ctx: ConvertContext,
|
ctx: ConvertContext,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
spec: FormulaSpec | None = None,
|
spec: FormulaSpec | None = None,
|
||||||
) -> ShapeResult:
|
) -> 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)
|
formula_spec = spec or validate_formula_payload(payload, ctx=ctx)
|
||||||
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, 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(
|
name = _xml_escape(
|
||||||
str(payload.get("name") or elem.get("id") or f"Formula {shape_id}")
|
str(payload.get("name") or elem.get("id") or f"Formula {shape_id}")
|
||||||
)
|
)
|
||||||
omml = _styled_omml(formula_spec)
|
omml = _styled_omml(formula_spec)
|
||||||
|
fallback_xml = _preview_fallback_xml(elem, ctx, name)
|
||||||
|
fallback = f"\n<mc:Fallback>\n{fallback_xml}\n</mc:Fallback>" if fallback_xml else ""
|
||||||
|
|
||||||
xml = f'''<mc:AlternateContent xmlns:mc="{MC_NS}">
|
xml = f'''<mc:AlternateContent xmlns:mc="{MC_NS}">
|
||||||
<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">
|
<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">
|
||||||
@@ -235,7 +286,7 @@ def build_native_formula(
|
|||||||
</a:p>
|
</a:p>
|
||||||
</p:txBody>
|
</p:txBody>
|
||||||
</p:sp>
|
</p:sp>
|
||||||
</mc:Choice>
|
</mc:Choice>{fallback}
|
||||||
</mc:AlternateContent>'''
|
</mc:AlternateContent>'''
|
||||||
return ShapeResult(
|
return ShapeResult(
|
||||||
xml=xml,
|
xml=xml,
|
||||||
|
|||||||
+3
-2
@@ -292,13 +292,14 @@ def build_inline_formula_xml(latex: str, run_properties_xml: str) -> str:
|
|||||||
return f"<a14:m>{styled}</a14:m>"
|
return f"<a14:m>{styled}</a14:m>"
|
||||||
|
|
||||||
|
|
||||||
def wrap_inline_formula_shape(shape_xml: str) -> str:
|
def wrap_inline_formula_shape(shape_xml: str, fallback_xml: str) -> str:
|
||||||
"""Wrap a text shape containing ``a14:m`` in its required MCE Choice."""
|
"""Wrap an ``a14:m`` text shape in its MCE Choice plus preview-text Fallback."""
|
||||||
return (
|
return (
|
||||||
f'<mc:AlternateContent xmlns:mc="{MC_NS}">'
|
f'<mc:AlternateContent xmlns:mc="{MC_NS}">'
|
||||||
f'<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">'
|
f'<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">'
|
||||||
f"{shape_xml}"
|
f"{shape_xml}"
|
||||||
"</mc:Choice>"
|
"</mc:Choice>"
|
||||||
|
f"<mc:Fallback>{fallback_xml}</mc:Fallback>"
|
||||||
"</mc:AlternateContent>"
|
"</mc:AlternateContent>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+128
-20
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
from xml.parsers import expat
|
||||||
from xml.sax.saxutils import escape, quoteattr
|
from xml.sax.saxutils import escape, quoteattr
|
||||||
|
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
@@ -71,7 +72,7 @@ from pptx_opc_validation import (
|
|||||||
)
|
)
|
||||||
from pptx_workspace import WorkspaceResourceSpec
|
from pptx_workspace import WorkspaceResourceSpec
|
||||||
from pptx_ooxml.clone import clone_presentation_slides
|
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 language_tags import language_uses_rtl, office_language_tag
|
||||||
from hyperlink_contract import (
|
from hyperlink_contract import (
|
||||||
HYPERLINK_REL_TYPE,
|
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(
|
def _apply_morph_shape_names(
|
||||||
extract_dir: Path,
|
extract_dir: Path,
|
||||||
pairs: tuple[MorphPair, ...],
|
pairs: tuple[MorphPair, ...],
|
||||||
slide_numbers: dict[str, int],
|
slide_numbers: dict[str, int],
|
||||||
shape_ids: dict[tuple[str, str], int],
|
shape_ids: dict[tuple[str, str], int],
|
||||||
|
*,
|
||||||
|
preserved_slides: set[int],
|
||||||
) -> dict[int, dict[str, str]]:
|
) -> dict[int, dict[str, str]]:
|
||||||
"""Write forced-Morph names after all structure transformations finish."""
|
"""Write forced-Morph names after all structure transformations finish."""
|
||||||
assignments: dict[int, dict[str, tuple[str, str]]] = {}
|
assignments: dict[int, dict[str, tuple[str, str]]] = {}
|
||||||
@@ -621,14 +705,21 @@ def _apply_morph_shape_names(
|
|||||||
(pair.destination_slide, pair.destination_group_id),
|
(pair.destination_slide, pair.destination_group_id),
|
||||||
):
|
):
|
||||||
slide_number = slide_numbers[slide_name]
|
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, {})
|
slide_assignments = assignments.setdefault(slide_number, {})
|
||||||
previous = slide_assignments.setdefault(
|
previous = slide_assignments.setdefault(
|
||||||
shape_id,
|
shape_id,
|
||||||
(pair.shape_name, group_id),
|
(pair.shape_name, group_id),
|
||||||
)
|
)
|
||||||
if previous[0] != pair.shape_name:
|
if previous[0] != pair.shape_name:
|
||||||
raise RuntimeError(
|
raise TemplateStructureError(
|
||||||
f'Morph target "{slide_name}/{group_id}" resolves to shape '
|
f'Morph target "{slide_name}/{group_id}" resolves to shape '
|
||||||
f'{shape_id} with conflicting names "{previous[0]}" and '
|
f'{shape_id} with conflicting names "{previous[0]}" and '
|
||||||
f'"{pair.shape_name}"'
|
f'"{pair.shape_name}"'
|
||||||
@@ -636,7 +727,7 @@ def _apply_morph_shape_names(
|
|||||||
slide_names = names_by_slide.setdefault(slide_number, {})
|
slide_names = names_by_slide.setdefault(slide_number, {})
|
||||||
previous_group = slide_names.setdefault(pair.shape_name, group_id)
|
previous_group = slide_names.setdefault(pair.shape_name, group_id)
|
||||||
if previous_group != group_id:
|
if previous_group != group_id:
|
||||||
raise RuntimeError(
|
raise TemplateStructureError(
|
||||||
f'Morph name "{pair.shape_name}" maps to multiple objects '
|
f'Morph name "{pair.shape_name}" maps to multiple objects '
|
||||||
f'on slide "{slide_name}"'
|
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
|
c_nv_pr.get("name") if c_nv_pr is not None else None
|
||||||
)
|
)
|
||||||
if existing_name in desired_names:
|
if existing_name in desired_names:
|
||||||
raise RuntimeError(
|
raise TemplateStructureError(
|
||||||
f'Morph name "{existing_name}" already belongs to an '
|
f'Morph name "{existing_name}" already belongs to an '
|
||||||
f'unmapped object on slide {slide_number}'
|
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():
|
for shape_id, (shape_name, group_id) in slide_assignments.items():
|
||||||
shape = top_level_shapes.get(shape_id)
|
shape = top_level_shapes.get(shape_id)
|
||||||
if shape is None:
|
if shape is None:
|
||||||
raise RuntimeError(
|
raise TemplateStructureError(
|
||||||
f'Morph target "{group_id}" no longer resolves to a '
|
f'Morph target "{group_id}" no longer resolves to a '
|
||||||
f'Slide-local shape on slide {slide_number}'
|
f'Slide-local shape on slide {slide_number}'
|
||||||
)
|
)
|
||||||
_set_shape_name(shape, shape_name)
|
_set_shape_name(shape, shape_name)
|
||||||
trace_names.setdefault(slide_number, {})[group_id] = 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
|
return trace_names
|
||||||
|
|
||||||
|
|
||||||
@@ -6892,6 +6988,7 @@ def create_pptx_with_native_svg(
|
|||||||
set(),
|
set(),
|
||||||
).add(pair.destination_group_id)
|
).add(pair.destination_group_id)
|
||||||
morph_shape_ids: dict[tuple[str, str], int] = {}
|
morph_shape_ids: dict[tuple[str, str], int] = {}
|
||||||
|
morph_preserved_slides: set[int] = set()
|
||||||
if definition_svg_files and pptx_structure != "structured":
|
if definition_svg_files and pptx_structure != "structured":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"unselected Layout prototypes require pptx_structure='structured'"
|
"unselected Layout prototypes require pptx_structure='structured'"
|
||||||
@@ -7223,6 +7320,7 @@ def create_pptx_with_native_svg(
|
|||||||
image_exts_used: set[str] = set()
|
image_exts_used: set[str] = set()
|
||||||
package_exts_used: set[str] = set()
|
package_exts_used: set[str] = set()
|
||||||
package_content_overrides: dict[str, str] = {}
|
package_content_overrides: dict[str, str] = {}
|
||||||
|
package_parts = PackagePartRegistry(extract_dir, _content_type_overrides(extract_dir))
|
||||||
notes_slides_created: set[int] = set()
|
notes_slides_created: set[int] = set()
|
||||||
notes_master_parts_used: set[str] = set()
|
notes_master_parts_used: set[str] = set()
|
||||||
notes_master_theme_parts_created: set[str] = set()
|
notes_master_theme_parts_created: set[str] = set()
|
||||||
@@ -7304,6 +7402,10 @@ def create_pptx_with_native_svg(
|
|||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
"Internal Layout definitions cannot use source-slide passthrough"
|
"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:
|
if verbose:
|
||||||
print(
|
print(
|
||||||
f" {progress_label} {svg_path.name} "
|
f" {progress_label} {svg_path.name} "
|
||||||
@@ -7421,21 +7523,25 @@ def create_pptx_with_native_svg(
|
|||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
slide_patch is not None
|
slide_patch is not None
|
||||||
and slide_patch.notes_changed
|
|
||||||
and not slide_patch.visual_changed
|
and not slide_patch.visual_changed
|
||||||
and not slide_patch.motion_changed
|
and not slide_patch.motion_changed
|
||||||
|
and (slide_patch.notes_changed or morph_group_overrides_by_slide.get(svg_path.stem))
|
||||||
):
|
):
|
||||||
notes_content = (
|
morph_shape_ids.update(_roundtrip_morph_shape_ids(
|
||||||
notes.get(svg_path.stem, '') if notes else ''
|
svg_path, morph_group_overrides_by_slide.get(svg_path.stem, set()),
|
||||||
)
|
))
|
||||||
notes_master = _apply_slide_notes(
|
morph_preserved_slides.add(slide_num)
|
||||||
extract_dir,
|
notes_master = None
|
||||||
rels_path,
|
if slide_patch.notes_changed:
|
||||||
slide_num,
|
notes_content = notes.get(svg_path.stem, '') if notes else ''
|
||||||
notes_content,
|
notes_master = _apply_slide_notes(
|
||||||
primary_language,
|
extract_dir,
|
||||||
enable_notes=enable_notes,
|
rels_path,
|
||||||
)
|
slide_num,
|
||||||
|
notes_content,
|
||||||
|
primary_language,
|
||||||
|
enable_notes=enable_notes,
|
||||||
|
)
|
||||||
if notes_master is not None:
|
if notes_master is not None:
|
||||||
notes_slides_created.add(slide_num)
|
notes_slides_created.add(slide_num)
|
||||||
notes_master_parts_used.add(notes_master.package_part)
|
notes_master_parts_used.add(notes_master.package_part)
|
||||||
@@ -7446,7 +7552,7 @@ def create_pptx_with_native_svg(
|
|||||||
if verbose:
|
if verbose:
|
||||||
print(
|
print(
|
||||||
f" {progress_label} {svg_path.name} "
|
f" {progress_label} {svg_path.name} "
|
||||||
"(Source slide notes overlay)"
|
"(Source slide notes / identity overlay)"
|
||||||
)
|
)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
continue
|
continue
|
||||||
@@ -7595,6 +7701,7 @@ def create_pptx_with_native_svg(
|
|||||||
image_scale=image_scale,
|
image_scale=image_scale,
|
||||||
image_quality=image_quality,
|
image_quality=image_quality,
|
||||||
native_objects=native_objects,
|
native_objects=native_objects,
|
||||||
|
package_parts=package_parts,
|
||||||
animation_group_overrides=converter_group_overrides,
|
animation_group_overrides=converter_group_overrides,
|
||||||
theme_font_spec=active_theme_font_spec,
|
theme_font_spec=active_theme_font_spec,
|
||||||
theme_color_spec=active_theme_color_spec,
|
theme_color_spec=active_theme_color_spec,
|
||||||
@@ -8337,6 +8444,7 @@ def create_pptx_with_native_svg(
|
|||||||
morph_pairs,
|
morph_pairs,
|
||||||
public_slide_numbers,
|
public_slide_numbers,
|
||||||
morph_shape_ids,
|
morph_shape_ids,
|
||||||
|
preserved_slides=morph_preserved_slides,
|
||||||
)
|
)
|
||||||
if template_shape_roster_expectations is not None:
|
if template_shape_roster_expectations is not None:
|
||||||
for slide_number in morph_trace_names:
|
for slide_number in morph_trace_names:
|
||||||
|
|||||||
+14
-1
@@ -134,6 +134,7 @@ from .template_structure import (
|
|||||||
from ..animation_config import (
|
from ..animation_config import (
|
||||||
animation_group_effect_entries,
|
animation_group_effect_entries,
|
||||||
load_animation_config,
|
load_animation_config,
|
||||||
|
resolve_morph_pairs,
|
||||||
resolve_slide_animation_config,
|
resolve_slide_animation_config,
|
||||||
validate_animation_config,
|
validate_animation_config,
|
||||||
validate_animation_config_errors,
|
validate_animation_config_errors,
|
||||||
@@ -440,6 +441,18 @@ def _roundtrip_passthrough_candidates(
|
|||||||
current_animation_config,
|
current_animation_config,
|
||||||
pages,
|
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}
|
pages_by_output = {page.output_index: page for page in pages}
|
||||||
for index in tuple(candidates):
|
for index in tuple(candidates):
|
||||||
page = pages_by_output[index]
|
page = pages_by_output[index]
|
||||||
@@ -2892,7 +2905,7 @@ Recorded narration:
|
|||||||
authoring_report=authoring_roundtrip_report,
|
authoring_report=authoring_roundtrip_report,
|
||||||
changed_resource_pages=changed_resource_pages,
|
changed_resource_pages=changed_resource_pages,
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
if roundtrip_passthrough_slides:
|
if roundtrip_passthrough_slides:
|
||||||
|
|||||||
+229
@@ -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 = (
|
||||||
|
'<g id="dscr" data-pptx-replace-with="formula" '
|
||||||
|
'data-pptx-x="200" data-pptx-y="200" data-pptx-width="600" '
|
||||||
|
'data-pptx-height="160" data-pptx-bounds="200 200 600 160">'
|
||||||
|
'<metadata type="application/json"><![CDATA['
|
||||||
|
'{"latex":"DSCR=\\\\frac{EBITDA}{PD}","display":"block",'
|
||||||
|
'"font_size":40,"color":"#173B57","align":"center"}'
|
||||||
|
']]></metadata>'
|
||||||
|
'{preview}'
|
||||||
|
'</g>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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'<p:txBody xmlns:p="{NS["p"]}" xmlns:a="{NS["a"]}" '
|
||||||
|
f'xmlns:a14="{NS["a14"]}" xmlns:m="{NS["m"]}">'
|
||||||
|
'<a:bodyPr/><a:lstStyle/><a:p><a14:m><m:oMathPara><m:oMath>'
|
||||||
|
'<m:f><m:num><m:r><m:t>a</m:t></m:r></m:num>'
|
||||||
|
'<m:den><m:r><m:t>b</m:t></m:r></m:den></m:f>'
|
||||||
|
'</m:oMath></m:oMathPara></a14:m></a:p></p:txBody>'
|
||||||
|
)
|
||||||
|
shape = (
|
||||||
|
'<g xmlns="http://www.w3.org/2000/svg" id="shape-13" '
|
||||||
|
'data-pptx-object="shape" data-pptx-frame="64 200 896 104" '
|
||||||
|
f'data-pptx-prst="rect" data-pptx-semantic-object="shape"{extra_attributes}>'
|
||||||
|
'<path d="M 64 200 L 960 200 L 960 304 L 64 304 Z" fill="#F5F5F5" '
|
||||||
|
'stroke="none" data-pptx-part="geometry"/>'
|
||||||
|
'<metadata data-pptx-part="txbody" data-pptx-encoding="base64" '
|
||||||
|
'data-pptx-text-sha256="{sha}">'
|
||||||
|
f'{base64.b64encode(txbody.encode("utf-8")).decode("ascii")}'
|
||||||
|
'</metadata>'
|
||||||
|
'<text x="512" y="262" text-anchor="middle" font-size="28" '
|
||||||
|
'fill="#123B63">a/b</text>'
|
||||||
|
'</g>'
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||||
|
f'{body}</svg>',
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
'<text x="500" y="290" text-anchor="middle" font-size="40" '
|
||||||
|
'fill="#173B57">DSCR = EBITDA / PD</text>'
|
||||||
|
),
|
||||||
|
))
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
'<text x="300" y="290" font-size="40" fill="#173B57">DSCR =</text>'
|
||||||
|
'<text x="560" y="260" text-anchor="middle" font-size="32" '
|
||||||
|
'fill="#173B57">EBITDA</text>'
|
||||||
|
'<line x1="480" y1="275" x2="640" y2="275" stroke="#173B57" '
|
||||||
|
'stroke-width="2"/>'
|
||||||
|
'<text x="560" y="320" text-anchor="middle" font-size="32" '
|
||||||
|
'fill="#173B57">PD</text>'
|
||||||
|
),
|
||||||
|
))
|
||||||
|
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(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
'ratio <tspan data-pptx-inline-formula="\\frac{a_i}{b_i}">aᵢ/bᵢ</tspan>'
|
||||||
|
' holds</text>'
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
'<text x="500" y="290" text-anchor="middle" font-size="40" '
|
||||||
|
'fill="#173B57">DSCR = EBITDA / PD</text>'
|
||||||
|
),
|
||||||
|
))
|
||||||
|
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(
|
||||||
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
|
'ratio <tspan data-pptx-inline-formula="x">x</tspan> holds</text>'
|
||||||
|
)
|
||||||
|
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()
|
||||||
+345
@@ -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 = (
|
||||||
|
'<g id="formula" data-pptx-replace-with="formula" '
|
||||||
|
'data-pptx-x="100" data-pptx-y="100" data-pptx-width="800" '
|
||||||
|
'data-pptx-height="100" data-pptx-bounds="100 100 800 100">'
|
||||||
|
f'<metadata type="application/json">{payload}</metadata>'
|
||||||
|
'<text x="100" y="150" font-size="28" fill="#173B57">x/y+z</text></g>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body = (
|
||||||
|
'<text x="100" y="150" font-size="28" fill="#173B57">'
|
||||||
|
f'{prefix}<tspan data-pptx-inline-formula="{escape(latex, quote=True)}" '
|
||||||
|
f'{style}>x/y+z</tspan> after</text>'
|
||||||
|
)
|
||||||
|
return self._export(f'<svg xmlns="{NS["s"]}" viewBox="0 0 1280 720">{body}</svg>', 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'<p:presentation xmlns:p="{NS["p"]}" xmlns:r="{NS["r"]}">'
|
||||||
|
'<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>'
|
||||||
|
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>'
|
||||||
|
))
|
||||||
|
archive.writestr('ppt/_rels/presentation.xml.rels', (
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
f'<Relationship Id="rId1" Type="{NS["r"]}/slide" Target="slides/slide1.xml"/>'
|
||||||
|
'</Relationships>'
|
||||||
|
))
|
||||||
|
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='<tspan opacity="0.25">before </tspan>')
|
||||||
|
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'))
|
||||||
+120
@@ -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 = '<text x="200" y="240" font-size="28">x = 1</text>'
|
||||||
|
|
||||||
|
|
||||||
|
def _block_formula(attributes: str = "", preview: str = PREVIEW) -> str:
|
||||||
|
return (
|
||||||
|
'<g id="formula" data-pptx-replace-with="formula" '
|
||||||
|
'data-pptx-x="100" data-pptx-y="200" data-pptx-width="400" '
|
||||||
|
f'data-pptx-height="80" data-pptx-bounds="100 200 400 80" {attributes}>'
|
||||||
|
'<metadata type="application/json">'
|
||||||
|
'{"latex":"x=1","display":"block","font_size":28,"name":"!!formula"}'
|
||||||
|
f'</metadata>{preview}</g>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||||
|
f'{body}</svg>',
|
||||||
|
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 + '<line x1="100" y1="260" x2="500" y2="260" stroke="#000000"/>',
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
+ '<rect x="0" y="0" width="10" height="10" '
|
||||||
|
'data-pptx-shape-id="2" data-pptx-shape-scope="slide"/>'
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
'<rect x="0" y="0" width="10" height="10" '
|
||||||
|
'data-pptx-shape-id="29" data-pptx-shape-scope="slide"/>'
|
||||||
|
+ _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() + '<rect x="0" y="0" width="10" height="10"/>'
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
'<text x="100" y="200" font-size="28" data-pptx-shape-id="29" '
|
||||||
|
'data-pptx-shape-scope="slide" data-pptx-shape-name="!!inline">'
|
||||||
|
'value <tspan data-pptx-inline-formula="x">x</tspan></text>'
|
||||||
|
)
|
||||||
|
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)
|
||||||
+253
@@ -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'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">{body}</svg>', 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'<Relationships xmlns="{NS["rel"]}"/>'.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'<g id="left">{_chart("preserved", source=source)}</g>'
|
||||||
|
f'<g id="right">{_chart("rebuilt")}</g>',
|
||||||
|
)
|
||||||
|
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))
|
||||||
+239
@@ -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'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">{body}</svg>',
|
||||||
|
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)
|
||||||
|
+ '<rect x="0" y="0" width="10" height="10" '
|
||||||
|
'data-pptx-shape-id="2" data-pptx-shape-scope="slide"/>'
|
||||||
|
)
|
||||||
|
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'<root {namespaces}>{create_transition_xml("morph")}</root>')
|
||||||
|
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(
|
||||||
|
'<rect x="0" y="0" width="10" height="10" '
|
||||||
|
'data-pptx-shape-id="29" data-pptx-shape-scope="slide"/>'
|
||||||
|
+ _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)
|
||||||
|
+ '<rect x="0" y="0" width="10" height="10"/>'
|
||||||
|
)
|
||||||
|
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)
|
||||||
+278
@@ -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'<svg xmlns="{NS["s"]}" viewBox="0 0 960 540" lang="en-US">'
|
||||||
|
'<g id="hero" data-pptx-bounds="50 50 100 80">'
|
||||||
|
'<rect x="50" y="50" width="100" height="80" fill="#123456"/>'
|
||||||
|
'<rect x="60" y="60" width="20" height="20" fill="#ABCDEF"/></g>'
|
||||||
|
'<g id="other" data-pptx-bounds="250 50 100 80">'
|
||||||
|
'<rect x="250" y="50" width="100" height="80" fill="#654321"/>'
|
||||||
|
'<rect x="260" y="60" width="20" height="20" fill="#FEDCBA"/></g>'
|
||||||
|
'</svg>', 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('<p:', '<z:').replace('</p:', '</z:')
|
||||||
|
xml = xml.replace('name="hero"', 'descr="literal > name=\'decoy\'" name = \'hero\'')
|
||||||
|
xml = xml.replace('</z:spTree>', '<!-- <z:cNvPr id="29" name="decoy"/> --></z:spTree>')
|
||||||
|
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" <tag> \'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)
|
||||||
+396
@@ -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 = '<a:srgbClr val="123456"/>' if reversible else '<a:sysClr val="windowText" lastClr="123456"/>'
|
||||||
|
math = (
|
||||||
|
'<a14:m><m:oMath><m:r><a:rPr><a:solidFill>'
|
||||||
|
f'{color}'
|
||||||
|
'</a:solidFill></a:rPr><m:t>x</m:t></m:r></m:oMath></a14:m>'
|
||||||
|
) if formula else ""
|
||||||
|
second_line = '<a:p><a:r><a:rPr sz="1800"/><a:t>Second line</a:t></a:r></a:p>' if multiline else ""
|
||||||
|
return (
|
||||||
|
f'<p:txBody xmlns:p="{NS["p"]}" xmlns:a="{NS["a"]}" '
|
||||||
|
f'xmlns:a14="{NS["a14"]}" xmlns:m="{NS["m"]}">'
|
||||||
|
'<a:bodyPr wrap="square" lIns="19050" tIns="28575" rIns="38100" bIns="47625">'
|
||||||
|
'<a:normAutofit fontScale="90000" lnSpcReduction="10000"/>'
|
||||||
|
'</a:bodyPr><a:lstStyle/><a:p><a:pPr>'
|
||||||
|
'<a:buAutoNum type="arabicPeriod" startAt="3"/></a:pPr>'
|
||||||
|
'<a:r><a:rPr lang="fr-FR" sz="1800"/><a:t>Bonjour </a:t></a:r>'
|
||||||
|
f'{math}{math}<a:endParaRPr lang="fr-FR" sz="1800"/>'
|
||||||
|
f'</a:p>{second_line}</p:txBody>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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'<svg xmlns="{NS["s"]}" width="960" height="540" viewBox="0 0 960 540">'
|
||||||
|
'<g id="shape-7" data-pptx-object="shape" data-pptx-shape-id="7" '
|
||||||
|
'data-pptx-shape-scope="slide" data-pptx-frame="80 100 500 120" '
|
||||||
|
'data-pptx-prst="rect" data-pptx-semantic-object="shape">'
|
||||||
|
'<path d="M80 100 H580 V220 H80 Z" fill="#FFFFFF" '
|
||||||
|
'stroke="none" data-pptx-part="geometry"/>'
|
||||||
|
'<text x="90" y="145" font-size="24" font-family="Arial" '
|
||||||
|
'fill="#123456">Bonjour xx</text></g></svg>'
|
||||||
|
)
|
||||||
|
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"<broken>").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)
|
||||||
+15
@@ -12,6 +12,7 @@ import unittest
|
|||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
from pptx.oxml.xmlchemy import OxmlElement
|
from pptx.oxml.xmlchemy import OxmlElement
|
||||||
from pptx.util import Inches
|
from pptx.util import Inches
|
||||||
@@ -169,3 +170,17 @@ class PptxContentLossTests(unittest.TestCase):
|
|||||||
md = self._convert(prs)
|
md = self._convert(prs)
|
||||||
self.assertIn('| First<br>Second |', md)
|
self.assertIn('| First<br>Second |', md)
|
||||||
self.assertNotIn('<br><br>', md)
|
self.assertNotIn('<br><br>', 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)
|
||||||
+4
-1
@@ -34,6 +34,7 @@ from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa:
|
|||||||
NS = {
|
NS = {
|
||||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||||
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
||||||
|
'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
|
||||||
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||||
}
|
}
|
||||||
EMU_PER_PX = 9525
|
EMU_PER_PX = 9525
|
||||||
@@ -333,6 +334,8 @@ class TextBaselineTests(unittest.TestCase):
|
|||||||
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||||
'Hg<tspan data-pptx-inline-formula="x">x</tspan>pq</text>'
|
'Hg<tspan data-pptx-inline-formula="x">x</tspan>pq</text>'
|
||||||
)
|
)
|
||||||
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._assert_top(shape, 166)
|
||||||
self.assertIsNotNone(shape.find('.//m:oMath', NS))
|
self.assertIsNotNone(shape.find('.//m:oMath', NS))
|
||||||
Reference in new issue
Block a user