返回 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_title_is_bounded(payload: dict[str, Any]) -> bool:
344 """Return whether the classic title requests an explicit companion box."""
345 title = payload.get("title")
346 return isinstance(title, dict) and _chart_companion_box(title) is not None
347
348
349 def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
350 raw = _first_present(item.get("font_size"), item.get("fontSize"))
351 if raw is None:
352 return fallback
353 return _font_size_hpt(raw, 12)
354
355
356 def _chart_text_entry_color(item: dict[str, Any], fallback: str | None) -> str | None:
357 return _hex_or_none(_first_present(
358 item.get("color"),
359 item.get("font_color"),
360 item.get("fontColor"),
361 )) or fallback
362
363
364 def _chart_text_entry_font_face(item: dict[str, Any], fallback: str | None) -> str | None:
365 raw = _first_present(
366 item.get("font_family"),
367 item.get("fontFamily"),
368 item.get("font_face"),
369 item.get("fontFace"),
370 )
371 if raw is None:
372 return fallback
373 font_face = str(raw).strip()
374 return font_face or fallback
375
376
377 def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
378 if value is None:
379 return ""
380 if isinstance(value, bool):
381 raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
382 try:
383 alpha = float(value)
384 except (TypeError, ValueError, OverflowError):
385 raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
386 if not math.isfinite(alpha):
387 raise RuntimeError(f"Native PPTX chart {field_name} must be finite")
388 if alpha < 0 or alpha > 1:
389 raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
390 return f'<a:alpha val="{quantize_ooxml_alpha(alpha)}"/>'
391
392
393 def _axis_title_xml(
394 title: Any,
395 *,
396 font_size: int,
397 color: str | None = None,
398 font_face: str | None = None,
399 primary_language: str | None = None,
400 ) -> str:
401 entry = _chart_text_entry(title)
402 if entry is None:
403 return ""
404 text, item = entry
405 text_color = _chart_text_entry_color(item, color)
406 fill_xml = (
407 f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
408 if text_color else ""
409 )
410 lang = detect_text_lang(text, primary_language)
411 rtl_attr = (
412 ' rtl="1"'
413 if text_uses_rtl(text, primary_language)
414 else ''
415 )
416 run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
417 return (
418 "<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
419 f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
420 f'sz="{_chart_text_entry_font_size(item, font_size)}">'
421 f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
422 f"{run_rtl}</a:rPr>"
423 f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
424 "</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
425 )
426
427
428 _AXIS_TITLE_KEY_GROUPS = {
429 "category": (
430 ("category",),
431 ("category_axis_title", "categoryAxisTitle"),
432 ),
433 "value": (
434 ("value",),
435 ("value_axis_title", "valueAxisTitle"),
436 ),
437 "x": (
438 ("x",),
439 ("x_axis_title", "xAxisTitle"),
440 ),
441 "y": (
442 ("y",),
443 ("y_axis_title", "yAxisTitle"),
444 ),
445 "secondary_value": (
446 ("secondary_value", "secondaryValue"),
447 ("secondary_value_axis_title", "secondaryValueAxisTitle"),
448 ),
449 }
450
451
452 def _axis_titles(payload: dict[str, Any]) -> dict[str, Any]:
453 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
454 raw = payload.get("axis_titles", payload.get("axisTitles"))
455 style_raw = style.get("axis_titles", style.get("axisTitles"))
456 axis_map = raw if isinstance(raw, dict) else {}
457 style_axis_map = style_raw if isinstance(style_raw, dict) else {}
458
459 def pick(axis_keys: tuple[str, ...], root_keys: tuple[str, ...]) -> Any:
460 values: list[Any] = []
461 for key in root_keys:
462 values.extend((
463 payload.get(key),
464 style.get(key),
465 ))
466 for key in axis_keys + root_keys:
467 values.extend((
468 axis_map.get(key),
469 style_axis_map.get(key),
470 ))
471 return _first_present(*values)
472
473 return {
474 field_name: pick(axis_keys, root_keys)
475 for field_name, (axis_keys, root_keys) in _AXIS_TITLE_KEY_GROUPS.items()
476 }
477
478
479 def _metadata_text(value: Any) -> str | None:
480 entry = _chart_text_entry(value)
481 if entry is None:
482 return None
483 text, _ = entry
484 return _normalized_fallback_text(text)
485
486
487 def _native_chart_chrome_errors(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
488 fallback_texts = set(_visible_fallback_texts(elem))
489 missing: list[str] = []
490
491 for field_name in ("title", "subtitle"):
492 text = _metadata_text(payload.get(field_name))
493 if text and text not in fallback_texts:
494 missing.append(f"{field_name}={text!r}")
495
496 for field_name, value in _axis_titles(payload).items():
497 text = _metadata_text(value)
498 if text and text not in fallback_texts:
499 missing.append(f"axis_titles.{field_name}={text!r}")
500
501 if not missing:
502 return []
503
504 sample = ", ".join(missing[:5])
505 suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
506 return [
507 "Native PPTX chart metadata contains title/axis text that is not visible "
508 "inside the fallback marker and would appear only after "
509 f"--native-charts-and-tables: {sample}{suffix}. "
510 "Use `name` for object naming, or draw the same text in the chart fallback."
511 ]
512
513
514 def _native_chart_export_payload(
515 elem: ET.Element,
516 payload: dict[str, Any],
517 ) -> tuple[dict[str, Any], list[str]]:
518 if native_import_source(elem) == "pptx":
519 return payload, []
520 fallback_texts = set(_visible_fallback_texts(elem))
521 output = payload
522 messages: list[str] = []
523
524 def mutable_payload() -> dict[str, Any]:
525 nonlocal output
526 if output is payload:
527 output = dict(payload)
528 return output
529
530 def mutable_style(target: dict[str, Any]) -> dict[str, Any] | None:
531 style = target.get("style")
532 if not isinstance(style, dict):
533 return None
534 if target.get("style") is payload.get("style"):
535 style = dict(style)
536 target["style"] = style
537 return style
538
539 def drop_map_keys(source: dict[str, Any], map_key: str, keys: tuple[str, ...]) -> None:
540 raw_map = source.get(map_key)
541 if not isinstance(raw_map, dict):
542 return
543 next_map = dict(raw_map)
544 for key in keys:
545 next_map.pop(key, None)
546 if next_map:
547 source[map_key] = next_map
548 else:
549 source.pop(map_key, None)
550
551 for key in ("title", "subtitle"):
552 text = _metadata_text(payload.get(key))
553 if text and text not in fallback_texts:
554 mutable_payload().pop(key, None)
555 messages.append(
556 f"omitted native chart {key} {text!r} because it is not visible in the fallback"
557 )
558
559 for field_name, value in _axis_titles(payload).items():
560 text = _metadata_text(value)
561 if not text or text in fallback_texts:
562 continue
563 axis_keys, root_keys = _AXIS_TITLE_KEY_GROUPS[field_name]
564 target = mutable_payload()
565 for key in root_keys:
566 target.pop(key, None)
567 for map_key in ("axis_titles", "axisTitles"):
568 drop_map_keys(target, map_key, axis_keys + root_keys)
569 style = mutable_style(target)
570 if style is not None:
571 for key in root_keys:
572 style.pop(key, None)
573 for map_key in ("axis_titles", "axisTitles"):
574 drop_map_keys(style, map_key, axis_keys + root_keys)
575 messages.append(
576 f"omitted native chart axis_titles.{field_name} {text!r} "
577 "because it is not visible in the fallback"
578 )
579
580 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
581 show_legend = payload.get("show_legend", style.get("show_legend", False))
582 if show_legend:
583 legend_texts = _legend_candidate_texts(payload)
584 if legend_texts and not any(text in fallback_texts for text in legend_texts):
585 mutable_payload()["show_legend"] = False
586 messages.append(
587 "omitted native chart legend because show_legend=true has no "
588 "visible fallback legend text"
589 )
590
591 return output, messages
592
593
594 def _legend_candidate_texts(payload: dict[str, Any]) -> list[str]:
595 series_values: list[str] = []
596 category_values: list[str] = []
597 categories = payload.get("categories")
598 if isinstance(categories, list):
599 category_values.extend(str(item) for item in categories if str(item).strip())
600 series = payload.get("series")
601 if isinstance(series, list):
602 for item in series:
603 if isinstance(item, dict) and item.get("name") is not None:
604 series_values.append(str(item.get("name")))
605 plots = payload.get("plots")
606 if isinstance(plots, list):
607 for plot in plots:
608 if not isinstance(plot, dict):
609 continue
610 plot_series = plot.get("series")
611 if not isinstance(plot_series, list):
612 continue
613 for item in plot_series:
614 if isinstance(item, dict) and item.get("name") is not None:
615 series_values.append(str(item.get("name")))
616 chart_type = _compact_key(payload.get("type") or payload.get("chart_type") or "")
617 category_legend_types = {"pie", "doughnut", "donut", "ofpie", "pieofpie", "barofpie"}
618 values = category_values if chart_type in category_legend_types else series_values
619 normalized: list[str] = []
620 for value in values:
621 text = _normalized_fallback_text(value)
622 if text:
623 normalized.append(text)
624 return normalized
625
626
627 def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
628 fallback_texts = set(_visible_fallback_texts(elem))
629 warnings: list[str] = []
630 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
631 show_legend = payload.get("show_legend", style.get("show_legend", False))
632 if show_legend:
633 legend_texts = _legend_candidate_texts(payload)
634 if legend_texts and not any(text in fallback_texts for text in legend_texts):
635 warnings.append(
636 "Native PPTX chart has show_legend=true, but no series/category "
637 "legend text is visible inside the fallback marker. Add the "
638 "fallback legend or remove show_legend."
639 )
640
641 companion_entries = _chart_companion_entries(
642 payload,
643 include_title=False,
644 include_subtitle_as_caption=False,
645 )
646 missing_companion: list[str] = []
647 for item in companion_entries:
648 text = _normalized_fallback_text(item.get("text"))
649 if text and text not in fallback_texts:
650 missing_companion.append(text)
651 if missing_companion:
652 sample = ", ".join(repr(text) for text in missing_companion[:5])
653 suffix = "" if len(missing_companion) <= 5 else f", and {len(missing_companion) - 5} more"
654 warnings.append(
655 "Native PPTX chart companion text is not visible inside the fallback "
656 "marker and may appear only after --native-charts-and-tables: "
657 f"{sample}{suffix}. "
658 "Keep companion metadata aligned with visible chart annotations."
659 )
660 return warnings
661
662
663 def _text_box_xml(
664 ctx: ConvertContext,
665 *,
666 text: str,
667 role: str,
668 off_x: int,
669 off_y: int,
670 ext_cx: int,
671 ext_cy: int,
672 font_size: int,
673 color: str | None,
674 align: str = "l",
675 bold: bool = False,
676 font_face: str | None = None,
677 ) -> str:
678 shape_id = ctx.next_id()
679 align_key = _compact_key(align)
680 algn = {
681 "center": "ctr",
682 "centre": "ctr",
683 "ctr": "ctr",
684 "middle": "ctr",
685 "right": "r",
686 "r": "r",
687 "left": "l",
688 "l": "l",
689 }.get(align_key, "l")
690 fill_xml = (
691 f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
692 if color else ""
693 )
694 bold_attr = ' b="1"' if bold else ""
695 lang = detect_text_lang(text, ctx.primary_language)
696 run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
697 run_properties_xml = (
698 f'{fill_xml}{_font_face_xml(font_face)}'
699 f'{run_rtl}'
700 )
701 rtl_attr = (
702 ' rtl="1"'
703 if text_uses_rtl(text, ctx.primary_language)
704 else ''
705 )
706 name = _xml_escape(f"Chart {role.title()} {shape_id}")
707 return f'''<p:sp>
708 <p:nvSpPr>
709 <p:cNvPr id="{shape_id}" name="{name}"/>
710 <p:cNvSpPr txBox="1"/><p:nvPr/>
711 </p:nvSpPr>
712 <p:spPr>
713 <a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
714 <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
715 <a:noFill/>
716 <a:ln><a:noFill/></a:ln>
717 </p:spPr>
718 <p:txBody>
719 <a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
720 <a:lstStyle/>
721 <a:p><a:pPr algn="{algn}"{rtl_attr}/>
722 <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>
723 </a:p>
724 </p:txBody>
725 </p:sp>'''
726
727
728 def _chart_companion_entries(
729 payload: dict[str, Any],
730 *,
731 include_title: bool,
732 include_subtitle_as_caption: bool,
733 ) -> list[dict[str, Any]]:
734 entries: list[dict[str, Any]] = []
735
736 def add(role: str, value: Any) -> None:
737 if value is None:
738 return
739 values = value if isinstance(value, list) else [value]
740 for item in values:
741 if isinstance(item, dict):
742 text = _first_present(item.get("text"), item.get("value"), item.get("content"))
743 if text:
744 entries.append({"role": role, **item, "text": str(text)})
745 elif str(item).strip():
746 entries.append({"role": role, "text": str(item)})
747
748 if include_title:
749 add("title", payload.get("title"))
750 caption_value = payload.get("caption")
751 if caption_value is None and include_subtitle_as_caption:
752 caption_value = payload.get("subtitle")
753 add("caption", caption_value)
754 add("source", payload.get("source"))
755 for key in ("note", "notes", "footnote", "footnotes"):
756 add("note", payload.get(key))
757 return entries
758
759
760 def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
761 """Validate and resolve an optional explicit companion text box."""
762 box_keys = ("x", "y", "width", "height")
763 provided_box_keys = [key for key in box_keys if key in item]
764 if provided_box_keys and len(provided_box_keys) != len(box_keys):
765 raise RuntimeError(
766 "Native PPTX chart companion text boxes require x/y/width/height together"
767 )
768 if not provided_box_keys:
769 return None
770 return (
771 _powerpoint_emu(item["x"], "companion text x"),
772 _powerpoint_emu(item["y"], "companion text y"),
773 _powerpoint_emu(item["width"], "companion text width", positive=True),
774 _powerpoint_emu(item["height"], "companion text height", positive=True),
775 )
776
777
778 def _validate_chart_companion_boxes(
779 payload: dict[str, Any],
780 *,
781 chart_bounds: tuple[int, int, int, int],
782 include_title: bool,
783 include_subtitle_as_caption: bool,
784 ) -> None:
785 """Validate companion boxes without allocating shapes or relationships."""
786 title_bounded = _chart_title_is_bounded(payload)
787 if (
788 title_bounded
789 and not include_subtitle_as_caption
790 and _chart_text_entry(payload.get("subtitle")) is not None
791 ):
792 raise RuntimeError(
793 "Native PPTX classic chart bounded title does not support subtitle; "
794 "use a separately bounded caption"
795 )
796 _, chart_off_y, _, chart_ext_cy = chart_bounds
797 below_index = 0
798 for item in _chart_companion_entries(
799 payload,
800 include_title=include_title,
801 include_subtitle_as_caption=include_subtitle_as_caption,
802 ):
803 text = str(item.get("text") or "").strip()
804 if not text:
805 continue
806 if _chart_companion_box(item) is not None:
807 continue
808 if str(item.get("role") or "note") != "title":
809 _powerpoint_emu_value(
810 chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
811 "companion text y",
812 )
813 below_index += 1
814
815
816 def _chart_companion_text_xml(
817 ctx: ConvertContext,
818 payload: dict[str, Any],
819 *,
820 chart_bounds: tuple[int, int, int, int],
821 chart_style: dict[str, str | None],
822 note_font_size: int,
823 title_font_size: int,
824 include_title: bool,
825 include_subtitle_as_caption: bool,
826 ) -> str:
827 if _chart_title_is_bounded(payload):
828 include_title = True
829 entries = _chart_companion_entries(
830 payload,
831 include_title=include_title,
832 include_subtitle_as_caption=include_subtitle_as_caption,
833 )
834 if not entries:
835 return ""
836
837 chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
838 parts: list[str] = []
839 below_index = 0
840 for item in entries:
841 role = str(item.get("role") or "note")
842 text = str(item.get("text") or "").strip()
843 if not text:
844 continue
845 font_size = _font_size_hpt(item.get("font_size", item.get("fontSize")), 16 if role == "title" else 12)
846 if role == "title" and item.get("font_size") is None and item.get("fontSize") is None:
847 font_size = title_font_size
848 elif item.get("font_size") is None and item.get("fontSize") is None:
849 font_size = note_font_size
850
851 color = _hex_or_none(item.get("color")) or chart_style.get("text_color")
852 font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
853 align = str(item.get("align") or ("ctr" if role == "title" else "l"))
854 bold = bool(item.get("bold", role == "title"))
855 explicit_box = _chart_companion_box(item)
856 if explicit_box is not None:
857 off_x, off_y, ext_cx, ext_cy = explicit_box
858 elif role == "title":
859 off_x = chart_off_x
860 off_y = chart_off_y
861 ext_cx = chart_ext_cx
862 ext_cy = px_to_emu(28)
863 else:
864 off_x = chart_off_x
865 off_y = _powerpoint_emu_value(
866 chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
867 "companion text y",
868 )
869 ext_cx = chart_ext_cx
870 ext_cy = px_to_emu(16)
871 below_index += 1
872 parts.append(_text_box_xml(
873 ctx,
874 text=text,
875 role=role,
876 off_x=off_x,
877 off_y=off_y,
878 ext_cx=ext_cx,
879 ext_cy=ext_cy,
880 font_size=font_size,
881 color=color,
882 align=align,
883 bold=bold,
884 font_face=font_face,
885 ))
886 return "".join(parts)
887
887 lines PYTHON