| 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 |