返回 ppt-master
chart_style.py
1 """Native chart styling and companion text helpers."""
2
3 from __future__ import annotations
4
5 import math
6 from typing import Any
7 from xml.etree import ElementTree as ET
8
9 from .marker_attributes import native_import_source
10
11 from ..drawingml.context import ConvertContext
12 from ..drawingml.utils import (
13 _xml_escape,
14 detect_text_lang,
15 parse_font_family,
16 px_to_emu,
17 quantize_ooxml_alpha,
18 text_has_rtl_characters,
19 text_uses_rtl,
20 )
21 from .chart_data import _DEFAULT_CHART_COLORS
22 from .marker_common import (
23 _bool_attr,
24 _bounds,
25 _chart_bool,
26 _clean_hex,
27 _compact_key,
28 _fallback_fill_candidates,
29 _fallback_stroke_colors,
30 _fallback_text_colors,
31 _first_present,
32 _font_size_hpt,
33 _hex_or_none,
34 _inferred_chart_background,
35 _local_tag,
36 _maybe_number,
37 _most_common_color,
38 _number,
39 _normalized_fallback_text,
40 _powerpoint_emu,
41 _powerpoint_emu_value,
42 _relative_luminance,
43 _style_attr,
44 _visible_fallback_texts,
45 )
46
47
48 def _chart_style_value(payload: dict[str, Any], *keys: str) -> Any:
49 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
50 for source in (payload, style):
51 for key in keys:
52 if source.get(key) is not None:
53 return source.get(key)
54 return None
55
56
57 def _chart_style_color(
58 payload: dict[str, Any],
59 keys: tuple[str, ...],
60 default: str | None,
61 ) -> str | None:
62 raw = _chart_style_value(payload, *keys)
63 if raw is None:
64 return default
65 if str(raw).strip().lower() in {"none", "transparent"}:
66 return None
67 return _hex_or_none(raw) or default
68
69
70 def _fallback_text_attr_values(
71 elem: ET.Element,
72 attr: str,
73 inherited_value: str | None = None,
74 ) -> list[str]:
75 tag = _local_tag(elem)
76 if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
77 return []
78 if elem.get("display") == "none" or elem.get("visibility") == "hidden":
79 return []
80
81 own_value = _style_attr(elem, attr)
82 next_value = own_value if own_value is not None else inherited_value
83 values: list[str] = []
84 if tag in {"text", "tspan"} and next_value:
85 values.append(str(next_value).strip())
86 for child in elem:
87 values.extend(_fallback_text_attr_values(child, attr, next_value))
88 return values
89
90
91 def _most_common_value(values: list[str]) -> str | None:
92 if not values:
93 return None
94 counts: dict[str, int] = {}
95 for value in values:
96 counts[value] = counts.get(value, 0) + 1
97 return max(counts.items(), key=lambda item: item[1])[0]
98
99
100 def _most_common_font_size(values: list[str]) -> str | None:
101 if not values:
102 return None
103 counts: dict[str, int] = {}
104 for value in values:
105 counts[value] = counts.get(value, 0) + 1
106 max_count = max(counts.values())
107 candidates = [value for value, count in counts.items() if count == max_count]
108 numeric_candidates = [
109 (float(value), value)
110 for value in candidates
111 if _maybe_number(value) is not None
112 ]
113 if numeric_candidates:
114 return min(numeric_candidates, key=lambda item: item[0])[1]
115 return candidates[0]
116
117
118 def _classic_chart_style(
119 payload: dict[str, Any],
120 elem: ET.Element,
121 inherited_styles: dict[str, str] | None = None,
122 ) -> dict[str, str | None]:
123 inherited_styles = inherited_styles or {}
124 fallback_background = _inferred_chart_background(elem)
125 text_color = _most_common_color(
126 _fallback_text_colors(elem, inherited_styles.get("fill"))
127 ) or "404040"
128 stroke_colors = _fallback_stroke_colors(elem, inherited_styles.get("stroke"))
129 darkest_stroke = min(stroke_colors, key=_relative_luminance) if stroke_colors else None
130 lightest_stroke = max(stroke_colors, key=_relative_luminance) if stroke_colors else None
131 raw_font_face = _chart_style_value(payload, "font_family", "fontFamily", "font_face", "fontFace")
132 fallback_font_face = _most_common_value(
133 _fallback_text_attr_values(
134 elem,
135 "font-family",
136 inherited_styles.get("font-family"),
137 )
138 )
139 font_face = str(raw_font_face).strip() if raw_font_face is not None else fallback_font_face
140 axis_color = darkest_stroke or text_color
141 grid_color = (
142 lightest_stroke
143 if lightest_stroke and _relative_luminance(lightest_stroke) > _relative_luminance(axis_color)
144 else "D9DED8"
145 )
146 chart_fill = _chart_style_color(
147 payload,
148 (
149 "chart_area_fill",
150 "chartAreaFill",
151 "chart_fill",
152 "chartFill",
153 "background",
154 "background_color",
155 "backgroundColor",
156 "fill",
157 ),
158 fallback_background,
159 )
160 return {
161 "axis_color": _chart_style_color(
162 payload,
163 ("axis_color", "axisColor", "axis_line_color", "axisLineColor"),
164 axis_color,
165 ),
166 "chart_fill": chart_fill,
167 "grid_color": _chart_style_color(
168 payload,
169 ("grid_color", "gridColor", "gridline_color", "gridlineColor"),
170 grid_color,
171 ),
172 "plot_fill": _chart_style_color(
173 payload,
174 ("plot_area_fill", "plotAreaFill", "plot_background", "plotBackground"),
175 None,
176 ),
177 "text_color": _chart_style_color(
178 payload,
179 ("text_color", "textColor", "label_color", "labelColor", "font_color", "fontColor"),
180 text_color,
181 ),
182 "font_face": font_face or None,
183 }
184
185
186 def _chart_text_sizes(
187 payload: dict[str, Any],
188 elem: ET.Element | None = None,
189 inherited_styles: dict[str, str] | None = None,
190 ) -> dict[str, int]:
191 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
192 inherited_styles = inherited_styles or {}
193 fallback_font_size = (
194 _most_common_font_size(
195 _fallback_text_attr_values(
196 elem,
197 "font-size",
198 inherited_styles.get("font-size"),
199 )
200 )
201 if elem is not None else None
202 )
203 base_raw = _first_present(
204 payload.get("font_size"),
205 payload.get("chart_font_size"),
206 payload.get("chartFontSize"),
207 style.get("font_size"),
208 style.get("chart_font_size"),
209 style.get("chartFontSize"),
210 fallback_font_size,
211 )
212 axis_raw = _first_present(
213 payload.get("axis_font_size"),
214 payload.get("axisFontSize"),
215 payload.get("tick_font_size"),
216 payload.get("tickFontSize"),
217 style.get("axis_font_size"),
218 style.get("axisFontSize"),
219 style.get("tick_font_size"),
220 style.get("tickFontSize"),
221 base_raw,
222 )
223 axis_title_raw = _first_present(
224 payload.get("axis_title_font_size"),
225 payload.get("axisTitleFontSize"),
226 style.get("axis_title_font_size"),
227 style.get("axisTitleFontSize"),
228 axis_raw,
229 )
230 legend_raw = _first_present(
231 payload.get("legend_font_size"),
232 payload.get("legendFontSize"),
233 style.get("legend_font_size"),
234 style.get("legendFontSize"),
235 axis_raw,
236 )
237 title_raw = _first_present(
238 payload.get("title_font_size"),
239 payload.get("titleFontSize"),
240 style.get("title_font_size"),
241 style.get("titleFontSize"),
242 )
243 subtitle_raw = _first_present(
244 payload.get("subtitle_font_size"),
245 payload.get("subtitleFontSize"),
246 style.get("subtitle_font_size"),
247 style.get("subtitleFontSize"),
248 base_raw,
249 )
250 note_raw = _first_present(
251 payload.get("note_font_size"),
252 payload.get("noteFontSize"),
253 style.get("note_font_size"),
254 style.get("noteFontSize"),
255 style.get("caption_font_size"),
256 style.get("captionFontSize"),
257 base_raw,
258 )
259 return {
260 "axis": _font_size_hpt(axis_raw, 12),
261 "axis_title": _font_size_hpt(axis_title_raw, 12),
262 "base": _font_size_hpt(base_raw, 12),
263 "legend": _font_size_hpt(legend_raw, 12),
264 "note": _font_size_hpt(note_raw, 12),
265 "subtitle": _font_size_hpt(subtitle_raw, 12),
266 "title": _font_size_hpt(title_raw, 16),
267 }
268
269
270 def _solid_fill_xml(color: str | None) -> str:
271 if not color:
272 return "<a:noFill/>"
273 return f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
274
275
276 def _chart_area_sp_pr_xml(fill_color: str | None) -> str:
277 return f"<c:spPr>{_solid_fill_xml(fill_color)}<a:ln><a:noFill/></a:ln></c:spPr>"
278
279
280 def _chart_line_sp_pr_xml(color: str | None, *, width: int = 9525) -> str:
281 if not color:
282 line_xml = "<a:ln><a:noFill/></a:ln>"
283 else:
284 line_xml = (
285 f'<a:ln w="{width}" cap="flat" cmpd="sng" algn="ctr">'
286 f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
287 "<a:round/></a:ln>"
288 )
289 return f"<c:spPr>{line_xml}</c:spPr>"
290
291
292 def _major_gridlines_xml(color: str | None) -> str:
293 return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
294
295
296 def _font_face_xml(font_face: str | None) -> str:
297 if not font_face:
298 return ""
299 fonts = parse_font_family(font_face)
300 latin_font = _xml_escape(fonts["latin"])
301 ea_font = _xml_escape(fonts["ea"])
302 return (
303 f'<a:latin typeface="{latin_font}"/>'
304 f'<a:ea typeface="{ea_font}"/>'
305 f'<a:cs typeface="{latin_font}"/>'
306 )
307
308
309 def _chart_tx_pr_xml(
310 font_size: int,
311 color: str | None = None,
312 *,
313 bold: bool = False,
314 font_face: str | None = None,
315 language: str | None = None,
316 ) -> str:
317 fill_xml = (
318 f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
319 if color else ""
320 )
321 bold_attr = ' b="1"' if bold else ""
322 resolved_language = language or 'en-US'
323 rtl_attr = ' rtl="1"' if text_uses_rtl('', language) else ''
324 return (
325 f"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr{rtl_attr}>"
326 f'<a:defRPr lang="{resolved_language}" sz="{font_size}"{bold_attr}>'
327 f'{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
328 f'</a:pPr><a:endParaRPr lang="{resolved_language}"/></a:p></c:txPr>'
329 )
330
331
332 def _chart_text_entry(value: Any) -> tuple[str, dict[str, Any]] | None:
333 if isinstance(value, dict):
334 text = _first_present(value.get("text"), value.get("value"), value.get("content"))
335 if text is None or not str(text).strip():
336 return None
337 return str(text).strip(), value
338 if value is None or not str(value).strip():
339 return None
340 return str(value).strip(), {}
341
342
343 def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
344 raw = _first_present(item.get("font_size"), item.get("fontSize"))
345 if raw is None:
346 return fallback
347 return _font_size_hpt(raw, 12)
348
349
350 def _chart_text_entry_color(item: dict[str, Any], fallback: str | None) -> str | None:
351 return _hex_or_none(_first_present(
352 item.get("color"),
353 item.get("font_color"),
354 item.get("fontColor"),
355 )) or fallback
356
357
358 def _chart_text_entry_font_face(item: dict[str, Any], fallback: str | None) -> str | None:
359 raw = _first_present(
360 item.get("font_family"),
361 item.get("fontFamily"),
362 item.get("font_face"),
363 item.get("fontFace"),
364 )
365 if raw is None:
366 return fallback
367 font_face = str(raw).strip()
368 return font_face or fallback
369
370
371 def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
372 if value is None:
373 return ""
374 if isinstance(value, bool):
375 raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
376 try:
377 alpha = float(value)
378 except (TypeError, ValueError, OverflowError):
379 raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
380 if not math.isfinite(alpha):
381 raise RuntimeError(f"Native PPTX chart {field_name} must be finite")
382 if alpha < 0 or alpha > 1:
383 raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
384 return f'<a:alpha val="{quantize_ooxml_alpha(alpha)}"/>'
385
386
387 def _axis_title_xml(
388 title: Any,
389 *,
390 font_size: int,
391 color: str | None = None,
392 font_face: str | None = None,
393 primary_language: str | None = None,
394 ) -> str:
395 entry = _chart_text_entry(title)
396 if entry is None:
397 return ""
398 text, item = entry
399 text_color = _chart_text_entry_color(item, color)
400 fill_xml = (
401 f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
402 if text_color else ""
403 )
404 lang = detect_text_lang(text, primary_language)
405 rtl_attr = (
406 ' rtl="1"'
407 if text_uses_rtl(text, primary_language)
408 else ''
409 )
410 run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
411 return (
412 "<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
413 f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
414 f'sz="{_chart_text_entry_font_size(item, font_size)}">'
415 f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
416 f"{run_rtl}</a:rPr>"
417 f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
418 "</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
419 )
420
421
422 _AXIS_TITLE_KEY_GROUPS = {
423 "category": (
424 ("category",),
425 ("category_axis_title", "categoryAxisTitle"),
426 ),
427 "value": (
428 ("value",),
429 ("value_axis_title", "valueAxisTitle"),
430 ),
431 "x": (
432 ("x",),
433 ("x_axis_title", "xAxisTitle"),
434 ),
435 "y": (
436 ("y",),
437 ("y_axis_title", "yAxisTitle"),
438 ),
439 "secondary_value": (
440 ("secondary_value", "secondaryValue"),
441 ("secondary_value_axis_title", "secondaryValueAxisTitle"),
442 ),
443 }
444
445
446 def _axis_titles(payload: dict[str, Any]) -> dict[str, Any]:
447 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
448 raw = payload.get("axis_titles", payload.get("axisTitles"))
449 style_raw = style.get("axis_titles", style.get("axisTitles"))
450 axis_map = raw if isinstance(raw, dict) else {}
451 style_axis_map = style_raw if isinstance(style_raw, dict) else {}
452
453 def pick(axis_keys: tuple[str, ...], root_keys: tuple[str, ...]) -> Any:
454 values: list[Any] = []
455 for key in root_keys:
456 values.extend((
457 payload.get(key),
458 style.get(key),
459 ))
460 for key in axis_keys + root_keys:
461 values.extend((
462 axis_map.get(key),
463 style_axis_map.get(key),
464 ))
465 return _first_present(*values)
466
467 return {
468 field_name: pick(axis_keys, root_keys)
469 for field_name, (axis_keys, root_keys) in _AXIS_TITLE_KEY_GROUPS.items()
470 }
471
472
473 def _metadata_text(value: Any) -> str | None:
474 entry = _chart_text_entry(value)
475 if entry is None:
476 return None
477 text, _ = entry
478 return _normalized_fallback_text(text)
479
480
481 def _native_chart_chrome_errors(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
482 fallback_texts = set(_visible_fallback_texts(elem))
483 missing: list[str] = []
484
485 for field_name in ("title", "subtitle"):
486 text = _metadata_text(payload.get(field_name))
487 if text and text not in fallback_texts:
488 missing.append(f"{field_name}={text!r}")
489
490 for field_name, value in _axis_titles(payload).items():
491 text = _metadata_text(value)
492 if text and text not in fallback_texts:
493 missing.append(f"axis_titles.{field_name}={text!r}")
494
495 if not missing:
496 return []
497
498 sample = ", ".join(missing[:5])
499 suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
500 return [
501 "Native PPTX chart metadata contains title/axis text that is not visible "
502 "inside the fallback marker and would appear only after "
503 f"--native-charts-and-tables: {sample}{suffix}. "
504 "Use `name` for object naming, or draw the same text in the chart fallback."
505 ]
506
507
508 def _native_chart_export_payload(
509 elem: ET.Element,
510 payload: dict[str, Any],
511 ) -> tuple[dict[str, Any], list[str]]:
512 if native_import_source(elem) == "pptx":
513 return payload, []
514 fallback_texts = set(_visible_fallback_texts(elem))
515 output = payload
516 messages: list[str] = []
517
518 def mutable_payload() -> dict[str, Any]:
519 nonlocal output
520 if output is payload:
521 output = dict(payload)
522 return output
523
524 def mutable_style(target: dict[str, Any]) -> dict[str, Any] | None:
525 style = target.get("style")
526 if not isinstance(style, dict):
527 return None
528 if target.get("style") is payload.get("style"):
529 style = dict(style)
530 target["style"] = style
531 return style
532
533 def drop_map_keys(source: dict[str, Any], map_key: str, keys: tuple[str, ...]) -> None:
534 raw_map = source.get(map_key)
535 if not isinstance(raw_map, dict):
536 return
537 next_map = dict(raw_map)
538 for key in keys:
539 next_map.pop(key, None)
540 if next_map:
541 source[map_key] = next_map
542 else:
543 source.pop(map_key, None)
544
545 for key in ("title", "subtitle"):
546 text = _metadata_text(payload.get(key))
547 if text and text not in fallback_texts:
548 mutable_payload().pop(key, None)
549 messages.append(
550 f"omitted native chart {key} {text!r} because it is not visible in the fallback"
551 )
552
553 for field_name, value in _axis_titles(payload).items():
554 text = _metadata_text(value)
555 if not text or text in fallback_texts:
556 continue
557 axis_keys, root_keys = _AXIS_TITLE_KEY_GROUPS[field_name]
558 target = mutable_payload()
559 for key in root_keys:
560 target.pop(key, None)
561 for map_key in ("axis_titles", "axisTitles"):
562 drop_map_keys(target, map_key, axis_keys + root_keys)
563 style = mutable_style(target)
564 if style is not None:
565 for key in root_keys:
566 style.pop(key, None)
567 for map_key in ("axis_titles", "axisTitles"):
568 drop_map_keys(style, map_key, axis_keys + root_keys)
569 messages.append(
570 f"omitted native chart axis_titles.{field_name} {text!r} "
571 "because it is not visible in the fallback"
572 )
573
574 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
575 show_legend = payload.get("show_legend", style.get("show_legend", False))
576 if show_legend:
577 legend_texts = _legend_candidate_texts(payload)
578 if legend_texts and not any(text in fallback_texts for text in legend_texts):
579 mutable_payload()["show_legend"] = False
580 messages.append(
581 "omitted native chart legend because show_legend=true has no "
582 "visible fallback legend text"
583 )
584
585 return output, messages
586
587
588 def _legend_candidate_texts(payload: dict[str, Any]) -> list[str]:
589 series_values: list[str] = []
590 category_values: list[str] = []
591 categories = payload.get("categories")
592 if isinstance(categories, list):
593 category_values.extend(str(item) for item in categories if str(item).strip())
594 series = payload.get("series")
595 if isinstance(series, list):
596 for item in series:
597 if isinstance(item, dict) and item.get("name") is not None:
598 series_values.append(str(item.get("name")))
599 plots = payload.get("plots")
600 if isinstance(plots, list):
601 for plot in plots:
602 if not isinstance(plot, dict):
603 continue
604 plot_series = plot.get("series")
605 if not isinstance(plot_series, list):
606 continue
607 for item in plot_series:
608 if isinstance(item, dict) and item.get("name") is not None:
609 series_values.append(str(item.get("name")))
610 chart_type = _compact_key(payload.get("type") or payload.get("chart_type") or "")
611 category_legend_types = {"pie", "doughnut", "donut", "ofpie", "pieofpie", "barofpie"}
612 values = category_values if chart_type in category_legend_types else series_values
613 normalized: list[str] = []
614 for value in values:
615 text = _normalized_fallback_text(value)
616 if text:
617 normalized.append(text)
618 return normalized
619
620
621 def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
622 fallback_texts = set(_visible_fallback_texts(elem))
623 warnings: list[str] = []
624 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
625 show_legend = payload.get("show_legend", style.get("show_legend", False))
626 if show_legend:
627 legend_texts = _legend_candidate_texts(payload)
628 if legend_texts and not any(text in fallback_texts for text in legend_texts):
629 warnings.append(
630 "Native PPTX chart has show_legend=true, but no series/category "
631 "legend text is visible inside the fallback marker. Add the "
632 "fallback legend or remove show_legend."
633 )
634
635 companion_entries = _chart_companion_entries(
636 payload,
637 include_title=False,
638 include_subtitle_as_caption=False,
639 )
640 missing_companion: list[str] = []
641 for item in companion_entries:
642 text = _normalized_fallback_text(item.get("text"))
643 if text and text not in fallback_texts:
644 missing_companion.append(text)
645 if missing_companion:
646 sample = ", ".join(repr(text) for text in missing_companion[:5])
647 suffix = "" if len(missing_companion) <= 5 else f", and {len(missing_companion) - 5} more"
648 warnings.append(
649 "Native PPTX chart companion text is not visible inside the fallback "
650 "marker and may appear only after --native-charts-and-tables: "
651 f"{sample}{suffix}. "
652 "Keep companion metadata aligned with visible chart annotations."
653 )
654 return warnings
655
656
657 def _text_box_xml(
658 ctx: ConvertContext,
659 *,
660 text: str,
661 role: str,
662 off_x: int,
663 off_y: int,
664 ext_cx: int,
665 ext_cy: int,
666 font_size: int,
667 color: str | None,
668 align: str = "l",
669 bold: bool = False,
670 font_face: str | None = None,
671 ) -> str:
672 shape_id = ctx.next_id()
673 align_key = _compact_key(align)
674 algn = {
675 "center": "ctr",
676 "centre": "ctr",
677 "ctr": "ctr",
678 "middle": "ctr",
679 "right": "r",
680 "r": "r",
681 "left": "l",
682 "l": "l",
683 }.get(align_key, "l")
684 fill_xml = (
685 f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
686 if color else ""
687 )
688 bold_attr = ' b="1"' if bold else ""
689 lang = detect_text_lang(text, ctx.primary_language)
690 run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
691 run_properties_xml = (
692 f'{fill_xml}{_font_face_xml(font_face)}'
693 f'{run_rtl}'
694 )
695 rtl_attr = (
696 ' rtl="1"'
697 if text_uses_rtl(text, ctx.primary_language)
698 else ''
699 )
700 name = _xml_escape(f"Chart {role.title()} {shape_id}")
701 return f'''<p:sp>
702 <p:nvSpPr>
703 <p:cNvPr id="{shape_id}" name="{name}"/>
704 <p:cNvSpPr txBox="1"/><p:nvPr/>
705 </p:nvSpPr>
706 <p:spPr>
707 <a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
708 <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
709 <a:noFill/>
710 <a:ln><a:noFill/></a:ln>
711 </p:spPr>
712 <p:txBody>
713 <a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
714 <a:lstStyle/>
715 <a:p><a:pPr algn="{algn}"{rtl_attr}/>
716 <a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
717 </a:p>
718 </p:txBody>
719 </p:sp>'''
720
721
722 def _chart_companion_entries(
723 payload: dict[str, Any],
724 *,
725 include_title: bool,
726 include_subtitle_as_caption: bool,
727 ) -> list[dict[str, Any]]:
728 entries: list[dict[str, Any]] = []
729
730 def add(role: str, value: Any) -> None:
731 if value is None:
732 return
733 values = value if isinstance(value, list) else [value]
734 for item in values:
735 if isinstance(item, dict):
736 text = _first_present(item.get("text"), item.get("value"), item.get("content"))
737 if text:
738 entries.append({"role": role, **item, "text": str(text)})
739 elif str(item).strip():
740 entries.append({"role": role, "text": str(item)})
741
742 if include_title:
743 add("title", payload.get("title"))
744 caption_value = payload.get("caption")
745 if caption_value is None and include_subtitle_as_caption:
746 caption_value = payload.get("subtitle")
747 add("caption", caption_value)
748 add("source", payload.get("source"))
749 for key in ("note", "notes", "footnote", "footnotes"):
750 add("note", payload.get(key))
751 return entries
752
753
754 def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
755 """Validate and resolve an optional explicit companion text box."""
756 box_keys = ("x", "y", "width", "height")
757 provided_box_keys = [key for key in box_keys if key in item]
758 if provided_box_keys and len(provided_box_keys) != len(box_keys):
759 raise RuntimeError(
760 "Native PPTX chart companion text boxes require x/y/width/height together"
761 )
762 if not provided_box_keys:
763 return None
764 return (
765 _powerpoint_emu(item["x"], "companion text x"),
766 _powerpoint_emu(item["y"], "companion text y"),
767 _powerpoint_emu(item["width"], "companion text width", positive=True),
768 _powerpoint_emu(item["height"], "companion text height", positive=True),
769 )
770
771
772 def _validate_chart_companion_boxes(
773 payload: dict[str, Any],
774 *,
775 chart_bounds: tuple[int, int, int, int],
776 include_title: bool,
777 include_subtitle_as_caption: bool,
778 ) -> None:
779 """Validate companion boxes without allocating shapes or relationships."""
780 _, chart_off_y, _, chart_ext_cy = chart_bounds
781 below_index = 0
782 for item in _chart_companion_entries(
783 payload,
784 include_title=include_title,
785 include_subtitle_as_caption=include_subtitle_as_caption,
786 ):
787 text = str(item.get("text") or "").strip()
788 if not text:
789 continue
790 if _chart_companion_box(item) is not None:
791 continue
792 if str(item.get("role") or "note") != "title":
793 _powerpoint_emu_value(
794 chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
795 "companion text y",
796 )
797 below_index += 1
798
799
800 def _chart_companion_text_xml(
801 ctx: ConvertContext,
802 payload: dict[str, Any],
803 *,
804 chart_bounds: tuple[int, int, int, int],
805 chart_style: dict[str, str | None],
806 note_font_size: int,
807 title_font_size: int,
808 include_title: bool,
809 include_subtitle_as_caption: bool,
810 ) -> str:
811 entries = _chart_companion_entries(
812 payload,
813 include_title=include_title,
814 include_subtitle_as_caption=include_subtitle_as_caption,
815 )
816 if not entries:
817 return ""
818
819 chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
820 parts: list[str] = []
821 below_index = 0
822 for item in entries:
823 role = str(item.get("role") or "note")
824 text = str(item.get("text") or "").strip()
825 if not text:
826 continue
827 font_size = _font_size_hpt(item.get("font_size", item.get("fontSize")), 16 if role == "title" else 12)
828 if role == "title" and item.get("font_size") is None and item.get("fontSize") is None:
829 font_size = title_font_size
830 elif item.get("font_size") is None and item.get("fontSize") is None:
831 font_size = note_font_size
832
833 color = _hex_or_none(item.get("color")) or chart_style.get("text_color")
834 font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
835 align = str(item.get("align") or ("ctr" if role == "title" else "l"))
836 bold = bool(item.get("bold", role == "title"))
837 explicit_box = _chart_companion_box(item)
838 if explicit_box is not None:
839 off_x, off_y, ext_cx, ext_cy = explicit_box
840 elif role == "title":
841 off_x = chart_off_x
842 off_y = chart_off_y
843 ext_cx = chart_ext_cx
844 ext_cy = px_to_emu(28)
845 else:
846 off_x = chart_off_x
847 off_y = _powerpoint_emu_value(
848 chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
849 "companion text y",
850 )
851 ext_cx = chart_ext_cx
852 ext_cy = px_to_emu(16)
853 below_index += 1
854 parts.append(_text_box_xml(
855 ctx,
856 text=text,
857 role=role,
858 off_x=off_x,
859 off_y=off_y,
860 ext_cx=ext_cx,
861 ext_cy=ext_cy,
862 font_size=font_size,
863 color=color,
864 align=align,
865 bold=bold,
866 font_face=font_face,
867 ))
868 return "".join(parts)
869
869 lines PYTHON