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
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-10-21T06:51:10Z"
|
||||
"syncedAt": "2026-10-21T16:02:37Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "72895219d44df111ee6ac81e4eac10d81a5e5cbf",
|
||||
"commit": "1984f672c01923e26a23ab24063d42a5263b1561",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-10-21T06:48:34Z"
|
||||
"syncedAt": "2026-10-21T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "cc8b722a24e9fb540e67c2d58a9ea3da534cd682",
|
||||
"commit": "604ecdec8dd6a0405e1abdb49565430902373276",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-10-21T06:48:34Z"
|
||||
"syncedAt": "2026-10-21T16:00:00Z"
|
||||
}
|
||||
@@ -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 — 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.
|
||||
|
||||
**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
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
@@ -52,12 +56,13 @@ from svg_authoring_view import (
|
||||
SOURCE_PROXY_ATTRIBUTE,
|
||||
SOURCE_PROXY_KIND,
|
||||
_element_chain,
|
||||
_format_transform_number,
|
||||
_materialize_adopted_context,
|
||||
project_svg_batch,
|
||||
semantic_subtree_sha256,
|
||||
)
|
||||
from svg_compatibility import normalize_single_child_group_filters
|
||||
from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS
|
||||
from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS, parse_transform_matrix
|
||||
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
@@ -1538,25 +1543,109 @@ def _restore_preserved_effect_metadata(
|
||||
item.set(name, value)
|
||||
|
||||
|
||||
def _text_body_frame(element: ET.Element) -> tuple[float, ...] | None:
|
||||
"""Read a finite carrier frame for geometry and relative text comparisons."""
|
||||
try:
|
||||
frame = tuple(float(value) for value in re.split(
|
||||
r"[\s,]+", (element.get("data-pptx-frame") or "").strip(),
|
||||
))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(frame) != 4 or not all(math.isfinite(value) for value in frame):
|
||||
return None
|
||||
return frame
|
||||
|
||||
|
||||
def _text_body_geometry(occurrence: RefOccurrence) -> tuple | None:
|
||||
"""Compare carrier size and effective axes, allowing only position changes."""
|
||||
frame = _text_body_frame(occurrence.element)
|
||||
if frame is None:
|
||||
return None
|
||||
contextual = _contextual_authoring_element(occurrence.element, occurrence.root)
|
||||
# Match the precision used when the shared context helper composes ancestry.
|
||||
axes = tuple(f"{value:.12g}" for value in parse_transform_matrix(contextual.get("transform", ""))[:4])
|
||||
return frame[2:], axes
|
||||
|
||||
|
||||
def _text_body_state(occurrence: RefOccurrence, *, frame_relative: bool = False) -> list[str] | None:
|
||||
"""Fingerprint text with carrier translations removed, retaining local edits."""
|
||||
carrier = copy.deepcopy(occurrence.element)
|
||||
root = carrier
|
||||
context = [carrier]
|
||||
for ancestor in reversed(_element_chain(occurrence.root, occurrence.element)[:-1]):
|
||||
wrapper = ET.Element(ancestor.tag, ancestor.attrib)
|
||||
wrapper.append(root)
|
||||
root = wrapper
|
||||
context.append(wrapper)
|
||||
# Normalize only the carrier and its ancestors. Text-local coordinates and
|
||||
# transforms (including those on wrappers inside the carrier) still count.
|
||||
for node in context:
|
||||
axes = parse_transform_matrix(node.get("transform", ""))[:4]
|
||||
node.attrib.pop("transform", None)
|
||||
if axes != (1.0, 0.0, 0.0, 1.0):
|
||||
node.set("transform", "matrix(" + " ".join(
|
||||
f"{value:.12g}" for value in (*axes, 0.0, 0.0)
|
||||
) + ")")
|
||||
if frame_relative:
|
||||
frame = _text_body_frame(carrier)
|
||||
if frame is None:
|
||||
return None
|
||||
for text in carrier.iter(f"{{{SVG_NS}}}text"):
|
||||
for node in text.iter():
|
||||
if node.tag not in {f"{{{SVG_NS}}}text", f"{{{SVG_NS}}}tspan"}:
|
||||
continue
|
||||
for axis, origin in zip(("x", "y"), frame[:2]):
|
||||
raw = node.get(axis)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
values = [float(value) - origin for value in re.split(r"[\s,]+", raw.strip())]
|
||||
except ValueError:
|
||||
return None
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
return None
|
||||
node.set(axis, " ".join(_format_transform_number(value) for value in values))
|
||||
return [
|
||||
semantic_subtree_sha256(_contextual_authoring_element(text, root))
|
||||
for text in carrier.iter(f"{{{SVG_NS}}}text")
|
||||
]
|
||||
|
||||
|
||||
def _text_body_formula_loss_warning(metadata: ET.Element, target: ET.Element, reason: str) -> str | None:
|
||||
"""Diagnose lost native math without taking over payload validation."""
|
||||
try:
|
||||
body = ET.fromstring(base64.b64decode((metadata.text or "").strip(), validate=True))
|
||||
except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError):
|
||||
# An unreadable payload has an unknown formula count. Keep validation
|
||||
# with the converter when the original restoration path consumes it.
|
||||
return None
|
||||
math_count = sum(1 for _ in body.iter("{http://schemas.microsoft.com/office/drawing/2010/main}m"))
|
||||
marker_count = sum(
|
||||
1 for node in target.iter()
|
||||
if node.get("data-pptx-inline-formula") or node.get("data-pptx-replace-with") == "formula"
|
||||
)
|
||||
if math_count > marker_count:
|
||||
return f"native formulas will become plain text; reason={reason}; source txBody cannot be restored"
|
||||
return None
|
||||
|
||||
|
||||
def _restore_unchanged_text_body(
|
||||
target: ET.Element,
|
||||
source: ET.Element,
|
||||
current: RefOccurrence,
|
||||
baseline: RefOccurrence,
|
||||
) -> None:
|
||||
"""Recover native text only while its projected text and context agree."""
|
||||
) -> str | None:
|
||||
"""Recover unchanged native text after translation, or diagnose lost math."""
|
||||
metadata = next((child for child in source if child.get("data-pptx-part") == "txbody"), None)
|
||||
if metadata is None or current.element.get("data-pptx-frame") != baseline.element.get("data-pptx-frame"):
|
||||
return
|
||||
|
||||
def text_state(occurrence: RefOccurrence) -> list[str]:
|
||||
return [
|
||||
semantic_subtree_sha256(_contextual_authoring_element(text, occurrence.root))
|
||||
for text in occurrence.element.iter(f"{{{SVG_NS}}}text")
|
||||
]
|
||||
|
||||
if text_state(current) != text_state(baseline):
|
||||
if metadata is None:
|
||||
return
|
||||
geometry = _text_body_geometry(current)
|
||||
if geometry is None or geometry != _text_body_geometry(baseline):
|
||||
return _text_body_formula_loss_warning(metadata, target, "size/transform-changed")
|
||||
if _text_body_state(current) != _text_body_state(baseline):
|
||||
relative_state = _text_body_state(current, frame_relative=True)
|
||||
if relative_state is None or relative_state != _text_body_state(baseline, frame_relative=True):
|
||||
return _text_body_formula_loss_warning(metadata, target, "text-edited")
|
||||
restored = copy.deepcopy(metadata)
|
||||
# Projection compacts text runs and paragraphs. Validate that projection
|
||||
# above, then let the existing decoder validate the recovered payload.
|
||||
@@ -1798,6 +1887,8 @@ def _materialize_document(
|
||||
"picture relationship"
|
||||
)
|
||||
|
||||
warnings: list[dict[str, Any]] = []
|
||||
|
||||
def restore_nodes(
|
||||
element: ET.Element,
|
||||
source_dir: Path | None = None,
|
||||
@@ -1872,9 +1963,25 @@ def _materialize_document(
|
||||
if clone.get(name) is None and source.get(name) is not None:
|
||||
clone.set(name, str(source.get(name)))
|
||||
_restore_preserved_effect_metadata(clone, source)
|
||||
_restore_unchanged_text_body(
|
||||
warning = _restore_unchanged_text_body(
|
||||
clone, source, current_occurrences[source_ref][0], baseline_occurrences[source_ref][0],
|
||||
)
|
||||
if warning:
|
||||
object_id = element.get("id") or source_ref
|
||||
output_index = page.output_index if page else document.source_slide
|
||||
warnings.append({
|
||||
"code": "native-formula-to-text",
|
||||
"output_page": output_index,
|
||||
"source_slide": document.source_slide,
|
||||
"object_id": object_id,
|
||||
"source_ref": source_ref,
|
||||
"message": warning,
|
||||
})
|
||||
print(
|
||||
f"[ROUNDTRIP][WARNING] page={output_index} ({document.name}) "
|
||||
f"object={object_id} source_ref={source_ref}: {warning}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if (
|
||||
_local_name(clone.tag) == "g" and source_ref is None
|
||||
and set(clone.attrib) <= {*INHERITABLE_ATTRS, "style", "opacity", "transform"}
|
||||
@@ -2022,6 +2129,7 @@ def _materialize_document(
|
||||
"authored_unreferenced": authored_unreferenced,
|
||||
"expanded_vector_uses": expanded_vector_uses,
|
||||
"defs_changed": defs_changed,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -376,9 +376,10 @@ replacement timing tree.
|
||||
|
||||
### Native formula reverse import
|
||||
|
||||
The importer reconstructs formulas only from the closed OMML vocabulary owned
|
||||
by the native formula compiler. One formula-only `a14:m > m:oMathPara` text
|
||||
shape becomes a bounded `<g data-pptx-replace-with="formula">` with canonical
|
||||
The importer reconstructs formulas from the native compiler's closed OMML
|
||||
vocabulary after the import-only color normalization below. One formula-only
|
||||
`a14:m > m:oMathPara` text shape becomes a bounded
|
||||
`<g data-pptx-replace-with="formula">` with canonical
|
||||
LaTeX JSON and a visible linear SVG preview when its carrier is an ungrouped,
|
||||
unstyled, unrotated rectangular formula shape. Carrier styling, effects,
|
||||
hyperlinks, or placeholder ownership force diagnosed fallback rather than
|
||||
@@ -388,8 +389,43 @@ Supported `a14:m > m:oMath` zones inside an ordinary paragraph become leaf
|
||||
their surrounding text runs. The generated markers pass the same native-object
|
||||
and inline-formula validators used by SVG-to-PPTX export.
|
||||
|
||||
**Color normalization** applies only to `a:solidFill` directly inside
|
||||
`m:r/a:rPr` or `m:ctrlPr/a:rPr`. Each fill must contain exactly one correctly
|
||||
namespaced `a:srgbClr` or `a:schemeClr`. The shared color resolver uses the
|
||||
caller's effective palette and color map, never a fixed `theme1.xml`, and
|
||||
strictly validates color attributes and the supported `alpha`, `alphaMod`,
|
||||
and `alphaOff` modifiers even during tolerant import. The importer validates
|
||||
resource limits before normalization and changes a copy of the OMML only.
|
||||
Resolved RGB replaces the color node before the unchanged compiler validator
|
||||
runs. The resolved marker color is the formula's base: block JSON `color` or
|
||||
inline run `fill`. Serialization omits run and control colors already supplied
|
||||
by that base or an enclosing local color scope. Only changes to the effective
|
||||
color become LaTeX `\textcolor` scopes; nested scopes can explicitly restore
|
||||
the base color when needed. Theme tokens become current RGB values and lose
|
||||
their original theme-token linkage. Re-export applies the exporter's existing anchor mapping
|
||||
to marker defaults; local LaTeX color scopes remain explicit RGB overrides.
|
||||
|
||||
**Transparency** must remain representable by the destination marker and its
|
||||
existing exporter. Inline formulas retain one uniform effective alpha across
|
||||
their runs and controls through the marker's `fill` / `fill-opacity` style;
|
||||
surrounding runs retain their own opacity. This includes the uniform alpha
|
||||
emitted by PPT Master's inline exporter with local color overrides. Alpha
|
||||
must fit DrawingML's 1/100000 precision. Mixed alpha, including an opaque run
|
||||
or control among translucent ones, is not reconstructed. Block markers keep
|
||||
RGB in JSON `color`; their existing exporter emits opaque RGB only, so a
|
||||
translucent block formula also takes the diagnosed opaque-`txBody` path.
|
||||
Alpha is never discarded to make a formula pass validation.
|
||||
|
||||
Canonical read-back uses explicit braces for script bases and argument bodies,
|
||||
`\left` / `\right` for delimiter nodes, Unicode glyphs for symbols, `\mathrm`
|
||||
for upright math runs, and `\mathop` for function nodes. Insignificant source
|
||||
whitespace is not recovered. Recompilation may split or join adjacent runs
|
||||
with identical effective styling without changing their text or appearance.
|
||||
|
||||
This is normalized semantic read-back, not recovery of the author's original
|
||||
LaTeX spelling and not a general Office Math converter. Every OMML root must
|
||||
LaTeX spelling and not a general Office Math converter. Multiple color
|
||||
children, incorrect namespaces, unresolved theme colors, unsupported modifiers,
|
||||
and unknown OMML remain outside the reversible subset. Every normalized root must
|
||||
pass the compiler's namespace, element, attribute, structure, size, and depth
|
||||
gates, and the reconstructed LaTeX must compile again under the same profile.
|
||||
If any formula in one text body falls outside that boundary, tolerant import
|
||||
|
||||
@@ -212,7 +212,7 @@ failure rather than a silent downgrade.
|
||||
|
||||
### 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
|
||||
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
|
||||
@@ -231,8 +231,14 @@ roster expectations are then refreshed. Package read-back requires:
|
||||
undeclared shared `!!` name on a Morph edge.
|
||||
|
||||
Morph without an explicit pair block retains PowerPoint's automatic matching
|
||||
behavior. Explicit pairing is generated-route authoring; source-preserving
|
||||
round-trip export keeps existing object names and transition XML.
|
||||
behavior. Round-trip export preserves source names unless an explicit pair
|
||||
assigns a new identity. An endpoint on an unchanged page resolves through its
|
||||
imported Slide-local shape id and counts as `patched`, including planned copies.
|
||||
When only identity changes, export replaces only the `cNvPr@name` attribute
|
||||
value; every other slide XML byte and its relationships stay unchanged. Notes
|
||||
and requested motion remain separate overlays. An endpoint without a unique
|
||||
native identity fails with its page/object and instructions to edit that page
|
||||
as well or pair two edited pages.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -93,10 +93,49 @@ vector edit units from `icons/imported/`, and retains edits/deletions/new
|
||||
content without rewriting the bundle. Unchanged slides and resources pass
|
||||
through byte-for-byte. Object comparison includes effective ancestor transforms,
|
||||
opacity, inherited styles, and enclosing links; changing a proxy's context also
|
||||
fails. When an edited shape's text and text context still match the baseline,
|
||||
the materializer restores its relationship-free source `txBody` through the
|
||||
existing fingerprint/relationship checks, preserving fields, numbering,
|
||||
autofit, language, and text effects. Edited text follows normal conversion.
|
||||
fails. When an edited shape's text and effective text style still match the
|
||||
baseline, unchanged geometry or a pure translation retains its relationship-free
|
||||
source `txBody` through the existing fingerprint/relationship checks. This
|
||||
preserves the complete text body, including native Office Math, fields,
|
||||
numbering, autofit, language, and text effects. Translation may change frame
|
||||
x/y without changing width/height, or move the object or its ancestors with
|
||||
unchanged effective rotation, flip, and scale. Moving frame, geometry, and
|
||||
text coordinates together is also supported: when absolute text positions
|
||||
differ, the materializer compares every numeric `text`/`tspan` x/y coordinate
|
||||
relative to the frame origin. Relative text positions, text-local transforms,
|
||||
and styles remain part of the fingerprint; moving only one line or editing
|
||||
text/style never restores stale text over the edit.
|
||||
|
||||
Width/height or non-translation transform changes continue to rebuild from
|
||||
SVG. Whenever a source `txBody` is not restored and its `a14:m` count exceeds
|
||||
the remaining formula marker count, including after text or style edits, the
|
||||
materializer emits a non-blocking `[ROUNDTRIP][WARNING]` to stderr with the
|
||||
output page, SVG filename, object id, and source ref, stating that native
|
||||
formulas will become plain text. Its reason is `size/transform-changed` or
|
||||
`text-edited`. An unreadable payload has an unknown formula count, produces
|
||||
no formula-loss warning, and remains subject to the converter's existing
|
||||
payload validation when consumed. The same `native-formula-to-text` diagnostic
|
||||
is recorded under each document's `warnings` in the postflight report's
|
||||
`source.authoring_roundtrip`; ordinary text and formulas rebuilt from native
|
||||
markers do not receive this warning. This does not expand the formula reverse
|
||||
import boundary, certify resized text layout/autofit, or change the existing
|
||||
POSTFLIGHT quality-gate status.
|
||||
|
||||
Native charts rebuilt from edited JSON retain the marker's scoped source shape
|
||||
id on the chart `graphicFrame`, just like restored source chart packages.
|
||||
For a rebuilt imported frame, `data-pptx-shape-name` takes precedence over the
|
||||
payload name. Source animation targets and forced-Morph `!!` names survive.
|
||||
Synthesized companion text receives fresh ids and follows the frame as sibling
|
||||
shapes without a new wrapper or inherited source name. The frame retains its
|
||||
original hierarchy level and `graphicFrame` type. Markers without a source id
|
||||
keep generated chart grouping, id allocation, and naming unchanged. Rebuilt
|
||||
chart relationships point to the newly generated chart/workbook parts;
|
||||
restored packages keep their source parts.
|
||||
New chart part families reserve unused names across the complete export
|
||||
(including source/template parts, workbooks, relationships, and ChartEx
|
||||
styles/colors); conflicting writes or content types fail with the part path
|
||||
and both owners, while identical immutable source dependencies may be reused.
|
||||
|
||||
A page edit rebuilds that output page; a changed
|
||||
materialized or derived resource rebuilds every output page that references it.
|
||||
Changed materialized bytes must still match the source package part's extension
|
||||
@@ -820,9 +859,14 @@ python3 -c "import sys; sys.path.insert(0, 'skills/ppt-master/scripts'); from sv
|
||||
Matrices, multiline derivations, and other high-structure expressions remain
|
||||
blocks. Formula replacement is always active, independent of
|
||||
`--native-charts-and-tables`: export replaces only the registered SVG preview
|
||||
and writes editable PowerPoint 2010+ Office Math. It emits no formula PNG, media
|
||||
relationship, or compatibility fallback, and makes no rendering/editability
|
||||
promise for Keynote, WPS, LibreOffice, or another non-PowerPoint client.
|
||||
and writes editable PowerPoint 2010+ Office Math in `mc:Choice Requires="a14"`.
|
||||
The same `mc:AlternateContent` carries an `mc:Fallback` built from the SVG
|
||||
preview as ordinary native shapes (block) or the same text box with the preview
|
||||
run in place of the math (inline), with fresh shape ids; no formula PNG or media
|
||||
relationship is created. A round-trip shape that restores source Office Math
|
||||
from its unchanged txBody gets the same branch from its visible SVG text. Clients without a14 math, such as LibreOffice, show
|
||||
that fallback; no rendering/editability promise is made for Keynote, WPS,
|
||||
LibreOffice, or another non-PowerPoint client.
|
||||
|
||||
```bash
|
||||
python3 scripts/svg_to_pptx.py <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.
|
||||
|
||||
The compiler validates its output against the closed OMML grammar before
|
||||
marker-level defaults are applied.
|
||||
The block and inline builders then merge marker-level run/control styles;
|
||||
the inline exporter can emit theme colors and alpha in that styled OMML.
|
||||
The import-only normalization and transparency boundary are owned by
|
||||
[`conversion.md`, Native formula reverse import](conversion.md#native-formula-reverse-import).
|
||||
|
||||
**Fail-closed**: unknown commands or environments, Microsoft's explicitly unsupported commands, unsupported mhchem arrows, unescaped `%` comments, invalid macros, and resource-limit overflow block conversion — stricter than Microsoft 365's literal-text passthrough and macro-limit behavior.
|
||||
|
||||
**Compatibility**: the package uses standard editable Office Math and keeps the PowerPoint 2010+ target; the executable profile is pinned to the documentation versions above. Repository verification covers compilation, OMML structure, and PPTX packaging, not a Microsoft 365 UI rendering/editability certification. Earlier PowerPoint versions are not the source-profile baseline; WPS, Keynote, LibreOffice, and other clients receive no embedded fallback. Reverse import is described in [`conversion.md`](conversion.md#native-formula-reverse-import).
|
||||
**Compatibility**: the package uses standard editable Office Math and keeps the PowerPoint 2010+ target; the executable profile is pinned to the documentation versions above. Repository verification covers compilation, OMML structure, and PPTX packaging, not a Microsoft 365 UI rendering/editability certification. Earlier PowerPoint versions are not the source-profile baseline; WPS, Keynote, LibreOffice, and other clients receive only the native-shape preview fallback. Reverse import is described in [`conversion.md`](conversion.md#native-formula-reverse-import).
|
||||
|
||||
## `visual_review.py`
|
||||
|
||||
|
||||
@@ -254,9 +254,17 @@ def _container_geometry(
|
||||
|
||||
|
||||
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
# mc:Fallback repeats its Choice for other clients; it is not slide content.
|
||||
fallback_nodes = {
|
||||
node
|
||||
for fallback in slide_root.iter(f"{{{MC_NS}}}Fallback")
|
||||
for node in fallback.iter()
|
||||
}
|
||||
containers: list[ET.Element] = []
|
||||
for tag in ("p:sp", "p:graphicFrame"):
|
||||
for element in slide_root.findall(f".//{tag}", NS):
|
||||
if element in fallback_nodes:
|
||||
continue
|
||||
if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS):
|
||||
containers.append(element)
|
||||
return containers
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Write-side OOXML package plumbing for source-preserving slide cloning.
|
||||
"""Write-side OOXML package plumbing for slide cloning and native objects.
|
||||
|
||||
Content-type override insertion, relationship-element construction / lookup, and
|
||||
part-number allocation used when cloning slides into a new package.
|
||||
part allocation, and collision checks for a complete export package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
@@ -33,6 +33,79 @@ _EXPLICIT_SLIDE_REL_KINDS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
class PackagePartRegistry:
|
||||
"""Reserve package names and reject conflicting native-object writes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
package_root: Path | None = None,
|
||||
content_types: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.package_root = package_root
|
||||
self.parts: dict[str, bytes] = {}
|
||||
self.content_types = dict(content_types or {})
|
||||
self.owners: dict[str, str] = {}
|
||||
self.source_names: set[str] = set()
|
||||
if package_root is not None:
|
||||
self.reserve_source(
|
||||
(path.relative_to(package_root).as_posix()
|
||||
for path in package_root.rglob("*") if path.is_file()),
|
||||
"template/source package",
|
||||
)
|
||||
self.reserve_source(self.content_types, "template/source content types")
|
||||
|
||||
def reserve_source(self, names: Iterable[str], owner: str) -> None:
|
||||
"""Reserve immutable source names before visiting any new objects."""
|
||||
for name in names:
|
||||
if name not in self.owners:
|
||||
self.owners[name] = owner
|
||||
self.source_names.add(name)
|
||||
|
||||
def next_index(
|
||||
self, patterns: tuple[str, ...], start: int, occupied: Iterable[str] = (),
|
||||
) -> int:
|
||||
"""Keep the preferred number unless any member of the part family exists."""
|
||||
used = self.owners.keys() | set(occupied)
|
||||
while any(pattern.format(start) in used for pattern in patterns):
|
||||
start += 1
|
||||
return start
|
||||
|
||||
def register(
|
||||
self,
|
||||
parts: dict[str, bytes],
|
||||
content_types: dict[str, str],
|
||||
owner: str,
|
||||
*,
|
||||
source: bool = False,
|
||||
) -> None:
|
||||
"""Register an entire object atomically; identical source parts may be reused."""
|
||||
for name in parts.keys() | content_types.keys():
|
||||
previous_owner = self.owners.get(name)
|
||||
existing = self.parts.get(name)
|
||||
if existing is None and self.package_root is not None:
|
||||
path = self.package_root / name
|
||||
if path.is_file():
|
||||
existing = path.read_bytes()
|
||||
previous_type = self.content_types.get(name)
|
||||
content_type = content_types.get(name)
|
||||
collision = previous_owner is not None and (
|
||||
not source or name not in self.source_names
|
||||
or (existing is not None and existing != parts.get(name))
|
||||
)
|
||||
if collision or (previous_type and content_type and previous_type != content_type):
|
||||
raise RuntimeError(
|
||||
f"OOXML part collision: {name!r} between {previous_owner!r} and {owner!r}; "
|
||||
"use a distinct part name for each new object"
|
||||
)
|
||||
for name in parts.keys() | content_types.keys():
|
||||
if name not in self.parts:
|
||||
self.owners[name] = owner
|
||||
if source:
|
||||
self.source_names.add(name)
|
||||
self.parts.update(parts)
|
||||
self.content_types.update(content_types)
|
||||
|
||||
|
||||
def unreferenced_slide_relationships(
|
||||
slide: ET.Element,
|
||||
relationships: ET.Element,
|
||||
|
||||
+87
-6
@@ -1,9 +1,10 @@
|
||||
"""Import PPT Master-owned Office Math into canonical SVG formula markers.
|
||||
|
||||
The reverse contract is deliberately narrow: accept only the closed OMML
|
||||
vocabulary already validated by the native formula compiler, serialize that
|
||||
structure to compiler-accepted LaTeX, and build a plain-text SVG preview.
|
||||
The reverse contract is deliberately narrow: normalize supported DrawingML
|
||||
colors, validate the compiler's closed OMML vocabulary, serialize that structure
|
||||
to compiler-accepted LaTeX, and build a plain-text SVG preview.
|
||||
Unknown third-party OMML remains outside this module's reconstruction claim.
|
||||
See scripts/docs/conversion.md, Native formula reverse import, for the boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,10 @@ from svg_to_pptx.native_objects.formula_profile import (
|
||||
ACCENT_COMMANDS,
|
||||
NARY_COMMANDS,
|
||||
)
|
||||
from svg_to_pptx.native_objects.formula_omml import validate_omml_resource_limits
|
||||
|
||||
from .color_resolver import ColorPalette, resolve_color
|
||||
from .emu_units import format_ooxml_alpha
|
||||
|
||||
|
||||
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
|
||||
@@ -89,9 +94,15 @@ class FormulaImport:
|
||||
color: str
|
||||
align: str
|
||||
language: str
|
||||
opacity: float
|
||||
|
||||
|
||||
def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
|
||||
def import_formula(
|
||||
math_zone: ET.Element,
|
||||
*,
|
||||
display: bool,
|
||||
palette: ColorPalette | None = None,
|
||||
) -> FormulaImport:
|
||||
"""Validate and reconstruct one ``a14:m`` formula zone."""
|
||||
if math_zone.tag != _A14_M:
|
||||
raise FormulaImportError("formula carrier must be one a14:m element")
|
||||
@@ -107,10 +118,17 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
|
||||
)
|
||||
|
||||
try:
|
||||
canonical = validate_omml_fragment(
|
||||
bounded = validate_omml_resource_limits(
|
||||
ET.tostring(root, encoding="unicode", short_empty_elements=True)
|
||||
)
|
||||
normalized_root = ET.fromstring(bounded)
|
||||
opacity = _normalize_colors(normalized_root, palette)
|
||||
if display and opacity != 1.0:
|
||||
raise FormulaImportError("block formula metadata color cannot retain transparency")
|
||||
canonical = validate_omml_fragment(ET.tostring(normalized_root, encoding="unicode"))
|
||||
validated_root = ET.fromstring(canonical)
|
||||
font_size_px, color, language = _dominant_run_style(validated_root)
|
||||
_omit_inherited_colors(validated_root, color[1:])
|
||||
latex = _serialize_root(validated_root)
|
||||
if display:
|
||||
compile_latex_to_omml(latex)
|
||||
@@ -122,7 +140,6 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
|
||||
preview = _preview_root(validated_root).strip()
|
||||
if not latex.strip() or not preview:
|
||||
raise FormulaImportError("formula reconstruction produced empty content")
|
||||
font_size_px, color, language = _dominant_run_style(validated_root)
|
||||
return FormulaImport(
|
||||
latex=latex,
|
||||
preview=preview,
|
||||
@@ -130,9 +147,47 @@ def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
|
||||
color=color,
|
||||
align=_formula_alignment(validated_root),
|
||||
language=language,
|
||||
opacity=opacity,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_colors(root: ET.Element, palette: ColorPalette | None) -> float:
|
||||
"""Normalize only run/control fills on a copy, retaining one uniform alpha."""
|
||||
opacities: set[float] = set()
|
||||
for owner in root.iter():
|
||||
if owner.tag not in {f"{_M}r", f"{_M}ctrlPr"}:
|
||||
continue
|
||||
fills = owner.findall(f"{_A}rPr/{_A}solidFill")
|
||||
if not fills:
|
||||
opacities.add(1.0)
|
||||
for fill in fills:
|
||||
colors = list(fill)
|
||||
if (
|
||||
fill.attrib or (fill.text or "").strip()
|
||||
or len(colors) != 1
|
||||
or colors[0].tag not in {f"{_A}srgbClr", f"{_A}schemeClr"}
|
||||
or (colors[0].tail or "").strip()
|
||||
):
|
||||
raise FormulaImportError("formula solidFill requires one a:srgbClr or a:schemeClr")
|
||||
color = colors[0]
|
||||
if any(child.tag not in {f"{_A}alpha", f"{_A}alphaMod", f"{_A}alphaOff"} for child in color):
|
||||
raise FormulaImportError("unsupported formula color modifier")
|
||||
try:
|
||||
rgb, alpha = resolve_color(color, palette, strict=True)
|
||||
except ValueError as exc:
|
||||
raise FormulaImportError(str(exc)) from exc
|
||||
if rgb is None:
|
||||
raise FormulaImportError("formula color cannot be resolved in the current palette")
|
||||
if alpha != float(format_ooxml_alpha(alpha)):
|
||||
raise FormulaImportError("formula alpha cannot be retained at DrawingML precision")
|
||||
opacities.add(alpha)
|
||||
fill.remove(color)
|
||||
ET.SubElement(fill, f"{_A}srgbClr", {"val": rgb[1:]})
|
||||
if len(opacities) > 1:
|
||||
raise FormulaImportError("mixed formula transparency cannot be represented by one marker")
|
||||
return next(iter(opacities), 1.0)
|
||||
|
||||
|
||||
def opaque_formula_preview(math_zone: ET.Element) -> str:
|
||||
"""Return readable source text without claiming native reconstruction."""
|
||||
text = "".join(
|
||||
@@ -142,6 +197,23 @@ def opaque_formula_preview(math_zone: ET.Element) -> str:
|
||||
return text or "[unsupported formula]"
|
||||
|
||||
|
||||
def _omit_inherited_colors(element: ET.Element, inherited_color: str) -> None:
|
||||
"""Keep only color changes in the validated serialization copy."""
|
||||
if element.tag == f"{_M}r":
|
||||
properties = element.find(f"{_A}rPr")
|
||||
else:
|
||||
name = _local_name(element)
|
||||
properties = element.find(f"{_M}{name}Pr/{_M}ctrlPr/{_A}rPr")
|
||||
color = _drawing_color(properties)
|
||||
if color == inherited_color:
|
||||
properties.remove(properties.find(f"{_A}solidFill"))
|
||||
# A retained structural scope supplies its color to the whole subexpression.
|
||||
# A descendant may still need an explicit scope to restore the marker base.
|
||||
for child in element:
|
||||
if not _local_name(child).endswith(_PROPERTY_SUFFIX):
|
||||
_omit_inherited_colors(child, color or inherited_color)
|
||||
|
||||
|
||||
def _serialize_root(root: ET.Element) -> str:
|
||||
if root.tag == f"{_M}oMathPara":
|
||||
math = root.find(f"{_M}oMath")
|
||||
@@ -172,6 +244,15 @@ def _serialize_argument(owner: ET.Element, name: str, *, alignment: bool = False
|
||||
|
||||
|
||||
def _serialize_node(element: ET.Element, *, alignment: bool = False) -> str:
|
||||
source = _serialize_node_content(element, alignment=alignment)
|
||||
# A scoped color must cover structural glyphs as well as selectable runs.
|
||||
name = _local_name(element)
|
||||
control = element.find(f"{_M}{name}Pr/{_M}ctrlPr/{_A}rPr")
|
||||
color = _drawing_color(control)
|
||||
return f"\\textcolor{{#{color}}}{{{source}}}" if color is not None else source
|
||||
|
||||
|
||||
def _serialize_node_content(element: ET.Element, *, alignment: bool = False) -> str:
|
||||
name = _local_name(element)
|
||||
if name == "r":
|
||||
return _serialize_run(element, alignment=alignment)
|
||||
|
||||
+4
-4
@@ -604,7 +604,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
)
|
||||
if carrier_error is not None:
|
||||
raise FormulaImportError(carrier_error)
|
||||
imported_formula = import_formula(block_formula, display=True)
|
||||
imported_formula = import_formula(block_formula, display=True, palette=ctx.palette)
|
||||
except FormulaImportError as exc:
|
||||
_diagnose_formula_fallback(ctx, exc)
|
||||
block_formula_failed = True
|
||||
@@ -1036,18 +1036,18 @@ def _prepare_inline_formula_resolver(
|
||||
elif not force_opaque:
|
||||
for zone in zones:
|
||||
try:
|
||||
imported[id(zone)] = import_formula(zone, display=False)
|
||||
imported[id(zone)] = import_formula(zone, display=False, palette=ctx.palette)
|
||||
except FormulaImportError as exc:
|
||||
failed = True
|
||||
_diagnose_formula_fallback(ctx, exc)
|
||||
|
||||
def _resolve(zone: ET.Element) -> tuple[str | None, str]:
|
||||
def _resolve(zone: ET.Element) -> tuple[FormulaImport | None, str]:
|
||||
if failed:
|
||||
return None, opaque_formula_preview(zone)
|
||||
item = imported.get(id(zone))
|
||||
if item is None:
|
||||
return None, opaque_formula_preview(zone)
|
||||
return item.latex, item.preview
|
||||
return item, item.preview
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
+15
-6
@@ -37,6 +37,7 @@ from .emu_units import (
|
||||
hundredths_pt_to_px, ooxml_bool,
|
||||
)
|
||||
from .fill_to_svg import resolve_fill
|
||||
from .formula_import import FormulaImport
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -75,7 +76,7 @@ class TextRun:
|
||||
|
||||
|
||||
HyperlinkResolver = Callable[[str, str], str | None]
|
||||
InlineFormulaResolver = Callable[[ET.Element], tuple[str | None, str]]
|
||||
InlineFormulaResolver = Callable[[ET.Element], tuple[FormulaImport | None, str]]
|
||||
TextDiagnosticSink = Callable[[str, str, str], None]
|
||||
|
||||
|
||||
@@ -782,14 +783,17 @@ def _parse_paragraph(
|
||||
== "{http://schemas.microsoft.com/office/drawing/2010/main}m"
|
||||
and inline_formula_resolver is not None
|
||||
):
|
||||
latex, preview = inline_formula_resolver(child)
|
||||
formula, preview = inline_formula_resolver(child)
|
||||
if preview:
|
||||
formula_rpr = next(
|
||||
child.iter(f"{{{NS['a']}}}rPr"),
|
||||
None,
|
||||
)
|
||||
run = resolved_run(preview, formula_rpr)
|
||||
run.formula_latex = latex
|
||||
if formula is not None:
|
||||
run.formula_latex = formula.latex
|
||||
run.fill = formula.color
|
||||
run.fill_opacity = formula.opacity
|
||||
para.runs.append(run)
|
||||
|
||||
return para
|
||||
@@ -1700,14 +1704,19 @@ def _emit_paragraph(
|
||||
else:
|
||||
spans.append(_run_tspan_markup(run))
|
||||
|
||||
base_attrs = _text_base_attrs(first_run, anchor_x, first_baseline, text_anchor)
|
||||
# Formula paragraphs carry alpha per run, so a translucent first run must
|
||||
# not make opaque formulas or surrounding text inherit that alpha.
|
||||
base_attrs = _text_base_attrs(
|
||||
first_run, anchor_x, first_baseline, text_anchor,
|
||||
inherit_opacity=not any(run.formula_latex is not None for line in visible_lines for run in line),
|
||||
)
|
||||
if metrics_baseline:
|
||||
base_attrs += ' data-pptx-text-baseline="metrics-v1"'
|
||||
return f"<text{base_attrs}>{''.join(spans)}</text>"
|
||||
|
||||
|
||||
def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
||||
text_anchor: str) -> str:
|
||||
text_anchor: str, *, inherit_opacity: bool = True) -> str:
|
||||
parts = [
|
||||
f'x="{fmt_num(x)}"',
|
||||
f'y="{fmt_num(y, 8)}"',
|
||||
@@ -1719,7 +1728,7 @@ def _text_base_attrs(run: TextRun | None, x: float, y: float,
|
||||
parts.append(f'font-family="{run.font_family}"')
|
||||
parts.append(f'font-size="{fmt_num(run.font_size_px)}"')
|
||||
parts.append(f'fill="{run.fill}"')
|
||||
if run.fill_opacity < 1.0:
|
||||
if inherit_opacity and run.fill_opacity < 1.0:
|
||||
parts.append(
|
||||
f'fill-opacity="{format_ooxml_alpha(run.fill_opacity)}"'
|
||||
)
|
||||
|
||||
@@ -73,6 +73,7 @@ EMU_PER_INCH = 914400
|
||||
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
MARKUP_COMPATIBILITY_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
|
||||
OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
|
||||
IMAGE_EXT_BY_CONTENT_TYPE = {
|
||||
@@ -186,6 +187,34 @@ def _safe_position(shape: object, attr: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _expose_math_choice_shapes(slide: object) -> None:
|
||||
"""Unwrap a14 Office Math Choice shapes so ``slide.shapes`` sees them.
|
||||
|
||||
python-pptx skips every ``mc:AlternateContent`` in the shape tree, which
|
||||
hides formula text boxes and the ordinary text around their math. Only the
|
||||
in-memory read view changes; the source file is never written.
|
||||
"""
|
||||
alternate_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}AlternateContent"
|
||||
choice_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}Choice"
|
||||
shape_tags = {f"{{{PRESENTATIONML_NS}}}sp", f"{{{PRESENTATIONML_NS}}}grpSp"}
|
||||
for wrapper in list(slide.element.iter(alternate_tag)):
|
||||
choice = wrapper.find(choice_tag)
|
||||
parent = wrapper.getparent()
|
||||
if (
|
||||
choice is None
|
||||
or parent is None
|
||||
or "a14" not in (choice.get("Requires") or "").split()
|
||||
):
|
||||
continue
|
||||
shapes = list(choice)
|
||||
if not shapes or any(shape.tag not in shape_tags for shape in shapes):
|
||||
continue
|
||||
index = parent.index(wrapper)
|
||||
parent.remove(wrapper)
|
||||
for offset, shape in enumerate(shapes):
|
||||
parent.insert(index + offset, shape)
|
||||
|
||||
|
||||
def iter_leaf_shapes(shapes: object) -> list[LeafShape]:
|
||||
"""Return a flattened, reading-order list of shapes."""
|
||||
items: list[LeafShape] = []
|
||||
@@ -1278,6 +1307,7 @@ def convert_presentation_to_markdown(
|
||||
}
|
||||
emitted_diagram_ids: set[str] = set()
|
||||
emitted_chart_relationship_ids: set[str] = set()
|
||||
_expose_math_choice_shapes(slide)
|
||||
for item in iter_leaf_shapes(slide.shapes):
|
||||
shape = item.shape
|
||||
|
||||
|
||||
+4
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pptx_ooxml.package import PackagePartRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .theme_colors import ThemeColorSpec
|
||||
from .theme_fonts import ThemeFontSpec
|
||||
@@ -84,6 +86,7 @@ class ConvertContext:
|
||||
rel_entries: list[dict[str, str]] = field(default_factory=list)
|
||||
package_files: dict[str, bytes] = field(default_factory=dict)
|
||||
content_type_overrides: dict[str, str] = field(default_factory=dict)
|
||||
package_parts: PackagePartRegistry = field(default_factory=PackagePartRegistry)
|
||||
rel_id_counter: int = 2 # rId1 reserved for slideLayout
|
||||
svg_dir: Path | None = None
|
||||
# Explicit project boundary for deterministic resource resolution. Public
|
||||
@@ -266,6 +269,7 @@ class ConvertContext:
|
||||
rel_entries=self.rel_entries,
|
||||
package_files=self.package_files,
|
||||
content_type_overrides=self.content_type_overrides,
|
||||
package_parts=self.package_parts,
|
||||
rel_id_counter=self.rel_id_counter,
|
||||
svg_dir=self.svg_dir,
|
||||
resource_root=self.resource_root,
|
||||
|
||||
+75
-1
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from native_payloads import NativePayloadError, hydrate_native_payload_refs
|
||||
from pptx_ooxml.package import PackagePartRegistry
|
||||
from hyperlink_contract import (
|
||||
SHAPE_HYPERLINK_ATTR,
|
||||
project_hyperlink_errors,
|
||||
@@ -125,8 +126,11 @@ from ..native_objects import (
|
||||
native_replacement_kind,
|
||||
native_marker_transform,
|
||||
require_fresh_native_fallback,
|
||||
reserve_source_chart_parts,
|
||||
snapshot_native_fallback_freshness,
|
||||
)
|
||||
from ..native_objects.formula import formula_fallback_xml
|
||||
from ..native_objects.inline_formula import wrap_inline_formula_shape
|
||||
from ..native_objects.marker_status import native_marker_status_errors
|
||||
from ..semantic_markers import is_static_page_frame
|
||||
|
||||
@@ -724,6 +728,60 @@ def _append_shape_text(
|
||||
)
|
||||
|
||||
|
||||
_A14_NS = 'http://schemas.microsoft.com/office/drawing/2010/main'
|
||||
_A14_MATH_TAG = f'{{{_A14_NS}}}m'
|
||||
# Generated txBody fragments rely on the slide's prefixes; bind them to parse.
|
||||
_TXBODY_PREFIXES = (
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
|
||||
f'xmlns:a="{DRAWINGML_NS}" xmlns:a14="{_A14_NS}" '
|
||||
'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"'
|
||||
)
|
||||
|
||||
|
||||
def _txbody_has_office_math(txbody_xml: str) -> bool:
|
||||
try:
|
||||
body = re.sub(r'^\s*<\?xml[^>]*\?>', '', txbody_xml)
|
||||
root = ET.fromstring(f'<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:
|
||||
"""Remove authoring-only hiding from a semantic geometry carrier clone."""
|
||||
for name in ('visibility', 'display', 'pointer-events'):
|
||||
@@ -847,7 +905,12 @@ def _convert_semantic_shape(
|
||||
'Semantic shape geometry carrier produced no native shape'
|
||||
)
|
||||
text_body = _semantic_shape_text_body(shape, ctx)
|
||||
return _append_shape_text(geometry, text_body) if text_body else geometry
|
||||
if not text_body:
|
||||
return geometry
|
||||
restored = _append_shape_text(geometry, text_body)
|
||||
if _txbody_has_office_math(text_body):
|
||||
return _with_office_math_fallback(shape, restored, geometry, ctx)
|
||||
return restored
|
||||
|
||||
|
||||
def _restore_placeholder_sp_pr(
|
||||
@@ -1313,6 +1376,11 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
native_text,
|
||||
)
|
||||
restored = _restore_placeholder_sp_pr(restored, elem)
|
||||
if _txbody_has_office_math(native_text):
|
||||
restored = _with_office_math_fallback(
|
||||
elem, restored, geometry_result, geometry_ctx,
|
||||
)
|
||||
ctx.sync_from_child(geometry_ctx)
|
||||
if should_animate_group and elem_id:
|
||||
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
|
||||
if shape_match:
|
||||
@@ -1969,6 +2037,7 @@ def convert_svg_to_slide_shapes(
|
||||
promote_background: bool = True,
|
||||
text_flow: str | None = None,
|
||||
dangerous_nonconforming_export: bool = False,
|
||||
package_parts: PackagePartRegistry | None = None,
|
||||
) -> tuple[
|
||||
str,
|
||||
dict[str, bytes],
|
||||
@@ -2017,6 +2086,8 @@ def convert_svg_to_slide_shapes(
|
||||
Parsing, resource, conversion, and package failures remain blocking.
|
||||
resource_root: Explicit project boundary for local images and icons.
|
||||
Omit only for direct library calls that require legacy inference.
|
||||
package_parts: Shared native-part registry for the complete export,
|
||||
including the template/source package and previously rebuilt slides.
|
||||
|
||||
Returns:
|
||||
(slide_xml, media_files, rel_entries, anim_targets,
|
||||
@@ -2375,12 +2446,15 @@ def convert_svg_to_slide_shapes(
|
||||
theme_font_spec=theme_font_spec,
|
||||
theme_color_spec=theme_color_spec,
|
||||
primary_language=primary_language,
|
||||
package_parts=package_parts if package_parts is not None else PackagePartRegistry(),
|
||||
inherited_styles=_extract_inheritable_styles(root),
|
||||
opacity_multiplier=1.0 if root_opacity is None else root_opacity,
|
||||
parent_by_id={id(child): parent for parent in root.iter() for child in parent},
|
||||
text_font_sizes=text_font_sizes,
|
||||
text_letter_spacings=text_letter_spacings,
|
||||
)
|
||||
if native_objects:
|
||||
reserve_source_chart_parts(root, ctx)
|
||||
shapes: list[str] = []
|
||||
converted = 0
|
||||
skipped = 0
|
||||
|
||||
+76
-45
@@ -3085,6 +3085,15 @@ def _text_axis_transform(ctx: ConvertContext) -> tuple[bool, bool, bool]:
|
||||
return True, a < 0, d < 0
|
||||
|
||||
|
||||
def _inline_formula_preview_run(run: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return an inline formula run as its ordinary SVG preview-text run."""
|
||||
if run.get(_INLINE_FORMULA_KEY) is None:
|
||||
return run
|
||||
preview = dict(run)
|
||||
del preview[_INLINE_FORMULA_KEY]
|
||||
return preview
|
||||
|
||||
|
||||
def _build_run_xml(
|
||||
run: dict[str, Any],
|
||||
default_fonts: dict[str, str],
|
||||
@@ -3590,47 +3599,61 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
shape_id = _claim_element_shape_id(elem, ctx)
|
||||
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
||||
|
||||
if paragraph_runs is not None:
|
||||
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
||||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||||
paragraph_xml_chunks = []
|
||||
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
||||
spc_bef_xml = ''
|
||||
if extra_px > 0:
|
||||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
||||
first_text_run = next(
|
||||
(run for run in line if not run.get('_line_break')),
|
||||
None,
|
||||
def _paragraphs_xml(formula_previews: bool) -> str:
|
||||
# The MCE Fallback copy writes each inline formula as its SVG preview
|
||||
# text so a reader without a14 math still shows the formula.
|
||||
def _runs_xml(line_runs: list[dict[str, Any]]) -> str:
|
||||
return '\n'.join(
|
||||
_build_run_xml(
|
||||
_inline_formula_preview_run(r) if formula_previews else r,
|
||||
fonts,
|
||||
ctx,
|
||||
text_effect_xml,
|
||||
)
|
||||
for r in line_runs
|
||||
)
|
||||
effective_line_spacing = (
|
||||
''
|
||||
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
|
||||
else ln_spc_xml
|
||||
)
|
||||
p_pr_xml = _paragraph_pr_xml(
|
||||
algn=algn,
|
||||
font_size=(
|
||||
float(first_text_run.get('font_size', font_size))
|
||||
if first_text_run is not None
|
||||
else font_size
|
||||
),
|
||||
body_xml=f'{effective_line_spacing}{spc_bef_xml}',
|
||||
bullet=bullet,
|
||||
ctx=ctx,
|
||||
rtl=text_uses_rtl(
|
||||
''.join(str(run.get('text', '')) for run in line),
|
||||
ctx.primary_language,
|
||||
),
|
||||
)
|
||||
paragraph_xml_chunks.append(
|
||||
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
||||
)
|
||||
paragraphs_xml = '\n'.join(paragraph_xml_chunks)
|
||||
else:
|
||||
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
|
||||
|
||||
if paragraph_runs is not None:
|
||||
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
||||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||||
paragraph_xml_chunks = []
|
||||
for line, extra_px, bullet in zip(paragraph_runs, paragraph_space_before, paragraph_bullets):
|
||||
spc_bef_xml = ''
|
||||
if extra_px > 0:
|
||||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||||
runs_inner = _runs_xml(line)
|
||||
first_text_run = next(
|
||||
(run for run in line if not run.get('_line_break')),
|
||||
None,
|
||||
)
|
||||
effective_line_spacing = (
|
||||
''
|
||||
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in line)
|
||||
else ln_spc_xml
|
||||
)
|
||||
p_pr_xml = _paragraph_pr_xml(
|
||||
algn=algn,
|
||||
font_size=(
|
||||
float(first_text_run.get('font_size', font_size))
|
||||
if first_text_run is not None
|
||||
else font_size
|
||||
),
|
||||
body_xml=f'{effective_line_spacing}{spc_bef_xml}',
|
||||
bullet=bullet,
|
||||
ctx=ctx,
|
||||
rtl=text_uses_rtl(
|
||||
''.join(str(run.get('text', '')) for run in line),
|
||||
ctx.primary_language,
|
||||
),
|
||||
)
|
||||
paragraph_xml_chunks.append(
|
||||
f'<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(
|
||||
algn=algn,
|
||||
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
|
||||
@@ -3638,7 +3661,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
ctx=ctx,
|
||||
rtl=text_uses_rtl(full_text, ctx.primary_language),
|
||||
)
|
||||
paragraphs_xml = f'<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_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>'
|
||||
)
|
||||
|
||||
shape_xml = f'''<p:sp>
|
||||
def _shape_xml(sp_id: int, sp_paragraphs_xml: str) -> str:
|
||||
return f'''<p:sp>
|
||||
<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:nvSpPr>
|
||||
<p:spPr>
|
||||
@@ -3718,12 +3744,17 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
<p:txBody>
|
||||
{body_pr_xml}
|
||||
<a:lstStyle/>
|
||||
{paragraphs_xml}
|
||||
{sp_paragraphs_xml}
|
||||
</p:txBody>
|
||||
</p:sp>'''
|
||||
|
||||
shape_xml = _shape_xml(shape_id, paragraphs_xml)
|
||||
if any(run.get(_INLINE_FORMULA_KEY) is not None for run in runs):
|
||||
from ..native_objects.inline_formula import wrap_inline_formula_shape
|
||||
shape_xml = wrap_inline_formula_shape(shape_xml)
|
||||
shape_xml = wrap_inline_formula_shape(
|
||||
shape_xml,
|
||||
_shape_xml(ctx.next_id(), _paragraphs_xml(True)),
|
||||
)
|
||||
return ShapeResult(
|
||||
xml=shape_xml,
|
||||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||||
|
||||
+68
-30
@@ -339,6 +339,23 @@ def _grow_chart_frame_for_axis_labels(
|
||||
return off_x, off_y, ext_cx, ext_cy
|
||||
|
||||
|
||||
def reserve_source_chart_parts(root: ET.Element, ctx: ConvertContext) -> None:
|
||||
"""Reserve source chart packages even when they follow newly authored charts."""
|
||||
for elem in root.iter():
|
||||
if native_replacement_kind(elem) != "chart":
|
||||
continue
|
||||
source = _decode_source_chart_package(_load_payload(elem, "chart"))
|
||||
if source is not None:
|
||||
ctx.package_parts.reserve_source(source[2], _chart_part_owner(elem, ctx))
|
||||
|
||||
|
||||
def _chart_part_owner(elem: ET.Element, ctx: ConvertContext) -> str:
|
||||
return (
|
||||
f"slide {ctx.slide_num} chart {elem.get('id')!r} "
|
||||
f"(source shape {elem.get('data-pptx-shape-id')!r})"
|
||||
)
|
||||
|
||||
|
||||
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
|
||||
source_package = _decode_source_chart_package(payload)
|
||||
chart_data = None if source_package is not None else _chart_data(payload)
|
||||
@@ -349,17 +366,29 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
axis_font_px=text_sizes["axis"] / 75,
|
||||
)
|
||||
|
||||
shape_id = (
|
||||
ctx.claim_shape_id(
|
||||
elem.get("data-pptx-shape-id"),
|
||||
elem.get("data-pptx-shape-scope"),
|
||||
)
|
||||
if source_package is not None
|
||||
else ctx.next_id()
|
||||
source_id = elem.get("data-pptx-shape-id")
|
||||
shape_id = ctx.claim_shape_id(
|
||||
source_id,
|
||||
elem.get("data-pptx-shape-scope"),
|
||||
)
|
||||
rel_id = ctx.next_rel_id()
|
||||
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
|
||||
part_index = ctx.slide_num * 100 + local_index
|
||||
if source_package is None:
|
||||
is_chartex = chart_data is not None and chart_data["kind"] == "chartex"
|
||||
chart_pattern = "chartEx{}.xml" if is_chartex else "chart{}.xml"
|
||||
patterns = (
|
||||
f"ppt/charts/{chart_pattern}",
|
||||
f"ppt/charts/_rels/{chart_pattern}.rels",
|
||||
"ppt/embeddings/Microsoft_Excel_Sheet{}.xlsx",
|
||||
)
|
||||
if is_chartex:
|
||||
patterns += ("ppt/charts/style{}.xml", "ppt/charts/colors{}.xml")
|
||||
part_index = ctx.package_parts.next_index(
|
||||
patterns, part_index, ctx.package_files.keys() | ctx.content_type_overrides.keys(),
|
||||
)
|
||||
parts: dict[str, bytes] = {}
|
||||
content_types: dict[str, str] = {}
|
||||
workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
|
||||
workbook_part = f"ppt/embeddings/{workbook_name}"
|
||||
if source_package is not None:
|
||||
@@ -372,14 +401,8 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
"type": CHART_REL_TYPE,
|
||||
"target": posixpath.relpath(chart_part, "ppt/slides"),
|
||||
})
|
||||
for part_name, part_payload in source_parts.items():
|
||||
existing = ctx.package_files.get(part_name)
|
||||
if existing is not None and existing != part_payload:
|
||||
raise RuntimeError(
|
||||
f"Native PPTX chart source package part collision: {part_name}"
|
||||
)
|
||||
ctx.package_files[part_name] = part_payload
|
||||
ctx.content_type_overrides.update(source_content_types)
|
||||
parts = source_parts
|
||||
content_types = source_content_types
|
||||
elif chart_data is not None and chart_data["kind"] == "chartex":
|
||||
chart_name = f"chartEx{part_index}.xml"
|
||||
style_name = f"style{part_index}.xml"
|
||||
@@ -399,18 +422,18 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
"type": CHARTEX_REL_TYPE,
|
||||
"target": f"../charts/{chart_name}",
|
||||
})
|
||||
ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
|
||||
ctx.package_files[chart_rels_part] = _chart_ex_rels_xml(
|
||||
parts[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
|
||||
parts[chart_rels_part] = _chart_ex_rels_xml(
|
||||
f"../embeddings/{workbook_name}",
|
||||
style_name,
|
||||
colors_name,
|
||||
)
|
||||
ctx.package_files[style_part] = _chart_ex_style_xml()
|
||||
ctx.package_files[colors_part] = _chart_ex_colors_xml(payload)
|
||||
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
|
||||
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
|
||||
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
|
||||
ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
|
||||
parts[style_part] = _chart_ex_style_xml()
|
||||
parts[colors_part] = _chart_ex_colors_xml(payload)
|
||||
parts[workbook_part] = _minimal_chart_ex_workbook(chart_data)
|
||||
content_types[chart_part] = CHARTEX_CONTENT_TYPE
|
||||
content_types[style_part] = CHART_STYLE_CONTENT_TYPE
|
||||
content_types[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
|
||||
else:
|
||||
chart_name = f"chart{part_index}.xml"
|
||||
chart_part = f"ppt/charts/{chart_name}"
|
||||
@@ -425,7 +448,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
"type": CHART_REL_TYPE,
|
||||
"target": f"../charts/{chart_name}",
|
||||
})
|
||||
ctx.package_files[chart_part] = _chart_xml(
|
||||
parts[chart_part] = _chart_xml(
|
||||
elem,
|
||||
payload,
|
||||
chart_rels_id="rId1",
|
||||
@@ -434,15 +457,22 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
primary_language=ctx.primary_language,
|
||||
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
|
||||
)
|
||||
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
|
||||
parts[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
|
||||
assert chart_data is not None
|
||||
if chart_data["kind"] == "xy":
|
||||
ctx.package_files[workbook_part] = _minimal_xy_chart_workbook(chart_data)
|
||||
parts[workbook_part] = _minimal_xy_chart_workbook(chart_data)
|
||||
else:
|
||||
ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data)
|
||||
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
|
||||
parts[workbook_part] = _minimal_category_chart_workbook(chart_data)
|
||||
content_types[chart_part] = CHART_CONTENT_TYPE
|
||||
|
||||
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
|
||||
ctx.package_parts.register(
|
||||
parts, content_types, _chart_part_owner(elem, ctx), source=source_package is not None,
|
||||
)
|
||||
ctx.package_files.update(parts)
|
||||
ctx.content_type_overrides.update(content_types)
|
||||
|
||||
source_name = elem.get("data-pptx-shape-name") if source_id is not None else None
|
||||
name = _xml_escape(str(source_name or payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
|
||||
chart_frame_xml = (
|
||||
_source_chart_frame_xml(
|
||||
source_frame,
|
||||
@@ -494,10 +524,18 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
x0, y0, x1, y1 = companion.bounds_emu
|
||||
min_x, min_y = min(min_x, x0), min(min_y, y0)
|
||||
max_x, max_y = max(max_x, x1), max(max_y, y1)
|
||||
companion_xml = "".join(companion.xml for companion in companions)
|
||||
if source_id is not None:
|
||||
# Preserve the imported frame's hierarchy and Morph object type.
|
||||
# Frame-first output keeps animation and trace consumers on its id.
|
||||
return ShapeResult(
|
||||
xml=chart_frame_xml + companion_xml,
|
||||
bounds_emu=(min_x, min_y, max_x, max_y),
|
||||
)
|
||||
|
||||
group_w, group_h = max_x - min_x, max_y - min_y
|
||||
validate_ooxml_xfrm(min_x, min_y, group_w, group_h)
|
||||
group_id = ctx.next_id()
|
||||
companion_xml = "".join(companion.xml for companion in companions)
|
||||
# The marker owns one selectable/animatable unit. Identity mapping keeps
|
||||
# the chart and labels at their already resolved slide coordinates.
|
||||
xml = f'''<p:grpSp>
|
||||
|
||||
+54
-3
@@ -196,20 +196,71 @@ def _styled_omml(spec: FormulaSpec) -> str:
|
||||
return serialize_styled_formula_omml(root)
|
||||
|
||||
|
||||
def formula_fallback_xml(
|
||||
results: list[ShapeResult],
|
||||
ctx: ConvertContext,
|
||||
name: str,
|
||||
) -> str:
|
||||
"""Join converted preview shapes into one MCE Fallback shape (or none)."""
|
||||
if not results:
|
||||
return ""
|
||||
if len(results) == 1:
|
||||
return results[0].xml
|
||||
bounds = [result.bounds_emu for result in results if result.bounds_emu]
|
||||
if not bounds:
|
||||
return "\n".join(result.xml for result in results)
|
||||
min_x = min(box[0] for box in bounds)
|
||||
min_y = min(box[1] for box in bounds)
|
||||
ext_cx = max(max(box[2] for box in bounds) - min_x, 1)
|
||||
ext_cy = max(max(box[3] for box in bounds) - min_y, 1)
|
||||
shapes_xml = "\n".join(result.xml for result in results)
|
||||
return f'''<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(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext,
|
||||
payload: dict[str, Any],
|
||||
spec: FormulaSpec | None = None,
|
||||
) -> ShapeResult:
|
||||
"""Build one Choice-only editable formula with no image fallback."""
|
||||
"""Build one editable formula whose MCE Fallback is its SVG preview."""
|
||||
formula_spec = spec or validate_formula_payload(payload, ctx=ctx)
|
||||
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
|
||||
shape_id = ctx.next_id()
|
||||
shape_id = ctx.claim_shape_id(
|
||||
elem.get("data-pptx-shape-id"),
|
||||
elem.get("data-pptx-shape-scope"),
|
||||
)
|
||||
name = _xml_escape(
|
||||
str(payload.get("name") or elem.get("id") or f"Formula {shape_id}")
|
||||
)
|
||||
omml = _styled_omml(formula_spec)
|
||||
fallback_xml = _preview_fallback_xml(elem, ctx, name)
|
||||
fallback = f"\n<mc:Fallback>\n{fallback_xml}\n</mc:Fallback>" if fallback_xml else ""
|
||||
|
||||
xml = f'''<mc:AlternateContent xmlns:mc="{MC_NS}">
|
||||
<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">
|
||||
@@ -235,7 +286,7 @@ def build_native_formula(
|
||||
</a:p>
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</mc:Choice>
|
||||
</mc:Choice>{fallback}
|
||||
</mc:AlternateContent>'''
|
||||
return ShapeResult(
|
||||
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>"
|
||||
|
||||
|
||||
def wrap_inline_formula_shape(shape_xml: str) -> str:
|
||||
"""Wrap a text shape containing ``a14:m`` in its required MCE Choice."""
|
||||
def wrap_inline_formula_shape(shape_xml: str, fallback_xml: str) -> str:
|
||||
"""Wrap an ``a14:m`` text shape in its MCE Choice plus preview-text Fallback."""
|
||||
return (
|
||||
f'<mc:AlternateContent xmlns:mc="{MC_NS}">'
|
||||
f'<mc:Choice xmlns:a14="{A14_NS}" Requires="a14">'
|
||||
f"{shape_xml}"
|
||||
"</mc:Choice>"
|
||||
f"<mc:Fallback>{fallback_xml}</mc:Fallback>"
|
||||
"</mc:AlternateContent>"
|
||||
)
|
||||
|
||||
|
||||
+128
-20
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.parsers import expat
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
from pptx import Presentation
|
||||
@@ -71,7 +72,7 @@ from pptx_opc_validation import (
|
||||
)
|
||||
from pptx_workspace import WorkspaceResourceSpec
|
||||
from pptx_ooxml.clone import clone_presentation_slides
|
||||
from pptx_ooxml.package import prune_unreferenced_directory_parts
|
||||
from pptx_ooxml.package import PackagePartRegistry, prune_unreferenced_directory_parts
|
||||
from language_tags import language_uses_rtl, office_language_tag
|
||||
from hyperlink_contract import (
|
||||
HYPERLINK_REL_TYPE,
|
||||
@@ -606,11 +607,94 @@ def _set_shape_name(elem: ET.Element, name: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _roundtrip_morph_shape_ids(svg_path: Path, group_ids: set[str]) -> dict[tuple[str, str], int]:
|
||||
"""Resolve preserved endpoints from imported identity, never SVG id spelling."""
|
||||
if not group_ids:
|
||||
return {}
|
||||
root = ET.parse(svg_path).getroot()
|
||||
result: dict[tuple[str, str], int] = {}
|
||||
for group_id in sorted(group_ids):
|
||||
groups = [node for node in root if node.tag == '{http://www.w3.org/2000/svg}g'
|
||||
and node.get('id') == group_id]
|
||||
shape_id = groups[0].get('data-pptx-shape-id', '') if len(groups) == 1 else ''
|
||||
if (not shape_id.isdecimal()
|
||||
or groups[0].get('data-pptx-shape-scope') != 'slide'):
|
||||
raise TemplateStructureError(
|
||||
f'Morph target "{svg_path.stem}/{group_id}" has no unique imported '
|
||||
'Slide-local shape identity; edit this endpoint page as well, or '
|
||||
'pair groups on two edited pages'
|
||||
)
|
||||
result[(svg_path.stem, group_id)] = int(shape_id)
|
||||
return result
|
||||
|
||||
|
||||
_XML_START_TAG_RE = re.compile(r'''<(?:"[^"]*"|'[^']*'|[^'">])*>''')
|
||||
_XML_ATTRIBUTE_RE = re.compile(r'''\s+([^\s=/>]+)\s*=\s*(["'])(.*?)\2''', re.DOTALL)
|
||||
|
||||
|
||||
def _patch_morph_name_bytes(
|
||||
source: bytes,
|
||||
assignments: dict[str, tuple[str, str]],
|
||||
slide_number: int,
|
||||
) -> bytes:
|
||||
"""Replace only cNvPr name values, retaining all other source XML bytes."""
|
||||
parser = expat.ParserCreate(namespace_separator='}')
|
||||
offsets: dict[str, list[tuple[int, str | None]]] = {}
|
||||
encoding = 'utf-8'
|
||||
|
||||
def declaration(_version: str, declared_encoding: str | None, _standalone: int) -> None:
|
||||
nonlocal encoding
|
||||
encoding = declared_encoding or encoding
|
||||
|
||||
def start(name: str, attributes: dict[str, str]) -> None:
|
||||
shape_id = attributes.get('id')
|
||||
if name == f'{PML_NS}}}cNvPr' and shape_id in assignments:
|
||||
offsets.setdefault(shape_id, []).append((parser.CurrentByteIndex, attributes.get('name')))
|
||||
|
||||
parser.XmlDeclHandler = declaration
|
||||
parser.StartElementHandler = start
|
||||
parser.Parse(source, True)
|
||||
# Byte offsets come from Expat; encode individual spans without a BOM.
|
||||
if source.startswith((b'\xff\xfe', b'<\x00')):
|
||||
encoding = 'utf-16-le'
|
||||
elif source.startswith((b'\xfe\xff', b'\x00<')):
|
||||
encoding = 'utf-16-be'
|
||||
replacements: list[tuple[int, int, bytes]] = []
|
||||
for shape_id, (name, group_id) in assignments.items():
|
||||
locations = offsets.get(shape_id, [])
|
||||
error = (
|
||||
f'Morph target "{group_id}" on slide {slide_number} cannot patch '
|
||||
f'cNvPr@name for source shape {shape_id} without rewriting XML; '
|
||||
'edit this endpoint page as well, or pair groups on two edited pages'
|
||||
)
|
||||
if len(locations) != 1:
|
||||
raise TemplateStructureError(error)
|
||||
offset, existing_name = locations[0]
|
||||
if existing_name == name:
|
||||
continue
|
||||
tag = _XML_START_TAG_RE.match(source[offset:].decode(encoding))
|
||||
attribute = next(
|
||||
(match for match in _XML_ATTRIBUTE_RE.finditer(tag.group()) if match[1] == 'name'),
|
||||
None,
|
||||
) if tag is not None else None
|
||||
if attribute is None:
|
||||
raise TemplateStructureError(error)
|
||||
start_offset = offset + len(tag.group()[:attribute.start(3)].encode(encoding))
|
||||
end_offset = offset + len(tag.group()[:attribute.end(3)].encode(encoding))
|
||||
escaped = escape(name, {'"': '"', "'": '''})
|
||||
replacements.append((start_offset, end_offset, escaped.encode(encoding, errors='xmlcharrefreplace')))
|
||||
for start_offset, end_offset, value in sorted(replacements, reverse=True):
|
||||
source = source[:start_offset] + value + source[end_offset:]
|
||||
return source
|
||||
|
||||
|
||||
def _apply_morph_shape_names(
|
||||
extract_dir: Path,
|
||||
pairs: tuple[MorphPair, ...],
|
||||
slide_numbers: dict[str, int],
|
||||
shape_ids: dict[tuple[str, str], int],
|
||||
*,
|
||||
preserved_slides: set[int],
|
||||
) -> dict[int, dict[str, str]]:
|
||||
"""Write forced-Morph names after all structure transformations finish."""
|
||||
assignments: dict[int, dict[str, tuple[str, str]]] = {}
|
||||
@@ -621,14 +705,21 @@ def _apply_morph_shape_names(
|
||||
(pair.destination_slide, pair.destination_group_id),
|
||||
):
|
||||
slide_number = slide_numbers[slide_name]
|
||||
shape_id = str(shape_ids[(slide_name, group_id)])
|
||||
resolved_id = shape_ids.get((slide_name, group_id))
|
||||
if resolved_id is None:
|
||||
raise TemplateStructureError(
|
||||
f'Morph target "{slide_name}/{group_id}" has no exported '
|
||||
'Slide-local shape identity; edit this endpoint page as well, '
|
||||
'or pair groups on two edited pages'
|
||||
)
|
||||
shape_id = str(resolved_id)
|
||||
slide_assignments = assignments.setdefault(slide_number, {})
|
||||
previous = slide_assignments.setdefault(
|
||||
shape_id,
|
||||
(pair.shape_name, group_id),
|
||||
)
|
||||
if previous[0] != pair.shape_name:
|
||||
raise RuntimeError(
|
||||
raise TemplateStructureError(
|
||||
f'Morph target "{slide_name}/{group_id}" resolves to shape '
|
||||
f'{shape_id} with conflicting names "{previous[0]}" and '
|
||||
f'"{pair.shape_name}"'
|
||||
@@ -636,7 +727,7 @@ def _apply_morph_shape_names(
|
||||
slide_names = names_by_slide.setdefault(slide_number, {})
|
||||
previous_group = slide_names.setdefault(pair.shape_name, group_id)
|
||||
if previous_group != group_id:
|
||||
raise RuntimeError(
|
||||
raise TemplateStructureError(
|
||||
f'Morph name "{pair.shape_name}" maps to multiple objects '
|
||||
f'on slide "{slide_name}"'
|
||||
)
|
||||
@@ -661,7 +752,7 @@ def _apply_morph_shape_names(
|
||||
c_nv_pr.get("name") if c_nv_pr is not None else None
|
||||
)
|
||||
if existing_name in desired_names:
|
||||
raise RuntimeError(
|
||||
raise TemplateStructureError(
|
||||
f'Morph name "{existing_name}" already belongs to an '
|
||||
f'unmapped object on slide {slide_number}'
|
||||
)
|
||||
@@ -669,13 +760,18 @@ def _apply_morph_shape_names(
|
||||
for shape_id, (shape_name, group_id) in slide_assignments.items():
|
||||
shape = top_level_shapes.get(shape_id)
|
||||
if shape is None:
|
||||
raise RuntimeError(
|
||||
raise TemplateStructureError(
|
||||
f'Morph target "{group_id}" no longer resolves to a '
|
||||
f'Slide-local shape on slide {slide_number}'
|
||||
)
|
||||
_set_shape_name(shape, shape_name)
|
||||
trace_names.setdefault(slide_number, {})[group_id] = shape_name
|
||||
_write_xml_tree(slide_path, tree)
|
||||
if slide_number in preserved_slides:
|
||||
slide_path.write_bytes(_patch_morph_name_bytes(
|
||||
slide_path.read_bytes(), slide_assignments, slide_number,
|
||||
))
|
||||
else:
|
||||
_write_xml_tree(slide_path, tree)
|
||||
return trace_names
|
||||
|
||||
|
||||
@@ -6892,6 +6988,7 @@ def create_pptx_with_native_svg(
|
||||
set(),
|
||||
).add(pair.destination_group_id)
|
||||
morph_shape_ids: dict[tuple[str, str], int] = {}
|
||||
morph_preserved_slides: set[int] = set()
|
||||
if definition_svg_files and pptx_structure != "structured":
|
||||
raise ValueError(
|
||||
"unselected Layout prototypes require pptx_structure='structured'"
|
||||
@@ -7223,6 +7320,7 @@ def create_pptx_with_native_svg(
|
||||
image_exts_used: set[str] = set()
|
||||
package_exts_used: set[str] = set()
|
||||
package_content_overrides: dict[str, str] = {}
|
||||
package_parts = PackagePartRegistry(extract_dir, _content_type_overrides(extract_dir))
|
||||
notes_slides_created: set[int] = set()
|
||||
notes_master_parts_used: set[str] = set()
|
||||
notes_master_theme_parts_created: set[str] = set()
|
||||
@@ -7304,6 +7402,10 @@ def create_pptx_with_native_svg(
|
||||
raise TemplateStructureError(
|
||||
"Internal Layout definitions cannot use source-slide passthrough"
|
||||
)
|
||||
morph_shape_ids.update(_roundtrip_morph_shape_ids(
|
||||
svg_path, morph_group_overrides_by_slide.get(svg_path.stem, set()),
|
||||
))
|
||||
morph_preserved_slides.add(slide_num)
|
||||
if verbose:
|
||||
print(
|
||||
f" {progress_label} {svg_path.name} "
|
||||
@@ -7421,21 +7523,25 @@ def create_pptx_with_native_svg(
|
||||
continue
|
||||
if (
|
||||
slide_patch is not None
|
||||
and slide_patch.notes_changed
|
||||
and not slide_patch.visual_changed
|
||||
and not slide_patch.motion_changed
|
||||
and (slide_patch.notes_changed or morph_group_overrides_by_slide.get(svg_path.stem))
|
||||
):
|
||||
notes_content = (
|
||||
notes.get(svg_path.stem, '') if notes else ''
|
||||
)
|
||||
notes_master = _apply_slide_notes(
|
||||
extract_dir,
|
||||
rels_path,
|
||||
slide_num,
|
||||
notes_content,
|
||||
primary_language,
|
||||
enable_notes=enable_notes,
|
||||
)
|
||||
morph_shape_ids.update(_roundtrip_morph_shape_ids(
|
||||
svg_path, morph_group_overrides_by_slide.get(svg_path.stem, set()),
|
||||
))
|
||||
morph_preserved_slides.add(slide_num)
|
||||
notes_master = None
|
||||
if slide_patch.notes_changed:
|
||||
notes_content = notes.get(svg_path.stem, '') if notes else ''
|
||||
notes_master = _apply_slide_notes(
|
||||
extract_dir,
|
||||
rels_path,
|
||||
slide_num,
|
||||
notes_content,
|
||||
primary_language,
|
||||
enable_notes=enable_notes,
|
||||
)
|
||||
if notes_master is not None:
|
||||
notes_slides_created.add(slide_num)
|
||||
notes_master_parts_used.add(notes_master.package_part)
|
||||
@@ -7446,7 +7552,7 @@ def create_pptx_with_native_svg(
|
||||
if verbose:
|
||||
print(
|
||||
f" {progress_label} {svg_path.name} "
|
||||
"(Source slide notes overlay)"
|
||||
"(Source slide notes / identity overlay)"
|
||||
)
|
||||
success_count += 1
|
||||
continue
|
||||
@@ -7595,6 +7701,7 @@ def create_pptx_with_native_svg(
|
||||
image_scale=image_scale,
|
||||
image_quality=image_quality,
|
||||
native_objects=native_objects,
|
||||
package_parts=package_parts,
|
||||
animation_group_overrides=converter_group_overrides,
|
||||
theme_font_spec=active_theme_font_spec,
|
||||
theme_color_spec=active_theme_color_spec,
|
||||
@@ -8337,6 +8444,7 @@ def create_pptx_with_native_svg(
|
||||
morph_pairs,
|
||||
public_slide_numbers,
|
||||
morph_shape_ids,
|
||||
preserved_slides=morph_preserved_slides,
|
||||
)
|
||||
if template_shape_roster_expectations is not None:
|
||||
for slide_number in morph_trace_names:
|
||||
|
||||
+14
-1
@@ -134,6 +134,7 @@ from .template_structure import (
|
||||
from ..animation_config import (
|
||||
animation_group_effect_entries,
|
||||
load_animation_config,
|
||||
resolve_morph_pairs,
|
||||
resolve_slide_animation_config,
|
||||
validate_animation_config,
|
||||
validate_animation_config_errors,
|
||||
@@ -440,6 +441,18 @@ def _roundtrip_passthrough_candidates(
|
||||
current_animation_config,
|
||||
pages,
|
||||
)
|
||||
# Explicit identity edits also touch the source endpoint, whose own motion
|
||||
# row may still match the import baseline. Classify both ends as patches.
|
||||
morph_slides = {
|
||||
stem
|
||||
for pair in resolve_morph_pairs(
|
||||
[page.svg_stem for page in pages], expanded_animation_config,
|
||||
)
|
||||
for stem in (pair.source_slide, pair.destination_slide)
|
||||
}
|
||||
candidates.difference_update(
|
||||
page.output_index for page in pages if page.svg_stem in morph_slides
|
||||
)
|
||||
pages_by_output = {page.output_index: page for page in pages}
|
||||
for index in tuple(candidates):
|
||||
page = pages_by_output[index]
|
||||
@@ -2892,7 +2905,7 @@ Recorded narration:
|
||||
authoring_report=authoring_roundtrip_report,
|
||||
changed_resource_pages=changed_resource_pages,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if roundtrip_passthrough_slides:
|
||||
|
||||
+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 pathlib import Path
|
||||
|
||||
from lxml import etree
|
||||
from pptx import Presentation
|
||||
from pptx.oxml.xmlchemy import OxmlElement
|
||||
from pptx.util import Inches
|
||||
@@ -169,3 +170,17 @@ class PptxContentLossTests(unittest.TestCase):
|
||||
md = self._convert(prs)
|
||||
self.assertIn('| First<br>Second |', 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 = {
|
||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
|
||||
'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
|
||||
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||
}
|
||||
EMU_PER_PX = 9525
|
||||
@@ -333,6 +334,8 @@ class TextBaselineTests(unittest.TestCase):
|
||||
'<text x="100" y="200" font-size="40" font-family="Microsoft YaHei">'
|
||||
'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.assertIsNotNone(shape.find('.//m:oMath', NS))
|
||||
Reference in new issue
Block a user