163 lines
6.3 KiB
Python
163 lines
6.3 KiB
Python
"""ConvertContext — shared state passed through the SVG → DrawingML pipeline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
|
|
AffineMatrix = tuple[float, float, float, float, float, float]
|
|
IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
|
|
|
|
@dataclass
|
|
class ShapeResult:
|
|
"""Internal conversion result carrying XML plus resolved EMU bounds."""
|
|
|
|
xml: str
|
|
bounds_emu: tuple[int, int, int, int] | None = None
|
|
|
|
|
|
@dataclass
|
|
class ConvertContext:
|
|
"""Shared context passed through the SVG → DrawingML conversion pipeline.
|
|
|
|
Derived via child() during recursive SVG tree traversal to accumulate
|
|
translate / scale / inherited style information.
|
|
"""
|
|
|
|
defs: dict[str, ET.Element] = field(default_factory=dict)
|
|
id_counter: int = 2 # 1 is reserved for spTree root
|
|
slide_num: int = 1
|
|
translate_x: float = 0.0
|
|
translate_y: float = 0.0
|
|
scale_x: float = 1.0
|
|
scale_y: float = 1.0
|
|
transform_matrix: AffineMatrix = IDENTITY_MATRIX
|
|
use_transform_matrix: bool = False
|
|
filter_id: str | None = None
|
|
media_files: dict[str, bytes] = field(default_factory=dict)
|
|
rel_entries: list[dict[str, str]] = field(default_factory=list)
|
|
rel_id_counter: int = 2 # rId1 reserved for slideLayout
|
|
svg_dir: Path | None = None
|
|
inherited_styles: dict[str, str] = field(default_factory=dict)
|
|
# Recursion depth — only the depth==0 (root) context records anim targets.
|
|
depth: int = 0
|
|
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
|
|
# Used by the PPTX builder to emit per-element entrance timing.
|
|
anim_targets: list = field(default_factory=list)
|
|
# Default-on flag: merge mergeable paragraph blocks into one editable
|
|
# text frame with multiple <a:p>. Disable it for strict line fidelity.
|
|
merge_paragraphs: bool = True
|
|
# Optional per-element conversion diagnostics. Shared by child contexts so
|
|
# callers can inspect native / skipped / unsupported decisions per slide.
|
|
trace_events: list[dict[str, Any]] | None = None
|
|
|
|
def next_id(self) -> int:
|
|
"""Allocate the next shape ID."""
|
|
cid = self.id_counter
|
|
self.id_counter += 1
|
|
return cid
|
|
|
|
def next_rel_id(self) -> str:
|
|
"""Allocate the next relationship ID (rIdN)."""
|
|
rid = f'rId{self.rel_id_counter}'
|
|
self.rel_id_counter += 1
|
|
return rid
|
|
|
|
def child(
|
|
self,
|
|
dx: float = 0,
|
|
dy: float = 0,
|
|
sx: float = 1.0,
|
|
sy: float = 1.0,
|
|
transform_matrix: AffineMatrix | None = None,
|
|
filter_id: str | None = None,
|
|
style_overrides: dict[str, str] | None = None,
|
|
) -> ConvertContext:
|
|
"""Create a child context with accumulated translate / scale / styles.
|
|
|
|
Args:
|
|
dx: X translation delta.
|
|
dy: Y translation delta.
|
|
sx: X scale factor.
|
|
sy: Y scale factor.
|
|
transform_matrix: Full affine transform to accumulate for
|
|
converters that can faithfully map it to DrawingML.
|
|
filter_id: Override filter ID.
|
|
style_overrides: Style attribute overrides from child element.
|
|
"""
|
|
local_matrix = transform_matrix or IDENTITY_MATRIX
|
|
# When first crossing from scalar to matrix mode, fold accumulated
|
|
# translate_x/y and scale_x/y into the matrix base. Otherwise the
|
|
# ancestor's scalar transform — which matrix-path readers (e.g.
|
|
# <image>) never look at — is silently lost, and the descendant
|
|
# lands at raw SVG coordinates (typically near (0,0)).
|
|
if transform_matrix is not None and not self.use_transform_matrix:
|
|
base_matrix: AffineMatrix = (
|
|
self.scale_x, 0.0,
|
|
0.0, self.scale_y,
|
|
self.translate_x, self.translate_y,
|
|
)
|
|
else:
|
|
base_matrix = self.transform_matrix
|
|
a1, b1, c1, d1, e1, f1 = base_matrix
|
|
a2, b2, c2, d2, e2, f2 = local_matrix
|
|
combined_matrix: AffineMatrix = (
|
|
a1 * a2 + c1 * b2,
|
|
b1 * a2 + d1 * b2,
|
|
a1 * c2 + c1 * d2,
|
|
b1 * c2 + d1 * d2,
|
|
a1 * e2 + c1 * f2 + e1,
|
|
b1 * e2 + d1 * f2 + f1,
|
|
)
|
|
|
|
merged = dict(self.inherited_styles)
|
|
|
|
if style_overrides:
|
|
# Opacity is multiplicative, not a simple override
|
|
_OPACITY_KEYS = ('opacity', 'fill-opacity', 'stroke-opacity')
|
|
for op_key in _OPACITY_KEYS:
|
|
if op_key in style_overrides and op_key in merged:
|
|
try:
|
|
merged[op_key] = str(
|
|
float(merged[op_key]) * float(style_overrides[op_key])
|
|
)
|
|
except ValueError:
|
|
merged[op_key] = style_overrides[op_key]
|
|
elif op_key in style_overrides:
|
|
merged[op_key] = style_overrides[op_key]
|
|
|
|
for k, v in style_overrides.items():
|
|
if k not in _OPACITY_KEYS:
|
|
merged[k] = v
|
|
|
|
return ConvertContext(
|
|
defs=self.defs,
|
|
id_counter=self.id_counter,
|
|
slide_num=self.slide_num,
|
|
translate_x=self.translate_x + dx,
|
|
translate_y=self.translate_y + dy,
|
|
scale_x=self.scale_x * sx,
|
|
scale_y=self.scale_y * sy,
|
|
transform_matrix=combined_matrix,
|
|
use_transform_matrix=self.use_transform_matrix or transform_matrix is not None,
|
|
filter_id=filter_id or self.filter_id,
|
|
media_files=self.media_files,
|
|
rel_entries=self.rel_entries,
|
|
rel_id_counter=self.rel_id_counter,
|
|
svg_dir=self.svg_dir,
|
|
inherited_styles=merged,
|
|
depth=self.depth + 1,
|
|
# anim_targets is intentionally a fresh list on the child;
|
|
# only the root-level context's list is read by the builder.
|
|
merge_paragraphs=self.merge_paragraphs,
|
|
trace_events=self.trace_events,
|
|
)
|
|
|
|
def sync_from_child(self, child_ctx: ConvertContext) -> None:
|
|
"""Sync counters back from a child context."""
|
|
self.id_counter = child_ctx.id_counter
|
|
self.rel_id_counter = child_ctx.rel_id_counter
|