返回 ppt-master
context.py
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 # Public presentation roster size, used to fail closed on #slide-N links.
73 slide_count: int | None = None
74 translate_x: float = 0.0
75 translate_y: float = 0.0
76 scale_x: float = 1.0
77 scale_y: float = 1.0
78 viewport_width: float = 1280.0
79 viewport_height: float = 720.0
80 transform_matrix: AffineMatrix = IDENTITY_MATRIX
81 use_transform_matrix: bool = False
82 filter_id: str | None = None
83 media_files: dict[str, bytes] = field(default_factory=dict)
84 rel_entries: list[dict[str, str]] = field(default_factory=list)
85 package_files: dict[str, bytes] = field(default_factory=dict)
86 content_type_overrides: dict[str, str] = field(default_factory=dict)
87 rel_id_counter: int = 2 # rId1 reserved for slideLayout
88 svg_dir: Path | None = None
89 inherited_styles: dict[str, str] = field(default_factory=dict)
90 # Effective SVG font sizes keyed by element identity. Shared resolution
91 # keeps relative sizes and em tracking identical across checker/exporter.
92 text_font_sizes: dict[int, float] = field(default_factory=dict)
93 # Effective source-pixel tracking resolved where each declaration occurs.
94 text_letter_spacings: dict[int, float] = field(default_factory=dict)
95 # SVG group opacity is post-compositing, not an inherited presentation
96 # property. DrawingML has no equivalent group alpha, so native export
97 # approximates it by multiplying this value into each descendant object.
98 opacity_multiplier: float = 1.0
99 # Recursion depth — only the depth==0 (root) context records anim targets.
100 depth: int = 0
101 # Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
102 # Used by the PPTX builder to emit per-element object-animation timing.
103 anim_targets: list = field(default_factory=list)
104 # Explicit sidecar group ids may override the legacy chrome-name heuristic.
105 # Explicit structural layer/role/placeholder markers remain non-animatable.
106 animation_group_overrides: frozenset[str] = frozenset()
107 # Text-layout policy for positional tspans: preserve authored line breaks
108 # in one frame, reflow them, or split them into independent frames.
109 text_flow: str = TEXT_FLOW_PRESERVE
110 # Explicit opt-in: replace marked chart/table fallback groups with editable
111 # PowerPoint graphicFrames. Default stays off to preserve SVG output.
112 native_objects_enabled: bool = False
113 # Native PPTX image optimization. Keeps generated decks compact by
114 # downsampling oversized raster assets to their rendered size.
115 image_optimize: bool = True
116 image_max_dimension: int | None = 2560
117 image_sizing: str = 'cap'
118 image_scale: float = 2.0
119 image_quality: int = 85
120 # Optional per-element conversion diagnostics. Shared by child contexts so
121 # callers can inspect native / skipped / unsupported decisions per slide.
122 trace_events: list[dict[str, Any]] | None = None
123 # Optional project theme contract. Matching SVG title/body families emit
124 # DrawingML +mj/+mn tokens instead of fixed typeface names.
125 theme_font_spec: ThemeFontSpec | None = None
126 # Optional project theme-color contract. Exact locked colors are promoted
127 # to context-safe DrawingML scheme slots while local colors stay concrete.
128 theme_color_spec: ThemeColorSpec | None = None
129 # Canonical BCP-47 content language from spec_lock.md. ``None`` preserves
130 # the legacy per-run script heuristic for older projects and lockless quick generation.
131 primary_language: str | None = None
132 # Reuse media relationships when the same image fills multiple text runs.
133 text_image_fill_cache: dict[tuple[str, str], str] = field(default_factory=dict)
134
135 def next_id(self) -> int:
136 """Allocate the next shape ID."""
137 cid = self.id_counter
138 while cid in self.reserved_shape_ids or cid in self.claimed_shape_ids:
139 cid += 1
140 self.id_counter = cid + 1
141 self.claimed_shape_ids.add(cid)
142 return cid
143
144 def claim_shape_id(
145 self,
146 source_id: str | None,
147 source_scope: str | None = None,
148 ) -> int:
149 """Claim a pre-reserved imported shape id, or allocate a fresh one."""
150 if source_id is None:
151 return self.next_id()
152 scope = source_scope or 'slide'
153 key = (scope, source_id)
154 shape_id = self.source_shape_id_map.get(key)
155 if shape_id is None:
156 raise ValueError(
157 f'Unreserved data-pptx-shape-id {source_id!r} in scope {scope!r}'
158 )
159 if shape_id in self.claimed_shape_ids:
160 raise ValueError(
161 f'Duplicate data-pptx-shape-id {source_id!r} in scope {scope!r}'
162 )
163 self.claimed_shape_ids.add(shape_id)
164 return shape_id
165
166 def reference_shape_id(
167 self,
168 source_id: str,
169 source_scope: str | None = None,
170 ) -> int:
171 """Resolve and record a connector target in the imported id space."""
172 scope = source_scope or 'slide'
173 shape_id = self.source_shape_id_map.get((scope, source_id))
174 if shape_id is None:
175 raise ValueError(
176 f'Unknown connector shape reference {source_id!r} in scope {scope!r}'
177 )
178 self.referenced_shape_ids.add(shape_id)
179 return shape_id
180
181 def next_rel_id(self) -> str:
182 """Allocate the next relationship ID (rIdN)."""
183 rid = f'rId{self.rel_id_counter}'
184 self.rel_id_counter += 1
185 return rid
186
187 def child(
188 self,
189 dx: float = 0,
190 dy: float = 0,
191 sx: float = 1.0,
192 sy: float = 1.0,
193 transform_matrix: AffineMatrix | None = None,
194 filter_id: str | None = None,
195 style_overrides: dict[str, str] | None = None,
196 opacity_multiplier: float = 1.0,
197 ) -> ConvertContext:
198 """Create a child context with accumulated translate / scale / styles.
199
200 Args:
201 dx: X translation delta.
202 dy: Y translation delta.
203 sx: X scale factor.
204 sy: Y scale factor.
205 transform_matrix: Full affine transform to accumulate for
206 converters that can faithfully map it to DrawingML.
207 filter_id: Override filter ID.
208 style_overrides: Style attribute overrides from child element.
209 opacity_multiplier: Local group opacity to multiply into descendants.
210 """
211 local_matrix = transform_matrix or IDENTITY_MATRIX
212 # When first crossing from scalar to matrix mode, fold accumulated
213 # translate_x/y and scale_x/y into the matrix base. Otherwise the
214 # ancestor's scalar transform — which matrix-path readers (e.g.
215 # <image>) never look at — is silently lost, and the descendant
216 # lands at raw SVG coordinates (typically near (0,0)).
217 if transform_matrix is not None and not self.use_transform_matrix:
218 base_matrix: AffineMatrix = (
219 self.scale_x, 0.0,
220 0.0, self.scale_y,
221 self.translate_x, self.translate_y,
222 )
223 else:
224 base_matrix = self.transform_matrix
225 a1, b1, c1, d1, e1, f1 = base_matrix
226 a2, b2, c2, d2, e2, f2 = local_matrix
227 combined_matrix: AffineMatrix = (
228 a1 * a2 + c1 * b2,
229 b1 * a2 + d1 * b2,
230 a1 * c2 + c1 * d2,
231 b1 * c2 + d1 * d2,
232 a1 * e2 + c1 * f2 + e1,
233 b1 * e2 + d1 * f2 + f1,
234 )
235
236 merged = dict(self.inherited_styles)
237 if style_overrides:
238 merged.update(style_overrides)
239
240 local_opacity = max(0.0, min(1.0, opacity_multiplier))
241
242 return ConvertContext(
243 defs=self.defs,
244 id_counter=self.id_counter,
245 reserved_shape_ids=self.reserved_shape_ids,
246 source_shape_id_map=self.source_shape_id_map,
247 claimed_shape_ids=self.claimed_shape_ids,
248 referenced_shape_ids=self.referenced_shape_ids,
249 slide_num=self.slide_num,
250 slide_count=self.slide_count,
251 translate_x=self.translate_x + dx,
252 translate_y=self.translate_y + dy,
253 scale_x=self.scale_x * sx,
254 scale_y=self.scale_y * sy,
255 viewport_width=self.viewport_width,
256 viewport_height=self.viewport_height,
257 transform_matrix=combined_matrix,
258 use_transform_matrix=self.use_transform_matrix or transform_matrix is not None,
259 filter_id=filter_id or self.filter_id,
260 media_files=self.media_files,
261 rel_entries=self.rel_entries,
262 package_files=self.package_files,
263 content_type_overrides=self.content_type_overrides,
264 rel_id_counter=self.rel_id_counter,
265 svg_dir=self.svg_dir,
266 inherited_styles=merged,
267 text_font_sizes=self.text_font_sizes,
268 text_letter_spacings=self.text_letter_spacings,
269 opacity_multiplier=self.opacity_multiplier * local_opacity,
270 depth=self.depth + 1,
271 # anim_targets is intentionally a fresh list on the child;
272 # only the root-level context's list is read by the builder.
273 animation_group_overrides=self.animation_group_overrides,
274 text_flow=self.text_flow,
275 native_objects_enabled=self.native_objects_enabled,
276 image_optimize=self.image_optimize,
277 image_max_dimension=self.image_max_dimension,
278 image_sizing=self.image_sizing,
279 image_scale=self.image_scale,
280 image_quality=self.image_quality,
281 trace_events=self.trace_events,
282 theme_font_spec=self.theme_font_spec,
283 theme_color_spec=self.theme_color_spec,
284 primary_language=self.primary_language,
285 text_image_fill_cache=self.text_image_fill_cache,
286 )
287
288 def sync_from_child(self, child_ctx: ConvertContext) -> None:
289 """Sync counters back from a child context."""
290 self.id_counter = child_ctx.id_counter
291 self.rel_id_counter = child_ctx.rel_id_counter
292
292 lines PYTHON