| 1 | """ConvertContext — shared state passed through the SVG → DrawingML pipeline.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from pathlib import Path |
| 6 | from typing import TYPE_CHECKING, Any |
| 7 | from xml.etree import ElementTree as ET |
| 8 | from dataclasses import dataclass, field |
| 9 | |
| 10 | if TYPE_CHECKING: |
| 11 | from .theme_colors import ThemeColorSpec |
| 12 | from .theme_fonts import ThemeFontSpec |
| 13 | |
| 14 | AffineMatrix = tuple[float, float, float, float, float, float] |
| 15 | IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) |
| 16 | |
| 17 | TEXT_FLOW_PRESERVE = 'preserve' |
| 18 | TEXT_FLOW_REFLOW = 'reflow' |
| 19 | TEXT_FLOW_SPLIT = 'split' |
| 20 | TEXT_FLOW_MODES = frozenset({ |
| 21 | TEXT_FLOW_PRESERVE, |
| 22 | TEXT_FLOW_REFLOW, |
| 23 | TEXT_FLOW_SPLIT, |
| 24 | }) |
| 25 | |
| 26 | |
| 27 | def resolve_text_flow( |
| 28 | text_flow: str | None = None, |
| 29 | merge_paragraphs: bool | None = None, |
| 30 | ) -> str: |
| 31 | """Resolve the public text-layout options to one internal mode.""" |
| 32 | if text_flow is not None and merge_paragraphs is not None: |
| 33 | raise ValueError( |
| 34 | 'text_flow and legacy merge_paragraphs cannot be used together' |
| 35 | ) |
| 36 | if merge_paragraphs is not None: |
| 37 | return TEXT_FLOW_REFLOW if merge_paragraphs else TEXT_FLOW_SPLIT |
| 38 | resolved = TEXT_FLOW_PRESERVE if text_flow is None else text_flow |
| 39 | if resolved not in TEXT_FLOW_MODES: |
| 40 | choices = ', '.join(sorted(TEXT_FLOW_MODES)) |
| 41 | raise ValueError( |
| 42 | f'unsupported text_flow {resolved!r}; expected one of: {choices}' |
| 43 | ) |
| 44 | return resolved |
| 45 | |
| 46 | |
| 47 | @dataclass |
| 48 | class ShapeResult: |
| 49 | """Internal conversion result carrying XML plus resolved EMU bounds.""" |
| 50 | |
| 51 | xml: str |
| 52 | bounds_emu: tuple[int, int, int, int] | None = None |
| 53 | |
| 54 | |
| 55 | @dataclass |
| 56 | class ConvertContext: |
| 57 | """Shared context passed through the SVG → DrawingML conversion pipeline. |
| 58 | |
| 59 | Derived via child() during recursive SVG tree traversal to accumulate |
| 60 | translate / scale / inherited style information. |
| 61 | """ |
| 62 | |
| 63 | defs: dict[str, ET.Element] = field(default_factory=dict) |
| 64 | id_counter: int = 2 # 1 is reserved for spTree root |
| 65 | # Imported PPTX shape ids are reserved before conversion so newly authored |
| 66 | # SVG elements cannot steal an id referenced by a native connector. |
| 67 | reserved_shape_ids: frozenset[int] = frozenset() |
| 68 | source_shape_id_map: dict[tuple[str, str], int] = field(default_factory=dict) |
| 69 | claimed_shape_ids: set[int] = field(default_factory=set) |
| 70 | referenced_shape_ids: set[int] = field(default_factory=set) |
| 71 | slide_num: int = 1 |
| 72 | translate_x: float = 0.0 |
| 73 | translate_y: float = 0.0 |
| 74 | scale_x: float = 1.0 |
| 75 | scale_y: float = 1.0 |
| 76 | viewport_width: float = 1280.0 |
| 77 | viewport_height: float = 720.0 |
| 78 | transform_matrix: AffineMatrix = IDENTITY_MATRIX |
| 79 | use_transform_matrix: bool = False |
| 80 | filter_id: str | None = None |
| 81 | media_files: dict[str, bytes] = field(default_factory=dict) |
| 82 | rel_entries: list[dict[str, str]] = field(default_factory=list) |
| 83 | package_files: dict[str, bytes] = field(default_factory=dict) |
| 84 | content_type_overrides: dict[str, str] = field(default_factory=dict) |
| 85 | rel_id_counter: int = 2 # rId1 reserved for slideLayout |
| 86 | svg_dir: Path | None = None |
| 87 | inherited_styles: dict[str, str] = field(default_factory=dict) |
| 88 | # Effective SVG font sizes keyed by element identity. Shared resolution |
| 89 | # keeps relative sizes and em tracking identical across checker/exporter. |
| 90 | text_font_sizes: dict[int, float] = field(default_factory=dict) |
| 91 | # Effective source-pixel tracking resolved where each declaration occurs. |
| 92 | text_letter_spacings: dict[int, float] = field(default_factory=dict) |
| 93 | # SVG group opacity is post-compositing, not an inherited presentation |
| 94 | # property. DrawingML has no equivalent group alpha, so native export |
| 95 | # approximates it by multiplying this value into each descendant object. |
| 96 | opacity_multiplier: float = 1.0 |
| 97 | # Recursion depth — only the depth==0 (root) context records anim targets. |
| 98 | depth: int = 0 |
| 99 | # Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order. |
| 100 | # Used by the PPTX builder to emit per-element object-animation timing. |
| 101 | anim_targets: list = field(default_factory=list) |
| 102 | # Explicit sidecar group ids may override the legacy chrome-name heuristic. |
| 103 | # Explicit structural layer/role/placeholder markers remain non-animatable. |
| 104 | animation_group_overrides: frozenset[str] = frozenset() |
| 105 | # Text-layout policy for positional tspans: preserve authored line breaks |
| 106 | # in one frame, reflow them, or split them into independent frames. |
| 107 | text_flow: str = TEXT_FLOW_PRESERVE |
| 108 | # Explicit opt-in: replace marked chart/table fallback groups with editable |
| 109 | # PowerPoint graphicFrames. Default stays off to preserve SVG output. |
| 110 | native_objects_enabled: bool = False |
| 111 | # Native PPTX image optimization. Keeps generated decks compact by |
| 112 | # downsampling oversized raster assets to their rendered size. |
| 113 | image_optimize: bool = True |
| 114 | image_max_dimension: int | None = 2560 |
| 115 | image_sizing: str = 'cap' |
| 116 | image_scale: float = 2.0 |
| 117 | image_quality: int = 85 |
| 118 | # Optional per-element conversion diagnostics. Shared by child contexts so |
| 119 | # callers can inspect native / skipped / unsupported decisions per slide. |
| 120 | trace_events: list[dict[str, Any]] | None = None |
| 121 | # Optional project theme contract. Matching SVG title/body families emit |
| 122 | # DrawingML +mj/+mn tokens instead of fixed typeface names. |
| 123 | theme_font_spec: ThemeFontSpec | None = None |
| 124 | # Optional project theme-color contract. Exact locked colors are promoted |
| 125 | # to context-safe DrawingML scheme slots while local colors stay concrete. |
| 126 | theme_color_spec: ThemeColorSpec | None = None |
| 127 | # Canonical BCP-47 content language from spec_lock.md. ``None`` preserves |
| 128 | # the legacy per-run script heuristic for older projects and lockless quick generation. |
| 129 | primary_language: str | None = None |
| 130 | |
| 131 | def next_id(self) -> int: |
| 132 | """Allocate the next shape ID.""" |
| 133 | cid = self.id_counter |
| 134 | while cid in self.reserved_shape_ids or cid in self.claimed_shape_ids: |
| 135 | cid += 1 |
| 136 | self.id_counter = cid + 1 |
| 137 | self.claimed_shape_ids.add(cid) |
| 138 | return cid |
| 139 | |
| 140 | def claim_shape_id( |
| 141 | self, |
| 142 | source_id: str | None, |
| 143 | source_scope: str | None = None, |
| 144 | ) -> int: |
| 145 | """Claim a pre-reserved imported shape id, or allocate a fresh one.""" |
| 146 | if source_id is None: |
| 147 | return self.next_id() |
| 148 | scope = source_scope or 'slide' |
| 149 | key = (scope, source_id) |
| 150 | shape_id = self.source_shape_id_map.get(key) |
| 151 | if shape_id is None: |
| 152 | raise ValueError( |
| 153 | f'Unreserved data-pptx-shape-id {source_id!r} in scope {scope!r}' |
| 154 | ) |
| 155 | if shape_id in self.claimed_shape_ids: |
| 156 | raise ValueError( |
| 157 | f'Duplicate data-pptx-shape-id {source_id!r} in scope {scope!r}' |
| 158 | ) |
| 159 | self.claimed_shape_ids.add(shape_id) |
| 160 | return shape_id |
| 161 | |
| 162 | def reference_shape_id( |
| 163 | self, |
| 164 | source_id: str, |
| 165 | source_scope: str | None = None, |
| 166 | ) -> int: |
| 167 | """Resolve and record a connector target in the imported id space.""" |
| 168 | scope = source_scope or 'slide' |
| 169 | shape_id = self.source_shape_id_map.get((scope, source_id)) |
| 170 | if shape_id is None: |
| 171 | raise ValueError( |
| 172 | f'Unknown connector shape reference {source_id!r} in scope {scope!r}' |
| 173 | ) |
| 174 | self.referenced_shape_ids.add(shape_id) |
| 175 | return shape_id |
| 176 | |
| 177 | def next_rel_id(self) -> str: |
| 178 | """Allocate the next relationship ID (rIdN).""" |
| 179 | rid = f'rId{self.rel_id_counter}' |
| 180 | self.rel_id_counter += 1 |
| 181 | return rid |
| 182 | |
| 183 | def child( |
| 184 | self, |
| 185 | dx: float = 0, |
| 186 | dy: float = 0, |
| 187 | sx: float = 1.0, |
| 188 | sy: float = 1.0, |
| 189 | transform_matrix: AffineMatrix | None = None, |
| 190 | filter_id: str | None = None, |
| 191 | style_overrides: dict[str, str] | None = None, |
| 192 | opacity_multiplier: float = 1.0, |
| 193 | ) -> ConvertContext: |
| 194 | """Create a child context with accumulated translate / scale / styles. |
| 195 | |
| 196 | Args: |
| 197 | dx: X translation delta. |
| 198 | dy: Y translation delta. |
| 199 | sx: X scale factor. |
| 200 | sy: Y scale factor. |
| 201 | transform_matrix: Full affine transform to accumulate for |
| 202 | converters that can faithfully map it to DrawingML. |
| 203 | filter_id: Override filter ID. |
| 204 | style_overrides: Style attribute overrides from child element. |
| 205 | opacity_multiplier: Local group opacity to multiply into descendants. |
| 206 | """ |
| 207 | local_matrix = transform_matrix or IDENTITY_MATRIX |
| 208 | # When first crossing from scalar to matrix mode, fold accumulated |
| 209 | # translate_x/y and scale_x/y into the matrix base. Otherwise the |
| 210 | # ancestor's scalar transform — which matrix-path readers (e.g. |
| 211 | # <image>) never look at — is silently lost, and the descendant |
| 212 | # lands at raw SVG coordinates (typically near (0,0)). |
| 213 | if transform_matrix is not None and not self.use_transform_matrix: |
| 214 | base_matrix: AffineMatrix = ( |
| 215 | self.scale_x, 0.0, |
| 216 | 0.0, self.scale_y, |
| 217 | self.translate_x, self.translate_y, |
| 218 | ) |
| 219 | else: |
| 220 | base_matrix = self.transform_matrix |
| 221 | a1, b1, c1, d1, e1, f1 = base_matrix |
| 222 | a2, b2, c2, d2, e2, f2 = local_matrix |
| 223 | combined_matrix: AffineMatrix = ( |
| 224 | a1 * a2 + c1 * b2, |
| 225 | b1 * a2 + d1 * b2, |
| 226 | a1 * c2 + c1 * d2, |
| 227 | b1 * c2 + d1 * d2, |
| 228 | a1 * e2 + c1 * f2 + e1, |
| 229 | b1 * e2 + d1 * f2 + f1, |
| 230 | ) |
| 231 | |
| 232 | merged = dict(self.inherited_styles) |
| 233 | if style_overrides: |
| 234 | merged.update(style_overrides) |
| 235 | |
| 236 | local_opacity = max(0.0, min(1.0, opacity_multiplier)) |
| 237 | |
| 238 | return ConvertContext( |
| 239 | defs=self.defs, |
| 240 | id_counter=self.id_counter, |
| 241 | reserved_shape_ids=self.reserved_shape_ids, |
| 242 | source_shape_id_map=self.source_shape_id_map, |
| 243 | claimed_shape_ids=self.claimed_shape_ids, |
| 244 | referenced_shape_ids=self.referenced_shape_ids, |
| 245 | slide_num=self.slide_num, |
| 246 | translate_x=self.translate_x + dx, |
| 247 | translate_y=self.translate_y + dy, |
| 248 | scale_x=self.scale_x * sx, |
| 249 | scale_y=self.scale_y * sy, |
| 250 | viewport_width=self.viewport_width, |
| 251 | viewport_height=self.viewport_height, |
| 252 | transform_matrix=combined_matrix, |
| 253 | use_transform_matrix=self.use_transform_matrix or transform_matrix is not None, |
| 254 | filter_id=filter_id or self.filter_id, |
| 255 | media_files=self.media_files, |
| 256 | rel_entries=self.rel_entries, |
| 257 | package_files=self.package_files, |
| 258 | content_type_overrides=self.content_type_overrides, |
| 259 | rel_id_counter=self.rel_id_counter, |
| 260 | svg_dir=self.svg_dir, |
| 261 | inherited_styles=merged, |
| 262 | text_font_sizes=self.text_font_sizes, |
| 263 | text_letter_spacings=self.text_letter_spacings, |
| 264 | opacity_multiplier=self.opacity_multiplier * local_opacity, |
| 265 | depth=self.depth + 1, |
| 266 | # anim_targets is intentionally a fresh list on the child; |
| 267 | # only the root-level context's list is read by the builder. |
| 268 | animation_group_overrides=self.animation_group_overrides, |
| 269 | text_flow=self.text_flow, |
| 270 | native_objects_enabled=self.native_objects_enabled, |
| 271 | image_optimize=self.image_optimize, |
| 272 | image_max_dimension=self.image_max_dimension, |
| 273 | image_sizing=self.image_sizing, |
| 274 | image_scale=self.image_scale, |
| 275 | image_quality=self.image_quality, |
| 276 | trace_events=self.trace_events, |
| 277 | theme_font_spec=self.theme_font_spec, |
| 278 | theme_color_spec=self.theme_color_spec, |
| 279 | primary_language=self.primary_language, |
| 280 | ) |
| 281 | |
| 282 | def sync_from_child(self, child_ctx: ConvertContext) -> None: |
| 283 | """Sync counters back from a child context.""" |
| 284 | self.id_counter = child_ctx.id_counter |
| 285 | self.rel_id_counter = child_ctx.rel_id_counter |
| 286 |