返回 ppt-master
marker_common.py
1 """Shared helpers for native PowerPoint object conversion."""
2
3 from __future__ import annotations
4
5 import json
6 import math
7 import re
8 from collections.abc import Iterable
9 from typing import Any
10 from xml.etree import ElementTree as ET
11
12 from ..drawingml.context import ConvertContext, IDENTITY_MATRIX
13 from ..drawingml.paths import (
14 parse_svg_path,
15 parse_svg_points,
16 svg_path_to_absolute,
17 )
18 from ..drawingml.utils import (
19 EMU_PER_PX,
20 ctx_h,
21 ctx_w,
22 ctx_x,
23 ctx_y,
24 font_px_to_hpt,
25 matrix_multiply,
26 parse_project_geometry_length,
27 parse_transform_operations,
28 parse_transform_matrix,
29 transform_point,
30 )
31 from .marker_attributes import (
32 NativeMarkerAttributeError,
33 native_metadata_payload_matches,
34 )
35
36 TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table"
37 CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
38 CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
39 CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
40 CHARTEX_REL_TYPE = "http://schemas.microsoft.com/office/2014/relationships/chartEx"
41 CHART_COLOR_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle"
42 CHART_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartStyle"
43 PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
44 CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
45 CHARTEX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml"
46 CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
47 CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
48
49 _NATIVE_KINDS = {"table", "chart"}
50 _POWERPOINT_COORD_MIN = -(2**31)
51 _POWERPOINT_COORD_MAX = 2**31 - 1
52 _POWERPOINT_LINE_WIDTH_MAX = 20116800
53 _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
54 _RGB_RE = re.compile(r"^rgba?\(([^)]+)\)$", re.IGNORECASE)
55 _CSS_NAMED_COLORS = {
56 "aliceblue": "F0F8FF",
57 "black": "000000",
58 "blue": "0000FF",
59 "brown": "A52A2A",
60 "cyan": "00FFFF",
61 "darkgray": "A9A9A9",
62 "darkgrey": "A9A9A9",
63 "gold": "FFD700",
64 "gray": "808080",
65 "green": "008000",
66 "grey": "808080",
67 "lightgray": "D3D3D3",
68 "lightgrey": "D3D3D3",
69 "magenta": "FF00FF",
70 "navy": "000080",
71 "orange": "FFA500",
72 "purple": "800080",
73 "red": "FF0000",
74 "silver": "C0C0C0",
75 "transparent": None,
76 "white": "FFFFFF",
77 "yellow": "FFFF00",
78 }
79
80
81 def _local_tag(elem: ET.Element) -> str:
82 return elem.tag.rsplit("}", 1)[-1] if "}" in elem.tag else elem.tag
83
84
85 def _clean_hex(value: Any, default: str) -> str:
86 return _hex_or_none(value) or _hex_or_none(default) or "000000"
87
88
89 def _hex_or_none(value: Any) -> str | None:
90 raw = str(value or "").strip()
91 if not raw:
92 return None
93 named = _CSS_NAMED_COLORS.get(raw.lower())
94 if named is not None or raw.lower() in _CSS_NAMED_COLORS:
95 return named
96
97 match = _HEX_RE.match(raw)
98 if match:
99 color = match.group(1).upper()
100 if len(color) == 3:
101 return "".join(channel * 2 for channel in color)
102 return color
103
104 match = _RGB_RE.match(raw)
105 if not match:
106 return None
107 parts = [part.strip() for part in match.group(1).split(",")]
108 if len(parts) not in {3, 4}:
109 return None
110 channels: list[int] = []
111 for part in parts[:3]:
112 try:
113 if part.endswith("%"):
114 value_float = float(part[:-1]) * 255.0 / 100.0
115 else:
116 value_float = float(part)
117 except ValueError:
118 return None
119 if not math.isfinite(value_float):
120 return None
121 channels.append(max(0, min(255, int(round(value_float)))))
122 return "".join(f"{channel:02X}" for channel in channels)
123
124
125 def _style_attr(elem: ET.Element, name: str) -> str | None:
126 if elem.get(name) is not None:
127 return elem.get(name)
128 style = elem.get("style")
129 if not style:
130 return None
131 for part in style.split(";"):
132 if ":" not in part:
133 continue
134 key, value = part.split(":", 1)
135 if key.strip() == name:
136 return value.strip()
137 return None
138
139
140 def _paint_visible(elem: ET.Element, paint: str) -> bool:
141 for name in ("opacity", f"{paint}-opacity"):
142 raw = _style_attr(elem, name)
143 if raw is None:
144 continue
145 try:
146 if float(raw) <= 0:
147 return False
148 except ValueError:
149 continue
150 return True
151
152
153 def _normalized_fallback_text(value: Any) -> str:
154 return re.sub(r"\s+", " ", str(value or "")).strip()
155
156
157 def _visible_fallback_texts(elem: ET.Element, *, include_metadata: bool = False) -> list[str]:
158 texts: list[str] = []
159
160 def visit(node: ET.Element, hidden: bool) -> None:
161 tag = _local_tag(node)
162 if tag in {"defs", "clipPath", "mask", "filter", "style"}:
163 return
164 if tag == "metadata" and not include_metadata:
165 return
166 node_hidden = (
167 hidden
168 or _style_attr(node, "display") == "none"
169 or _style_attr(node, "visibility") == "hidden"
170 )
171 if tag == "text" and not node_hidden:
172 text = _normalized_fallback_text("".join(node.itertext()))
173 if text:
174 texts.append(text)
175 return
176 for child in node:
177 visit(child, node_hidden)
178
179 visit(elem, False)
180 return texts
181
182
183 def _number(value: Any, field_name: str) -> float:
184 if isinstance(value, bool):
185 raise RuntimeError(f"Native PPTX object requires numeric {field_name}")
186 try:
187 number = float(value)
188 except (TypeError, ValueError, OverflowError) as exc:
189 raise RuntimeError(f"Native PPTX object requires numeric {field_name}") from exc
190 if not math.isfinite(number):
191 raise RuntimeError(f"Native PPTX object requires finite numeric {field_name}")
192 return number
193
194
195 def _maybe_number(value: Any) -> float | None:
196 if value is None or isinstance(value, bool):
197 return None
198 try:
199 number = float(value)
200 except (TypeError, ValueError, OverflowError):
201 return None
202 return number if math.isfinite(number) else None
203
204
205 def _project_geometry_number(
206 elem: ET.Element,
207 attribute: str,
208 default: float = 0.0,
209 ) -> float:
210 """Read one fallback geometry value through the project length contract."""
211 raw = elem.get(attribute)
212 if raw is None:
213 return default
214 try:
215 return parse_project_geometry_length(raw, attribute)
216 except ValueError as exc:
217 tag = _local_tag(elem)
218 elem_id = elem.get("id")
219 label = f"<{tag} id={elem_id!r}>" if elem_id else f"<{tag}>"
220 raise RuntimeError(
221 f"Native PPTX fallback {label} {attribute}={raw!r}: {exc}"
222 ) from exc
223
224
225 def _powerpoint_emu_value(
226 emu: int,
227 field_name: str,
228 *,
229 positive: bool = False,
230 ) -> int:
231 """Validate an already-resolved EMU value for a PowerPoint coordinate."""
232 lower_bound = 1 if positive else _POWERPOINT_COORD_MIN
233 if emu < lower_bound or emu > _POWERPOINT_COORD_MAX:
234 qualifier = "positive " if positive else ""
235 raise RuntimeError(
236 f"Native PPTX object {field_name} must resolve to a {qualifier}"
237 "32-bit PowerPoint coordinate"
238 )
239 return emu
240
241
242 def _powerpoint_emu(value: Any, field_name: str, *, positive: bool = False) -> int:
243 """Convert SVG px to an EMU value that PowerPoint can represent."""
244 number = _number(value, field_name)
245 scaled = number * EMU_PER_PX
246 if not math.isfinite(scaled):
247 raise RuntimeError(f"Native PPTX object {field_name} exceeds PowerPoint coordinates")
248 return _powerpoint_emu_value(round(scaled), field_name, positive=positive)
249
250
251 def _powerpoint_line_width_emu(value: Any, field_name: str) -> int:
252 """Convert SVG px to a legal DrawingML ``ST_LineWidth`` value."""
253 emu = _powerpoint_emu(value, field_name, positive=True)
254 if emu > _POWERPOINT_LINE_WIDTH_MAX:
255 raise RuntimeError(
256 f"Native PPTX object {field_name} exceeds DrawingML line-width range"
257 )
258 return emu
259
260
261 def native_marker_transform(transform: str | None) -> tuple[float, float, float, float]:
262 """Return a strict native-marker transform as ``dx, dy, sx, sy``."""
263 raw = (transform or "").strip()
264 if not raw:
265 return 0.0, 0.0, 1.0, 1.0
266
267 try:
268 operations = parse_transform_operations(raw)
269 except ValueError as exc:
270 raise RuntimeError(
271 "Native PPTX table/chart markers support translate/scale transforms only"
272 ) from exc
273 if any(name not in {"translate", "scale"} for name, _args in operations):
274 raise RuntimeError(
275 "Native PPTX table/chart markers support translate/scale transforms only"
276 )
277
278 a, b, c, d, e, f = parse_transform_matrix(raw)
279 components = (a, b, c, d, e, f)
280 if not all(math.isfinite(value) for value in components):
281 raise RuntimeError("Native PPTX marker transform exceeds finite coordinates")
282 if b != 0.0 or c != 0.0:
283 raise RuntimeError(
284 "Native PPTX table/chart markers support translate/scale transforms only"
285 )
286 return e, f, a, d
287
288
289 def _native_marker_validation_context(
290 elem: ET.Element,
291 ancestors: Iterable[ET.Element] = (),
292 ) -> ConvertContext:
293 """Build the scalar context used by native export for one marker path."""
294 ctx = ConvertContext()
295 for current in (*ancestors, elem):
296 dx, dy, sx, sy = native_marker_transform(current.get("transform"))
297 ctx = ctx.child(
298 dx=ctx.scale_x * dx,
299 dy=ctx.scale_y * dy,
300 sx=sx,
301 sy=sy,
302 )
303 return ctx
304
305
306 def _bbox_union(
307 first: tuple[float, float, float, float] | None,
308 second: tuple[float, float, float, float] | None,
309 ) -> tuple[float, float, float, float] | None:
310 if first is None:
311 return second
312 if second is None:
313 return first
314 return (
315 min(first[0], second[0]),
316 min(first[1], second[1]),
317 max(first[2], second[2]),
318 max(first[3], second[3]),
319 )
320
321
322 def _bbox_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float] | None:
323 if not points:
324 return None
325 xs = [point[0] for point in points]
326 ys = [point[1] for point in points]
327 return min(xs), min(ys), max(xs), max(ys)
328
329
330 def _apply_matrix_bbox(
331 bbox: tuple[float, float, float, float],
332 matrix: tuple[float, float, float, float, float, float],
333 ) -> tuple[float, float, float, float]:
334 x1, y1, x2, y2 = bbox
335 points = [
336 transform_point(matrix, x1, y1),
337 transform_point(matrix, x2, y1),
338 transform_point(matrix, x2, y2),
339 transform_point(matrix, x1, y2),
340 ]
341 result = _bbox_from_points(points)
342 if result is None:
343 raise RuntimeError("Native PPTX object fallback bbox inference failed")
344 return result
345
346
347 def _points_attr_bbox(
348 value: str | None,
349 *,
350 min_points: int,
351 ) -> tuple[float, float, float, float] | None:
352 try:
353 points = parse_svg_points(value or "", min_points=min_points)
354 except ValueError as exc:
355 raise RuntimeError(f"Invalid native fallback points: {exc}") from exc
356 return _bbox_from_points(points)
357
358
359 def _path_bbox(value: str | None) -> tuple[float, float, float, float] | None:
360 try:
361 commands = svg_path_to_absolute(parse_svg_path(value or ""))
362 except ValueError as exc:
363 raise RuntimeError(f"Invalid native fallback path d: {exc}") from exc
364
365 points: list[tuple[float, float]] = []
366 subpath_x = 0.0
367 subpath_y = 0.0
368
369 for command in commands:
370 values = command.args
371 if command.cmd == "M":
372 subpath_x, subpath_y = values
373 points.append((subpath_x, subpath_y))
374 elif command.cmd in {"L", "T"}:
375 points.append((values[0], values[1]))
376 elif command.cmd == "C":
377 points.extend(
378 (values[index], values[index + 1])
379 for index in range(0, 6, 2)
380 )
381 elif command.cmd in {"S", "Q"}:
382 points.extend(
383 (values[index], values[index + 1])
384 for index in range(0, 4, 2)
385 )
386 elif command.cmd == "A":
387 points.append((values[5], values[6]))
388 elif command.cmd == "Z":
389 points.append((subpath_x, subpath_y))
390
391 return _bbox_from_points(points)
392
393
394 def _element_local_bbox(elem: ET.Element) -> tuple[float, float, float, float] | None:
395 tag = _local_tag(elem)
396 if tag == "metadata":
397 return None
398 if tag in {"defs", "clipPath", "mask", "filter", "style"}:
399 return None
400 if elem.get("display") == "none" or elem.get("visibility") == "hidden":
401 return None
402
403 if tag in {"g", "svg", "a"}:
404 bbox = None
405 for child in elem:
406 bbox = _bbox_union(bbox, _fallback_bbox(child))
407 return bbox
408
409 if tag in {"rect", "image", "use"}:
410 x = _project_geometry_number(elem, "x")
411 y = _project_geometry_number(elem, "y")
412 width = _project_geometry_number(elem, "width")
413 height = _project_geometry_number(elem, "height")
414 if width <= 0 or height <= 0:
415 return None
416 return x, y, x + width, y + height
417
418 if tag == "circle":
419 cx = _project_geometry_number(elem, "cx")
420 cy = _project_geometry_number(elem, "cy")
421 r = _project_geometry_number(elem, "r")
422 if r <= 0:
423 return None
424 return cx - r, cy - r, cx + r, cy + r
425
426 if tag == "ellipse":
427 cx = _project_geometry_number(elem, "cx")
428 cy = _project_geometry_number(elem, "cy")
429 rx = _project_geometry_number(elem, "rx")
430 ry = _project_geometry_number(elem, "ry")
431 if rx <= 0 or ry <= 0:
432 return None
433 return cx - rx, cy - ry, cx + rx, cy + ry
434
435 if tag == "line":
436 points = [
437 (
438 _project_geometry_number(elem, "x1"),
439 _project_geometry_number(elem, "y1"),
440 ),
441 (
442 _project_geometry_number(elem, "x2"),
443 _project_geometry_number(elem, "y2"),
444 ),
445 ]
446 return _bbox_from_points(points)
447
448 if tag in {"polygon", "polyline"}:
449 return _points_attr_bbox(
450 elem.get("points"),
451 min_points=3 if tag == "polygon" else 2,
452 )
453
454 if tag == "path":
455 # This intentionally approximates path geometry from command endpoints.
456 # Explicit metadata remains the precise path for complex arcs/curves.
457 return _path_bbox(elem.get("d"))
458
459 if tag == "text":
460 x = _project_geometry_number(elem, "x")
461 y = _project_geometry_number(elem, "y")
462 font_size = _maybe_number(elem.get("font-size")) or 16.0
463 text = "".join(elem.itertext())
464 width = max(len(text), 1) * font_size * 0.55
465 height = font_size * 1.25
466 return x, y - height * 0.8, x + width, y + height * 0.2
467
468 return None
469
470
471 def _fallback_bbox(
472 elem: ET.Element,
473 matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
474 ) -> tuple[float, float, float, float] | None:
475 local_matrix = matrix
476 transform = elem.get("transform")
477 if transform:
478 local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
479
480 tag = _local_tag(elem)
481 if tag in {"g", "svg", "a"}:
482 bbox = None
483 for child in elem:
484 bbox = _bbox_union(bbox, _fallback_bbox(child, local_matrix))
485 return bbox
486
487 local_bbox = _element_local_bbox(elem)
488 if local_bbox is None:
489 return None
490 return _apply_matrix_bbox(local_bbox, local_matrix)
491
492
493 def _inferred_bounds(elem: ET.Element) -> tuple[float, float, float, float] | None:
494 bbox = None
495 for child in elem:
496 bbox = _bbox_union(bbox, _fallback_bbox(child))
497 return bbox
498
499
500 def _fallback_fill_candidates(
501 elem: ET.Element,
502 matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
503 inherited_fill: str | None = None,
504 ) -> list[tuple[float, str]]:
505 tag = _local_tag(elem)
506 if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
507 return []
508 if elem.get("display") == "none" or elem.get("visibility") == "hidden":
509 return []
510 if not _paint_visible(elem, "fill"):
511 return []
512
513 local_matrix = matrix
514 transform = elem.get("transform")
515 if transform:
516 local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
517
518 fill = _style_attr(elem, "fill")
519 next_fill = fill if fill is not None else inherited_fill
520 if tag in {"g", "svg", "a"}:
521 candidates: list[tuple[float, str]] = []
522 for child in elem:
523 candidates.extend(_fallback_fill_candidates(child, local_matrix, next_fill))
524 return candidates
525
526 if tag != "rect":
527 return []
528 if not next_fill or next_fill.strip().lower() in {"none", "transparent"}:
529 return []
530 color = _hex_or_none(next_fill)
531 if not color:
532 return []
533 local_bbox = _element_local_bbox(elem)
534 if local_bbox is None:
535 return []
536 x1, y1, x2, y2 = _apply_matrix_bbox(local_bbox, local_matrix)
537 area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
538 return [(area, color)] if area > 0 else []
539
540
541 def _inferred_chart_background(elem: ET.Element) -> str | None:
542 bounds = _inferred_bounds(elem)
543 if bounds is None:
544 return None
545 x1, y1, x2, y2 = bounds
546 bounds_area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
547 if bounds_area <= 0:
548 return None
549
550 candidates: list[tuple[float, str]] = []
551 for child in elem:
552 candidates.extend(_fallback_fill_candidates(child))
553 if not candidates:
554 return None
555 area, color = max(candidates, key=lambda item: item[0])
556 # Avoid mistaking a large data bar for a chart background when no panel /
557 # plot-area rectangle exists in the fallback drawing.
558 return color if area >= bounds_area * 0.25 else None
559
560
561 def _fallback_text_colors(
562 elem: ET.Element,
563 inherited_fill: str | None = None,
564 ) -> list[str]:
565 tag = _local_tag(elem)
566 if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
567 return []
568 if elem.get("display") == "none" or elem.get("visibility") == "hidden":
569 return []
570 if not _paint_visible(elem, "fill"):
571 return []
572
573 fill = _style_attr(elem, "fill")
574 next_fill = fill if fill is not None else inherited_fill
575 colors: list[str] = []
576 if tag in {"text", "tspan"} and next_fill:
577 color = _hex_or_none(next_fill)
578 if color:
579 colors.append(color)
580 for child in elem:
581 colors.extend(_fallback_text_colors(child, next_fill))
582 return colors
583
584
585 def _fallback_stroke_colors(
586 elem: ET.Element,
587 inherited_stroke: str | None = None,
588 ) -> list[str]:
589 tag = _local_tag(elem)
590 if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
591 return []
592 if elem.get("display") == "none" or elem.get("visibility") == "hidden":
593 return []
594 if not _paint_visible(elem, "stroke"):
595 return []
596
597 stroke = _style_attr(elem, "stroke")
598 next_stroke = stroke if stroke is not None else inherited_stroke
599 colors: list[str] = []
600 if tag in {"circle", "ellipse", "line", "path", "polygon", "polyline", "rect"} and next_stroke:
601 color = _hex_or_none(next_stroke)
602 if color:
603 colors.append(color)
604 for child in elem:
605 colors.extend(_fallback_stroke_colors(child, next_stroke))
606 return colors
607
608
609 def _most_common_color(colors: list[str]) -> str | None:
610 if not colors:
611 return None
612 counts: dict[str, int] = {}
613 for color in colors:
614 counts[color] = counts.get(color, 0) + 1
615 return max(counts.items(), key=lambda item: item[1])[0]
616
617
618 def _relative_luminance(color: str) -> float:
619 channels = [int(color[idx:idx + 2], 16) / 255.0 for idx in (0, 2, 4)]
620 linear = [
621 channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
622 for channel in channels
623 ]
624 return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
625
626
627 def _resolved_bounds(
628 elem: ET.Element,
629 payload: dict[str, Any],
630 ctx: ConvertContext,
631 ) -> tuple[float, float, float, float, bool]:
632 """Resolve object bounds in SVG px plus whether all bounds were explicit."""
633 if ctx.use_transform_matrix:
634 raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
635
636 raw_x = payload.get("x", elem.get("data-pptx-x"))
637 raw_y = payload.get("y", elem.get("data-pptx-y"))
638 raw_width = payload.get("width", elem.get("data-pptx-width"))
639 raw_height = payload.get("height", elem.get("data-pptx-height"))
640 explicit_bounds = all(value is not None for value in (raw_x, raw_y, raw_width, raw_height))
641 inferred = None
642 if not explicit_bounds:
643 inferred = _inferred_bounds(elem)
644 if inferred is None:
645 raise RuntimeError(
646 "Native PPTX object requires x/y/width/height or visible fallback geometry"
647 )
648
649 x = _number(raw_x, "x") if raw_x is not None else inferred[0] # type: ignore[index]
650 y = _number(raw_y, "y") if raw_y is not None else inferred[1] # type: ignore[index]
651 width = (
652 _number(raw_width, "width")
653 if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
654 )
655 height = (
656 _number(raw_height, "height")
657 if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
658 )
659 if width <= 0 or height <= 0:
660 raise RuntimeError("Native PPTX object width/height must be positive")
661
662 if explicit_bounds:
663 resolved_x = x
664 resolved_y = y
665 resolved_w = width
666 resolved_h = height
667 else:
668 resolved_x = ctx_x(x, ctx)
669 resolved_y = ctx_y(y, ctx)
670 resolved_w = ctx_w(width, ctx)
671 resolved_h = ctx_h(height, ctx)
672 return resolved_x, resolved_y, resolved_w, resolved_h, explicit_bounds
673
674
675 def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
676 """Return object bounds as DrawingML EMU tuple."""
677 x, y, width, height, _ = _resolved_bounds(elem, payload, ctx)
678 return (
679 _powerpoint_emu(x, "x"),
680 _powerpoint_emu(y, "y"),
681 _powerpoint_emu(width, "width", positive=True),
682 _powerpoint_emu(height, "height", positive=True),
683 )
684
685
686 def _validate_bounds_inputs(
687 elem: ET.Element,
688 payload: dict[str, Any],
689 ctx: ConvertContext,
690 ) -> tuple[int, int, int, int, bool]:
691 x, y, width, height, explicit_bounds = _resolved_bounds(elem, payload, ctx)
692 return (
693 _powerpoint_emu(x, "x"),
694 _powerpoint_emu(y, "y"),
695 _powerpoint_emu(width, "width", positive=True),
696 _powerpoint_emu(height, "height", positive=True),
697 explicit_bounds,
698 )
699
700
701 def _validate_payload_xml_strings(value: Any) -> None:
702 """Reject JSON strings that cannot be serialized into PPTX XML 1.0 parts."""
703 if isinstance(value, dict):
704 for key, item in value.items():
705 _validate_payload_xml_strings(key)
706 _validate_payload_xml_strings(item)
707 return
708 if isinstance(value, list):
709 for item in value:
710 _validate_payload_xml_strings(item)
711 return
712 if not isinstance(value, str):
713 return
714 for char in value:
715 codepoint = ord(char)
716 if (
717 codepoint in {0x09, 0x0A, 0x0D}
718 or 0x20 <= codepoint <= 0xD7FF
719 or 0xE000 <= codepoint <= 0xFFFD
720 or 0x10000 <= codepoint <= 0x10FFFF
721 ):
722 continue
723 raise RuntimeError(
724 "Native PPTX metadata contains an XML 1.0-invalid character "
725 f"U+{codepoint:04X}"
726 )
727
728
729 def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
730 raw = elem.get("data-pptx-json") or elem.get("data-pptx-data")
731 if raw is None:
732 for child in elem:
733 if _local_tag(child) != "metadata":
734 continue
735 if native_metadata_payload_matches(child, kind):
736 raw = "".join(child.itertext()).strip()
737 break
738
739 if not raw:
740 raise RuntimeError(
741 f"PPTX {kind} replacement marker requires JSON metadata; add "
742 '<metadata type="application/json"> for a real data-backed '
743 "object, or remove data-pptx-replace-with from SVG-only "
744 "KPI/diagram groups"
745 )
746
747 try:
748 payload = json.loads(raw)
749 except json.JSONDecodeError as exc:
750 raise RuntimeError(f"Native PPTX {kind} metadata is not valid JSON: {exc.msg}") from exc
751 except (ValueError, RecursionError) as exc:
752 raise RuntimeError(f"Native PPTX {kind} metadata cannot be decoded") from exc
753 if not isinstance(payload, dict):
754 raise RuntimeError(f"Native PPTX {kind} metadata must be a JSON object")
755 _validate_payload_xml_strings(payload)
756 return payload
757
758
759 def _font_size_hpt(value: Any, default_px: int = 18) -> int:
760 def convert(raw: Any) -> int | None:
761 try:
762 px = float(raw)
763 except (TypeError, ValueError, OverflowError):
764 return None
765 try:
766 return font_px_to_hpt(px)
767 except ValueError:
768 return None
769
770 return convert(value) or convert(default_px) or 1350
771
772
773 def _first_present(*values: Any) -> Any:
774 for value in values:
775 if value is not None:
776 return value
777 return None
778
779
780 def _bool_attr(value: bool) -> str:
781 return "1" if value else "0"
782
783
784 def _chart_bool(value: Any, default: bool = False) -> bool:
785 if value is None:
786 return default
787 if isinstance(value, bool):
788 return value
789 key = _compact_key(value)
790 if key in {"1", "on", "true", "yes"}:
791 return True
792 if key in {"0", "false", "no", "off"}:
793 return False
794 return bool(value)
795
796
797 def _excel_col(index: int) -> str:
798 result = ""
799 while index:
800 index, remainder = divmod(index - 1, 26)
801 result = chr(65 + remainder) + result
802 return result or "A"
803
804
805 def _compact_key(value: Any) -> str:
806 return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
807
807 lines PYTHON