| 1 | """Classic native chart XML emitters.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from typing import Any |
| 6 | from xml.etree import ElementTree as ET |
| 7 | |
| 8 | from ..drawingml.utils import ( |
| 9 | _xml_escape, |
| 10 | detect_text_lang, |
| 11 | text_has_rtl_characters, |
| 12 | text_uses_rtl, |
| 13 | ) |
| 14 | from .chart_data import ( |
| 15 | _DEFAULT_CHART_COLORS, |
| 16 | _category_axis_is_date, |
| 17 | _chart_list, |
| 18 | _chart_plot_area_layout, |
| 19 | _data_label_position, |
| 20 | _data_label_point_items, |
| 21 | _data_labels_config, |
| 22 | ) |
| 23 | from .chart_style import ( |
| 24 | _alpha_xml, |
| 25 | _axis_title_xml, |
| 26 | _axis_titles, |
| 27 | _chart_area_sp_pr_xml, |
| 28 | _chart_line_sp_pr_xml, |
| 29 | _chart_text_entry_color, |
| 30 | _chart_text_entry_font_face, |
| 31 | _chart_text_entry_font_size, |
| 32 | _chart_text_entry, |
| 33 | _chart_text_sizes, |
| 34 | _chart_title_is_bounded, |
| 35 | _chart_tx_pr_xml, |
| 36 | _classic_chart_style, |
| 37 | _font_face_xml, |
| 38 | _major_gridlines_xml, |
| 39 | ) |
| 40 | from .marker_common import ( |
| 41 | PACKAGE_REL_TYPE, |
| 42 | _bool_attr, |
| 43 | _chart_bool, |
| 44 | _clean_hex, |
| 45 | _compact_key, |
| 46 | _excel_col, |
| 47 | _first_present, |
| 48 | _font_size_hpt, |
| 49 | _hex_or_none, |
| 50 | _powerpoint_line_width_emu, |
| 51 | ) |
| 52 | |
| 53 | |
| 54 | def _string_cache(values: list[str]) -> str: |
| 55 | points = "".join( |
| 56 | f'<c:pt idx="{idx}"><c:v>{_xml_escape(value)}</c:v></c:pt>' |
| 57 | for idx, value in enumerate(values) |
| 58 | ) |
| 59 | return f'<c:strCache><c:ptCount val="{len(values)}"/>{points}</c:strCache>' |
| 60 | |
| 61 | |
| 62 | def _number_cache( |
| 63 | values: list[int | float], |
| 64 | number_format: str = "General", |
| 65 | ) -> str: |
| 66 | points = "".join( |
| 67 | f'<c:pt idx="{idx}"><c:v>{value}</c:v></c:pt>' |
| 68 | for idx, value in enumerate(values) |
| 69 | ) |
| 70 | return ( |
| 71 | f'<c:numCache><c:formatCode>{_xml_escape(number_format)}</c:formatCode>' |
| 72 | f'<c:ptCount val="{len(values)}"/>{points}</c:numCache>' |
| 73 | ) |
| 74 | |
| 75 | |
| 76 | def _category_reference_xml( |
| 77 | categories: list[Any], |
| 78 | *, |
| 79 | column_index: int = 1, |
| 80 | numeric: bool, |
| 81 | number_format: str | None = None, |
| 82 | ) -> str: |
| 83 | reference_tag = "numRef" if numeric else "strRef" |
| 84 | cache = ( |
| 85 | _number_cache(categories, number_format or "General") |
| 86 | if numeric |
| 87 | else _string_cache([str(value) for value in categories]) |
| 88 | ) |
| 89 | return ( |
| 90 | f"<c:cat><c:{reference_tag}>" |
| 91 | f"<c:f>Sheet1!${_excel_col(column_index)}$2:" |
| 92 | f"${_excel_col(column_index)}${len(categories) + 1}</c:f>" |
| 93 | f"{cache}" |
| 94 | f"</c:{reference_tag}></c:cat>" |
| 95 | ) |
| 96 | |
| 97 | |
| 98 | def _series_color_xml( |
| 99 | color: str | None, |
| 100 | *, |
| 101 | line: bool = True, |
| 102 | fill_opacity: Any = None, |
| 103 | line_width: Any = None, |
| 104 | ) -> str: |
| 105 | if not color: |
| 106 | return "" |
| 107 | clean = _clean_hex(color, "#4472C4") |
| 108 | alpha_xml = _alpha_xml(fill_opacity, "series fill_opacity") |
| 109 | line_width_xml = "" |
| 110 | if line_width is not None: |
| 111 | line_width_xml = ( |
| 112 | f' w="{_powerpoint_line_width_emu(line_width, "series line_width")}"' |
| 113 | ) |
| 114 | line_xml = ( |
| 115 | f'<a:ln{line_width_xml}><a:solidFill><a:srgbClr val="{clean}"/></a:solidFill></a:ln>' |
| 116 | if line else '<a:ln><a:noFill/></a:ln>' |
| 117 | ) |
| 118 | return ( |
| 119 | "<c:spPr>" |
| 120 | f'<a:solidFill><a:srgbClr val="{clean}">{alpha_xml}</a:srgbClr></a:solidFill>' |
| 121 | f'{line_xml}' |
| 122 | "</c:spPr>" |
| 123 | ) |
| 124 | |
| 125 | |
| 126 | def _data_label_flags_xml(config: dict[str, Any]) -> str: |
| 127 | show_value = _chart_bool( |
| 128 | _first_present(config.get("show_value"), config.get("showValue"), config.get("value")), |
| 129 | True, |
| 130 | ) |
| 131 | show_category = _chart_bool( |
| 132 | _first_present(config.get("show_category"), config.get("showCategory"), config.get("category")), |
| 133 | False, |
| 134 | ) |
| 135 | show_series = _chart_bool( |
| 136 | _first_present(config.get("show_series"), config.get("showSeries"), config.get("series")), |
| 137 | False, |
| 138 | ) |
| 139 | show_percent = _chart_bool( |
| 140 | _first_present(config.get("show_percent"), config.get("showPercent"), config.get("percent")), |
| 141 | False, |
| 142 | ) |
| 143 | return ( |
| 144 | '<c:showLegendKey val="0"/>' |
| 145 | f'<c:showVal val="{_bool_attr(show_value)}"/>' |
| 146 | f'<c:showCatName val="{_bool_attr(show_category)}"/>' |
| 147 | f'<c:showSerName val="{_bool_attr(show_series)}"/>' |
| 148 | f'<c:showPercent val="{_bool_attr(show_percent)}"/>' |
| 149 | '<c:showBubbleSize val="0"/>' |
| 150 | ) |
| 151 | |
| 152 | |
| 153 | def _data_labels_xml( |
| 154 | config: dict[str, Any] | None, |
| 155 | *, |
| 156 | chart_type: str, |
| 157 | grouping: str | None, |
| 158 | point_count: int, |
| 159 | font_size: int, |
| 160 | default_color: str | None, |
| 161 | default_font_face: str | None, |
| 162 | language: str | None = None, |
| 163 | ) -> str: |
| 164 | if config is None: |
| 165 | return "" |
| 166 | show_leader_lines = _chart_bool( |
| 167 | _first_present( |
| 168 | config.get("show_leader_lines"), |
| 169 | config.get("showLeaderLines"), |
| 170 | ), |
| 171 | False, |
| 172 | ) |
| 173 | leader_lines_xml = f'<c:showLeaderLines val="{_bool_attr(show_leader_lines)}"/>' |
| 174 | position = _data_label_position(config.get("position"), chart_type, grouping) |
| 175 | raw_font_size = _first_present(config.get("font_size"), config.get("fontSize")) |
| 176 | label_font_size = _font_size_hpt(raw_font_size, 12) if raw_font_size is not None else font_size |
| 177 | color = _hex_or_none(config.get("color")) or default_color |
| 178 | bold = _chart_bool(config.get("bold"), False) |
| 179 | font_face = _chart_text_entry_font_face(config, default_font_face) |
| 180 | tx_pr_xml = _chart_tx_pr_xml( |
| 181 | label_font_size, |
| 182 | color, |
| 183 | bold=bold, |
| 184 | font_face=font_face, |
| 185 | language=language, |
| 186 | ) |
| 187 | num_fmt = _first_present( |
| 188 | config.get("number_format"), |
| 189 | config.get("numberFormat"), |
| 190 | config.get("format"), |
| 191 | ) |
| 192 | num_fmt_xml = ( |
| 193 | f'<c:numFmt formatCode="{_xml_escape(str(num_fmt))}" sourceLinked="0"/>' |
| 194 | if num_fmt else "" |
| 195 | ) |
| 196 | flags_xml = _data_label_flags_xml(config) |
| 197 | point_items = _data_label_point_items( |
| 198 | config, |
| 199 | chart_type, |
| 200 | grouping, |
| 201 | point_count, |
| 202 | ) |
| 203 | if point_items: |
| 204 | selected_items = {int(item["idx"]): item for item in point_items} |
| 205 | point_label_xml = "" |
| 206 | for idx in range(point_count): |
| 207 | item = selected_items.get(idx) |
| 208 | if item is None: |
| 209 | point_label_xml += f'<c:dLbl><c:idx val="{idx}"/><c:delete val="1"/></c:dLbl>' |
| 210 | continue |
| 211 | item_font_size_raw = _first_present(item.get("font_size"), item.get("fontSize")) |
| 212 | item_font_size = ( |
| 213 | _font_size_hpt(item_font_size_raw, 12) |
| 214 | if item_font_size_raw is not None else label_font_size |
| 215 | ) |
| 216 | item_color = _hex_or_none(item.get("color")) or color |
| 217 | item_font_face = _chart_text_entry_font_face(item, font_face) |
| 218 | item_position = _data_label_position( |
| 219 | _first_present(item.get("position"), config.get("position")), |
| 220 | chart_type, |
| 221 | grouping, |
| 222 | ) |
| 223 | item_num_fmt = _first_present( |
| 224 | item.get("number_format"), |
| 225 | item.get("numberFormat"), |
| 226 | item.get("format"), |
| 227 | num_fmt, |
| 228 | ) |
| 229 | item_num_fmt_xml = ( |
| 230 | f'<c:numFmt formatCode="{_xml_escape(str(item_num_fmt))}" sourceLinked="0"/>' |
| 231 | if item_num_fmt else "" |
| 232 | ) |
| 233 | item_bold = _chart_bool(item.get("bold"), bold) |
| 234 | item_position_xml = f'<c:dLblPos val="{item_position}"/>' if item_position else "" |
| 235 | item_text_properties_xml = _chart_tx_pr_xml( |
| 236 | item_font_size, |
| 237 | item_color, |
| 238 | bold=item_bold, |
| 239 | font_face=item_font_face, |
| 240 | language=language, |
| 241 | ) |
| 242 | point_label_xml += ( |
| 243 | f'<c:dLbl><c:idx val="{idx}"/>' |
| 244 | f"{item_num_fmt_xml}" |
| 245 | f"{item_text_properties_xml}" |
| 246 | f"{item_position_xml}" |
| 247 | f"{_data_label_flags_xml({**config, **item})}" |
| 248 | "</c:dLbl>" |
| 249 | ) |
| 250 | return f"<c:dLbls>{point_label_xml}{leader_lines_xml}</c:dLbls>" |
| 251 | |
| 252 | label_colors = [ |
| 253 | _clean_hex(item, "#404040") |
| 254 | for item in _chart_list( |
| 255 | _first_present(config.get("colors"), config.get("label_colors"), config.get("labelColors")), |
| 256 | "data_labels.colors", |
| 257 | ) |
| 258 | ] |
| 259 | if label_colors and len(label_colors) != point_count: |
| 260 | raise RuntimeError("Native PPTX chart data_labels.colors must match point count") |
| 261 | point_label_xml = "" |
| 262 | for idx, label_color in enumerate(label_colors): |
| 263 | position_xml = f'<c:dLblPos val="{position}"/>' if position else "" |
| 264 | point_label_xml += ( |
| 265 | f'<c:dLbl><c:idx val="{idx}"/>' |
| 266 | f"{num_fmt_xml}" |
| 267 | f"{_chart_tx_pr_xml(label_font_size, label_color, bold=bold, font_face=font_face, language=language)}" |
| 268 | f"{position_xml}" |
| 269 | f"{flags_xml}" |
| 270 | "</c:dLbl>" |
| 271 | ) |
| 272 | position_xml = f'<c:dLblPos val="{position}"/>' if position else "" |
| 273 | return ( |
| 274 | "<c:dLbls>" |
| 275 | f"{point_label_xml}" |
| 276 | f"{num_fmt_xml}{tx_pr_xml}" |
| 277 | f"{position_xml}" |
| 278 | f"{flags_xml}" |
| 279 | f'{leader_lines_xml}' |
| 280 | "</c:dLbls>" |
| 281 | ) |
| 282 | |
| 283 | |
| 284 | def _series_scoped_data_labels(config: dict[str, Any] | None) -> bool: |
| 285 | """Return whether point-level overrides require a series ``c:dLbls``.""" |
| 286 | if config is None: |
| 287 | return False |
| 288 | return any( |
| 289 | bool(config.get(key)) |
| 290 | for key in ("points", "colors", "label_colors", "labelColors") |
| 291 | ) |
| 292 | |
| 293 | |
| 294 | def _chart_color(colors: list[str], index: int) -> str: |
| 295 | if index < len(colors): |
| 296 | return colors[index] |
| 297 | return _DEFAULT_CHART_COLORS[index % len(_DEFAULT_CHART_COLORS)] |
| 298 | |
| 299 | |
| 300 | def _data_point_colors_xml( |
| 301 | count: int, |
| 302 | colors: list[str], |
| 303 | *, |
| 304 | disable_negative_invert: bool = False, |
| 305 | ) -> str: |
| 306 | invert_xml = '<c:invertIfNegative val="0"/>' if disable_negative_invert else "" |
| 307 | return "".join( |
| 308 | f'<c:dPt><c:idx val="{idx}"/>{invert_xml}' |
| 309 | f'{_series_color_xml(_chart_color(colors, idx))}</c:dPt>' |
| 310 | for idx in range(count) |
| 311 | ) |
| 312 | |
| 313 | |
| 314 | def _marker_xml(symbol: str | None) -> str: |
| 315 | if not symbol: |
| 316 | return "" |
| 317 | if symbol == "none": |
| 318 | return '<c:marker><c:symbol val="none"/></c:marker>' |
| 319 | return f'<c:marker><c:symbol val="{_xml_escape(symbol)}"/></c:marker>' |
| 320 | |
| 321 | |
| 322 | def _series_xml( |
| 323 | categories: list[Any], |
| 324 | series: list[dict[str, Any]], |
| 325 | *, |
| 326 | chart_type: str, |
| 327 | grouping: str | None = None, |
| 328 | line_style: str = "line", |
| 329 | radar_marker_style: str | None = None, |
| 330 | radar_style: str = "marker", |
| 331 | colors: list[str], |
| 332 | category_is_numeric: bool = False, |
| 333 | category_number_format: str | None = None, |
| 334 | data_labels: dict[str, Any] | None = None, |
| 335 | data_label_font_size: int = 900, |
| 336 | data_label_color: str | None = None, |
| 337 | data_label_font_face: str | None = None, |
| 338 | language: str | None = None, |
| 339 | category_column: int = 1, |
| 340 | color_start_index: int | None = None, |
| 341 | series_indices: list[int] | None = None, |
| 342 | start_column: int = 2, |
| 343 | start_index: int = 0, |
| 344 | ) -> str: |
| 345 | parts: list[str] = [] |
| 346 | category_xml = _category_reference_xml( |
| 347 | categories, |
| 348 | column_index=category_column, |
| 349 | numeric=category_is_numeric, |
| 350 | number_format=category_number_format, |
| 351 | ) |
| 352 | for offset, item in enumerate(series): |
| 353 | index = ( |
| 354 | series_indices[offset] |
| 355 | if series_indices is not None |
| 356 | else start_index + offset |
| 357 | ) |
| 358 | color_index = ( |
| 359 | color_start_index |
| 360 | if color_start_index is not None |
| 361 | else start_index |
| 362 | ) + offset |
| 363 | column_index = offset + start_column |
| 364 | fill_opacity = item.get("fill_opacity") if chart_type == "area" else None |
| 365 | line_width = item.get("line_width") if chart_type in {"area", "line"} else None |
| 366 | color_xml = _series_color_xml( |
| 367 | _chart_color(colors, color_index), |
| 368 | fill_opacity=fill_opacity, |
| 369 | line_width=line_width, |
| 370 | ) |
| 371 | point_colors_xml = "" |
| 372 | marker_xml = "" |
| 373 | smooth_xml = "" |
| 374 | if chart_type in {"doughnut", "of_pie", "pie"}: |
| 375 | color_xml = "" |
| 376 | point_count = ( |
| 377 | len(categories) + 1 |
| 378 | if chart_type == "of_pie" |
| 379 | else len(categories) |
| 380 | ) |
| 381 | point_colors_xml = _data_point_colors_xml( |
| 382 | point_count, |
| 383 | item.get("point_colors") or colors, |
| 384 | ) |
| 385 | elif chart_type in {"bar", "column"} and item.get("point_colors"): |
| 386 | point_colors_xml = _data_point_colors_xml( |
| 387 | len(item["values"]), |
| 388 | item["point_colors"], |
| 389 | disable_negative_invert=True, |
| 390 | ) |
| 391 | if chart_type == "line": |
| 392 | marker_xml = _marker_xml("circle" if line_style == "lineMarker" else "none") |
| 393 | smooth_xml = '<c:smooth val="0"/>' |
| 394 | if chart_type == "radar": |
| 395 | if radar_style == "filled": |
| 396 | color_xml = _series_color_xml( |
| 397 | _chart_color(colors, color_index), |
| 398 | line=False, |
| 399 | ) |
| 400 | marker_xml = _marker_xml(radar_marker_style) |
| 401 | invert_xml = '<c:invertIfNegative val="0"/>' if chart_type in {"bar", "column"} else "" |
| 402 | data_labels_xml = ( |
| 403 | _data_labels_xml( |
| 404 | data_labels, |
| 405 | chart_type=chart_type, |
| 406 | grouping=grouping, |
| 407 | point_count=len(item["values"]), |
| 408 | font_size=data_label_font_size, |
| 409 | default_color=data_label_color, |
| 410 | default_font_face=data_label_font_face, |
| 411 | language=language, |
| 412 | ) |
| 413 | if _series_scoped_data_labels(data_labels) |
| 414 | and chart_type in {"area", "bar", "column", "line"} |
| 415 | else "" |
| 416 | ) |
| 417 | parts.append( |
| 418 | "<c:ser>" |
| 419 | f'<c:idx val="{index}"/><c:order val="{index}"/>' |
| 420 | "<c:tx><c:strRef>" |
| 421 | f"<c:f>Sheet1!${_excel_col(column_index)}$1</c:f>" |
| 422 | f"{_string_cache([str(item['name'])])}" |
| 423 | "</c:strRef></c:tx>" |
| 424 | f"{color_xml}{invert_xml}{marker_xml}{point_colors_xml}" |
| 425 | f"{data_labels_xml}" |
| 426 | f"{category_xml}" |
| 427 | "<c:val><c:numRef>" |
| 428 | f"<c:f>Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}</c:f>" |
| 429 | f"{_number_cache(item['values'])}" |
| 430 | "</c:numRef></c:val>" |
| 431 | f"{smooth_xml}" |
| 432 | "</c:ser>" |
| 433 | ) |
| 434 | return "".join(parts) |
| 435 | |
| 436 | |
| 437 | def _chart_title_paragraph_xml( |
| 438 | text: str, |
| 439 | *, |
| 440 | font_size: int, |
| 441 | color: str | None = None, |
| 442 | font_face: str | None = None, |
| 443 | primary_language: str | None = None, |
| 444 | ) -> str: |
| 445 | fill_xml = ( |
| 446 | f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>' |
| 447 | if color else "" |
| 448 | ) |
| 449 | lang = detect_text_lang(text, primary_language) |
| 450 | rtl_attr = ( |
| 451 | ' rtl="1"' |
| 452 | if text_uses_rtl(text, primary_language) |
| 453 | else '' |
| 454 | ) |
| 455 | run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else '' |
| 456 | return ( |
| 457 | f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" ' |
| 458 | f'sz="{font_size}">{fill_xml}{_font_face_xml(font_face)}' |
| 459 | f'{run_rtl}</a:rPr>' |
| 460 | f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>" |
| 461 | ) |
| 462 | |
| 463 | |
| 464 | def _chart_title_xml( |
| 465 | title: Any, |
| 466 | *, |
| 467 | font_size: int, |
| 468 | color: str | None = None, |
| 469 | subtitle: Any = None, |
| 470 | subtitle_font_size: int | None = None, |
| 471 | font_face: str | None = None, |
| 472 | primary_language: str | None = None, |
| 473 | ) -> str: |
| 474 | title_entry = _chart_text_entry(title) |
| 475 | subtitle_entry = _chart_text_entry(subtitle) |
| 476 | if title_entry is None and subtitle_entry is None: |
| 477 | return '<c:autoTitleDeleted val="1"/>' |
| 478 | paragraphs = [] |
| 479 | if title_entry is not None: |
| 480 | text, item = title_entry |
| 481 | paragraphs.append(_chart_title_paragraph_xml( |
| 482 | text, |
| 483 | font_size=_chart_text_entry_font_size(item, font_size), |
| 484 | color=_chart_text_entry_color(item, color), |
| 485 | font_face=_chart_text_entry_font_face(item, font_face), |
| 486 | primary_language=primary_language, |
| 487 | )) |
| 488 | if subtitle_entry is not None: |
| 489 | text, item = subtitle_entry |
| 490 | paragraphs.append(_chart_title_paragraph_xml( |
| 491 | text, |
| 492 | font_size=_chart_text_entry_font_size(item, subtitle_font_size or font_size), |
| 493 | color=_chart_text_entry_color(item, color), |
| 494 | font_face=_chart_text_entry_font_face(item, font_face), |
| 495 | primary_language=primary_language, |
| 496 | )) |
| 497 | return ( |
| 498 | "<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>" |
| 499 | f"{''.join(paragraphs)}" |
| 500 | "</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>" |
| 501 | '<c:autoTitleDeleted val="0"/>' |
| 502 | ) |
| 503 | |
| 504 | |
| 505 | def _plot_area_layout_xml( |
| 506 | chart_data: dict[str, Any], |
| 507 | chart_bounds: tuple[int, int, int, int], |
| 508 | ) -> str: |
| 509 | layout = _chart_plot_area_layout(chart_data, chart_bounds) |
| 510 | if layout is None: |
| 511 | return "<c:layout/>" |
| 512 | x, y, width, height = layout |
| 513 | return ( |
| 514 | "<c:layout><c:manualLayout>" |
| 515 | '<c:layoutTarget val="inner"/>' |
| 516 | '<c:xMode val="edge"/><c:yMode val="edge"/>' |
| 517 | '<c:wMode val="factor"/><c:hMode val="factor"/>' |
| 518 | f'<c:x val="{x:.12g}"/><c:y val="{y:.12g}"/>' |
| 519 | f'<c:w val="{width:.12g}"/><c:h val="{height:.12g}"/>' |
| 520 | "</c:manualLayout></c:layout>" |
| 521 | ) |
| 522 | |
| 523 | |
| 524 | def _chart_legend_xml( |
| 525 | payload: dict[str, Any], |
| 526 | *, |
| 527 | font_size: int, |
| 528 | color: str | None = None, |
| 529 | font_face: str | None = None, |
| 530 | primary_language: str | None = None, |
| 531 | ) -> str: |
| 532 | style = payload.get("style") if isinstance(payload.get("style"), dict) else {} |
| 533 | show_legend = payload.get("show_legend", style.get("show_legend", False)) |
| 534 | if not show_legend: |
| 535 | return "" |
| 536 | position_key = _compact_key(payload.get("legend_position") or style.get("legend_position") or "bottom") |
| 537 | positions = { |
| 538 | "bottom": "b", |
| 539 | "b": "b", |
| 540 | "left": "l", |
| 541 | "l": "l", |
| 542 | "right": "r", |
| 543 | "r": "r", |
| 544 | "top": "t", |
| 545 | "t": "t", |
| 546 | } |
| 547 | position = positions.get(position_key, "b") |
| 548 | return ( |
| 549 | f'<c:legend><c:legendPos val="{position}"/><c:layout/>' |
| 550 | '<c:overlay val="0"/>' |
| 551 | f'{_chart_tx_pr_xml(font_size, color, font_face=font_face, language=primary_language)}' |
| 552 | '</c:legend>' |
| 553 | ) |
| 554 | |
| 555 | |
| 556 | def _scatter_series_style_xml(scatter_style: str, color: str) -> tuple[str, str, str]: |
| 557 | has_line = scatter_style in {"line", "lineMarker", "smooth", "smoothMarker"} |
| 558 | has_marker = scatter_style in {"lineMarker", "marker", "smoothMarker"} |
| 559 | smooth = scatter_style in {"smooth", "smoothMarker"} |
| 560 | marker_symbol = "circle" if has_marker else "none" |
| 561 | return ( |
| 562 | _series_color_xml(color, line=has_line), |
| 563 | f'<c:marker><c:symbol val="{marker_symbol}"/></c:marker>', |
| 564 | f'<c:smooth val="{_bool_attr(smooth)}"/>', |
| 565 | ) |
| 566 | |
| 567 | |
| 568 | def _xy_series_xml( |
| 569 | series: list[dict[str, Any]], |
| 570 | *, |
| 571 | chart_type: str, |
| 572 | colors: list[str], |
| 573 | scatter_style: str = "lineMarker", |
| 574 | ) -> str: |
| 575 | parts: list[str] = [] |
| 576 | column_stride = 3 if chart_type == "bubble" else 2 |
| 577 | for index, item in enumerate(series): |
| 578 | x_col = 1 + index * column_stride |
| 579 | y_col = x_col + 1 |
| 580 | first_row = 2 |
| 581 | last_row = len(item["x"]) + 1 |
| 582 | color = _chart_color(colors, index) |
| 583 | color_xml = _series_color_xml(color) |
| 584 | marker_xml = "" |
| 585 | smooth_xml = "" |
| 586 | if chart_type == "scatter": |
| 587 | color_xml, marker_xml, smooth_xml = _scatter_series_style_xml(scatter_style, color) |
| 588 | invert_xml = '<c:invertIfNegative val="0"/>' if chart_type == "bubble" else "" |
| 589 | size_xml = "" |
| 590 | if chart_type == "bubble": |
| 591 | size_col = x_col + 2 |
| 592 | size_xml = ( |
| 593 | "<c:bubbleSize><c:numRef>" |
| 594 | f"<c:f>Sheet1!${_excel_col(size_col)}${first_row}:" |
| 595 | f"${_excel_col(size_col)}${last_row}</c:f>" |
| 596 | f"{_number_cache(item['sizes'])}" |
| 597 | "</c:numRef></c:bubbleSize><c:bubble3D val=\"0\"/>" |
| 598 | ) |
| 599 | parts.append( |
| 600 | "<c:ser>" |
| 601 | f'<c:idx val="{index}"/><c:order val="{index}"/>' |
| 602 | "<c:tx><c:strRef>" |
| 603 | f"<c:f>Sheet1!${_excel_col(y_col)}$1</c:f>" |
| 604 | f"{_string_cache([str(item['name'])])}" |
| 605 | "</c:strRef></c:tx>" |
| 606 | f"{color_xml}" |
| 607 | f"{marker_xml}" |
| 608 | f"{invert_xml}" |
| 609 | "<c:xVal><c:numRef>" |
| 610 | f"<c:f>Sheet1!${_excel_col(x_col)}${first_row}:" |
| 611 | f"${_excel_col(x_col)}${last_row}</c:f>" |
| 612 | f"{_number_cache(item['x'])}" |
| 613 | "</c:numRef></c:xVal>" |
| 614 | "<c:yVal><c:numRef>" |
| 615 | f"<c:f>Sheet1!${_excel_col(y_col)}${first_row}:" |
| 616 | f"${_excel_col(y_col)}${last_row}</c:f>" |
| 617 | f"{_number_cache(item['y'])}" |
| 618 | "</c:numRef></c:yVal>" |
| 619 | f"{size_xml}" |
| 620 | f"{smooth_xml}" |
| 621 | "</c:ser>" |
| 622 | ) |
| 623 | return "".join(parts) |
| 624 | |
| 625 | |
| 626 | def _bar_chart_group_xml( |
| 627 | chart_type: str, |
| 628 | grouping: str, |
| 629 | ser_xml: str, |
| 630 | *, |
| 631 | cat_ax_id: str, |
| 632 | val_ax_id: str, |
| 633 | vary_colors: bool = False, |
| 634 | data_labels_xml: str = "", |
| 635 | ) -> str: |
| 636 | bar_dir = "bar" if chart_type == "bar" else "col" |
| 637 | vary_colors_xml = '<c:varyColors val="1"/>' if vary_colors else '<c:varyColors val="0"/>' |
| 638 | overlap_xml = ( |
| 639 | '<c:overlap val="100"/>' |
| 640 | if grouping in {"stacked", "percentStacked"} |
| 641 | else "" |
| 642 | ) |
| 643 | return ( |
| 644 | "<c:barChart>" |
| 645 | f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>' |
| 646 | f"{vary_colors_xml}" |
| 647 | f"{ser_xml}" |
| 648 | f"{data_labels_xml}" |
| 649 | '<c:gapWidth val="150"/>' |
| 650 | f"{overlap_xml}" |
| 651 | f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' |
| 652 | "</c:barChart>" |
| 653 | ) |
| 654 | |
| 655 | |
| 656 | def _line_area_chart_group_xml( |
| 657 | chart_type: str, |
| 658 | grouping: str, |
| 659 | ser_xml: str, |
| 660 | *, |
| 661 | cat_ax_id: str, |
| 662 | val_ax_id: str, |
| 663 | data_labels_xml: str = "", |
| 664 | ) -> str: |
| 665 | tag = "lineChart" if chart_type == "line" else "areaChart" |
| 666 | line_tail_xml = '<c:marker val="1"/><c:smooth val="0"/>' if chart_type == "line" else "" |
| 667 | return ( |
| 668 | f'<c:{tag}><c:grouping val="{grouping}"/><c:varyColors val="0"/>' |
| 669 | f"{ser_xml}" |
| 670 | f"{data_labels_xml}" |
| 671 | f"{line_tail_xml}" |
| 672 | f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' |
| 673 | f"</c:{tag}>" |
| 674 | ) |
| 675 | |
| 676 | |
| 677 | def _axis_scaling_xml(config: dict[str, Any]) -> str: |
| 678 | orientation = "maxMin" if config.get("reverse") else "minMax" |
| 679 | maximum = ( |
| 680 | f'<c:max val="{config["maximum"]}"/>' |
| 681 | if config.get("maximum") is not None else "" |
| 682 | ) |
| 683 | minimum = ( |
| 684 | f'<c:min val="{config["minimum"]}"/>' |
| 685 | if config.get("minimum") is not None else "" |
| 686 | ) |
| 687 | return f'<c:scaling><c:orientation val="{orientation}"/>{maximum}{minimum}</c:scaling>' |
| 688 | |
| 689 | |
| 690 | def _axis_position(config: dict[str, Any], default: str) -> str: |
| 691 | return { |
| 692 | "bottom": "b", |
| 693 | "left": "l", |
| 694 | "right": "r", |
| 695 | "top": "t", |
| 696 | }.get(str(config.get("position") or ""), default) |
| 697 | |
| 698 | |
| 699 | def _axis_label_position(config: dict[str, Any], default: str) -> str: |
| 700 | return { |
| 701 | "high": "high", |
| 702 | "low": "low", |
| 703 | "next_to": "nextTo", |
| 704 | "none": "none", |
| 705 | }.get(str(config.get("label_position") or ""), default) |
| 706 | |
| 707 | |
| 708 | def _axis_number_format_xml( |
| 709 | config: dict[str, Any], |
| 710 | default: str | None = None, |
| 711 | ) -> str: |
| 712 | number_format = config.get("number_format", default) |
| 713 | if number_format is None: |
| 714 | return "" |
| 715 | return f'<c:numFmt formatCode="{_xml_escape(str(number_format))}" sourceLinked="0"/>' |
| 716 | |
| 717 | |
| 718 | def _axis_major_gridlines_xml( |
| 719 | config: dict[str, Any], |
| 720 | *, |
| 721 | default: bool, |
| 722 | color: str | None, |
| 723 | ) -> str: |
| 724 | enabled = config.get("major_gridlines", default) |
| 725 | return _major_gridlines_xml(color) if enabled else "" |
| 726 | |
| 727 | |
| 728 | def _axis_pair_xml( |
| 729 | cat_ax_id: str, |
| 730 | val_ax_id: str, |
| 731 | *, |
| 732 | axis_font_size: int, |
| 733 | axis_title_font_size: int, |
| 734 | axis_titles: dict[str, Any], |
| 735 | chart_style: dict[str, str | None], |
| 736 | chart_type: str, |
| 737 | grouping: str | None, |
| 738 | show_value_axis_labels: bool, |
| 739 | axes: dict[str, dict[str, Any]], |
| 740 | secondary: bool, |
| 741 | ) -> str: |
| 742 | primary_language = chart_style.get("primary_language") |
| 743 | category_role = "secondary_category" if secondary else "category" |
| 744 | value_role = "secondary_value" if secondary else "value" |
| 745 | category = axes.get(category_role, {}) |
| 746 | value = axes.get(value_role, {}) |
| 747 | category_kind = str(category.get("kind") or ("date" if chart_type == "stock" else "text")) |
| 748 | category_tag = "dateAx" if category_kind == "date" else "catAx" |
| 749 | default_cat_pos = "l" if chart_type == "bar" else "b" |
| 750 | default_val_pos = "r" if secondary else ("b" if chart_type == "bar" else "l") |
| 751 | cat_pos = _axis_position(category, default_cat_pos) |
| 752 | val_pos = _axis_position(value, default_val_pos) |
| 753 | cat_delete = _bool_attr(not category.get("visible", not secondary)) |
| 754 | val_delete = _bool_attr(not value.get("visible", True)) |
| 755 | cat_tick_label_pos = _axis_label_position(category, "nextTo") |
| 756 | default_val_tick = "nextTo" if show_value_axis_labels else "none" |
| 757 | val_tick_label_pos = _axis_label_position(value, default_val_tick) |
| 758 | default_value_format = "0%" if grouping == "percentStacked" else None |
| 759 | cat_number_format = _axis_number_format_xml( |
| 760 | category, |
| 761 | "m/d/yyyy" if category_kind == "date" else None, |
| 762 | ) |
| 763 | val_number_format = _axis_number_format_xml(value, default_value_format) |
| 764 | axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) |
| 765 | axis_tx_pr = _chart_tx_pr_xml( |
| 766 | axis_font_size, |
| 767 | chart_style.get("text_color"), |
| 768 | font_face=chart_style.get("font_face"), |
| 769 | language=primary_language, |
| 770 | ) |
| 771 | cat_title_xml = "" if secondary else _axis_title_xml( |
| 772 | _first_present(axis_titles.get("category"), axis_titles.get("x")), |
| 773 | font_size=axis_title_font_size, |
| 774 | color=chart_style.get("text_color"), |
| 775 | font_face=chart_style.get("font_face"), |
| 776 | primary_language=primary_language, |
| 777 | ) |
| 778 | value_title_key = "secondary_value" if secondary else "value" |
| 779 | value_title = axis_titles.get(value_title_key) |
| 780 | if not secondary: |
| 781 | value_title = _first_present(value_title, axis_titles.get("y")) |
| 782 | val_title_xml = _axis_title_xml( |
| 783 | value_title, |
| 784 | font_size=axis_title_font_size, |
| 785 | color=chart_style.get("text_color"), |
| 786 | font_face=chart_style.get("font_face"), |
| 787 | primary_language=primary_language, |
| 788 | ) |
| 789 | cat_gridlines = _axis_major_gridlines_xml( |
| 790 | category, |
| 791 | default=False, |
| 792 | color=chart_style.get("grid_color"), |
| 793 | ) |
| 794 | val_gridlines = _axis_major_gridlines_xml( |
| 795 | value, |
| 796 | default=not secondary, |
| 797 | color=chart_style.get("grid_color"), |
| 798 | ) |
| 799 | if category_kind == "date": |
| 800 | category_tail = '<c:auto val="1"/><c:lblOffset val="100"/><c:baseTimeUnit val="days"/>' |
| 801 | else: |
| 802 | category_tail = ( |
| 803 | '<c:auto val="1"/><c:lblAlgn val="ctr"/><c:lblOffset val="100"/>' |
| 804 | '<c:noMultiLvlLbl val="0"/>' |
| 805 | ) |
| 806 | is_combo = chart_type == "combo" |
| 807 | cross_between = "" |
| 808 | if chart_type == "area" and category_kind == "date": |
| 809 | cross_between = '<c:crossBetween val="midCat"/>' |
| 810 | elif chart_type == "stock" or is_combo: |
| 811 | cross_between = '<c:crossBetween val="between"/>' |
| 812 | major_unit = ( |
| 813 | f'<c:majorUnit val="{value["major_unit"]}"/>' |
| 814 | if value.get("major_unit") is not None else "" |
| 815 | ) |
| 816 | value_crosses = "max" if secondary else "autoZero" |
| 817 | return ( |
| 818 | f"<c:{category_tag}>" |
| 819 | f'<c:axId val="{cat_ax_id}"/>{_axis_scaling_xml(category)}' |
| 820 | f'<c:delete val="{cat_delete}"/><c:axPos val="{cat_pos}"/>' |
| 821 | f"{cat_gridlines}{cat_title_xml}{cat_number_format}" |
| 822 | '<c:majorTickMark val="out"/><c:minorTickMark val="none"/>' |
| 823 | f'<c:tickLblPos val="{cat_tick_label_pos}"/>' |
| 824 | f"{axis_sp_pr}{axis_tx_pr}" |
| 825 | f'<c:crossAx val="{val_ax_id}"/><c:crosses val="autoZero"/>{category_tail}' |
| 826 | f"</c:{category_tag}>" |
| 827 | "<c:valAx>" |
| 828 | f'<c:axId val="{val_ax_id}"/>{_axis_scaling_xml(value)}' |
| 829 | f'<c:delete val="{val_delete}"/><c:axPos val="{val_pos}"/>' |
| 830 | f"{val_gridlines}{val_title_xml}{val_number_format}" |
| 831 | '<c:majorTickMark val="out"/><c:minorTickMark val="none"/>' |
| 832 | f'<c:tickLblPos val="{val_tick_label_pos}"/>' |
| 833 | f"{axis_sp_pr}{axis_tx_pr}" |
| 834 | f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="{value_crosses}"/>' |
| 835 | f"{cross_between}{major_unit}" |
| 836 | "</c:valAx>" |
| 837 | ) |
| 838 | |
| 839 | |
| 840 | def _secondary_axis_xml( |
| 841 | cat_ax_id: str, |
| 842 | val_ax_id: str, |
| 843 | *, |
| 844 | axis_font_size: int, |
| 845 | axis_title_font_size: int, |
| 846 | axis_titles: dict[str, Any], |
| 847 | chart_style: dict[str, str | None], |
| 848 | grouping: str | None = None, |
| 849 | axes: dict[str, dict[str, Any]] | None = None, |
| 850 | ) -> str: |
| 851 | return _axis_pair_xml( |
| 852 | cat_ax_id, |
| 853 | val_ax_id, |
| 854 | axis_font_size=axis_font_size, |
| 855 | axis_title_font_size=axis_title_font_size, |
| 856 | axis_titles=axis_titles, |
| 857 | chart_style=chart_style, |
| 858 | chart_type="combo", |
| 859 | grouping=grouping, |
| 860 | show_value_axis_labels=True, |
| 861 | axes=axes or {}, |
| 862 | secondary=True, |
| 863 | ) |
| 864 | |
| 865 | |
| 866 | def _combo_axis_grouping(plots: list[dict[str, Any]], axis: str) -> str | None: |
| 867 | for plot in plots: |
| 868 | if plot.get("axis") == axis and plot.get("grouping") == "percentStacked": |
| 869 | return "percentStacked" |
| 870 | return None |
| 871 | |
| 872 | |
| 873 | def _combo_plot_layer(plot: dict[str, Any]) -> int: |
| 874 | return { |
| 875 | "area": 0, |
| 876 | "column": 1, |
| 877 | "line": 2, |
| 878 | }.get(str(plot.get("type")), 1) |
| 879 | |
| 880 | |
| 881 | def _combo_plot_xml( |
| 882 | chart_data: dict[str, Any], |
| 883 | colors: list[str], |
| 884 | *, |
| 885 | axis_font_size: int, |
| 886 | axis_title_font_size: int, |
| 887 | axis_titles: dict[str, Any], |
| 888 | chart_style: dict[str, str | None], |
| 889 | ) -> str: |
| 890 | axes = chart_data.get("axes") or {} |
| 891 | primary_cat_ax_id = "2068027336" |
| 892 | primary_val_ax_id = "2113994440" |
| 893 | secondary_cat_ax_id = "2080229232" |
| 894 | secondary_val_ax_id = "2098941040" |
| 895 | parts: list[str] = [] |
| 896 | |
| 897 | for plot in sorted(chart_data["plots"], key=_combo_plot_layer): |
| 898 | categories = plot["categories"] |
| 899 | category_is_numeric = bool(plot.get("category_is_numeric")) |
| 900 | chart_type = plot["type"] |
| 901 | axis = plot.get("axis", "primary") |
| 902 | cat_ax_id = secondary_cat_ax_id if axis == "secondary" else primary_cat_ax_id |
| 903 | val_ax_id = secondary_val_ax_id if axis == "secondary" else primary_val_ax_id |
| 904 | category_role = "secondary_category" if axis == "secondary" else "category" |
| 905 | category_number_format = axes.get(category_role, {}).get("number_format") |
| 906 | start_index = int(plot.get("start_index", 0)) |
| 907 | grouping = plot.get("grouping") or ("clustered" if chart_type == "column" else "standard") |
| 908 | ser_xml = _series_xml( |
| 909 | categories, |
| 910 | plot["series"], |
| 911 | chart_type=chart_type, |
| 912 | grouping=grouping, |
| 913 | colors=colors, |
| 914 | category_is_numeric=category_is_numeric, |
| 915 | category_number_format=category_number_format, |
| 916 | data_labels=_data_labels_config(plot), |
| 917 | data_label_font_size=axis_font_size, |
| 918 | data_label_color=chart_style.get("text_color"), |
| 919 | data_label_font_face=chart_style.get("font_face"), |
| 920 | language=chart_style.get("primary_language"), |
| 921 | line_style=plot.get("line_style", "line"), |
| 922 | category_column=int(plot.get("category_column", 1)), |
| 923 | color_start_index=start_index, |
| 924 | series_indices=plot.get("series_indices"), |
| 925 | start_column=int(plot.get("start_column", 2 + start_index)), |
| 926 | start_index=start_index, |
| 927 | ) |
| 928 | data_labels = _data_labels_config(plot) |
| 929 | data_labels_xml = ( |
| 930 | _data_labels_xml( |
| 931 | data_labels, |
| 932 | chart_type=chart_type, |
| 933 | grouping=grouping, |
| 934 | point_count=len(plot["series"][0]["values"]), |
| 935 | font_size=axis_font_size, |
| 936 | default_color=chart_style.get("text_color"), |
| 937 | default_font_face=chart_style.get("font_face"), |
| 938 | language=chart_style.get("primary_language"), |
| 939 | ) |
| 940 | if not _series_scoped_data_labels(data_labels) |
| 941 | else "" |
| 942 | ) |
| 943 | if chart_type == "column": |
| 944 | parts.append(_bar_chart_group_xml( |
| 945 | chart_type, |
| 946 | grouping, |
| 947 | ser_xml, |
| 948 | cat_ax_id=cat_ax_id, |
| 949 | val_ax_id=val_ax_id, |
| 950 | vary_colors=any(item.get("point_colors") for item in plot["series"]), |
| 951 | data_labels_xml=data_labels_xml, |
| 952 | )) |
| 953 | elif chart_type in {"area", "line"}: |
| 954 | parts.append(_line_area_chart_group_xml( |
| 955 | chart_type, |
| 956 | grouping, |
| 957 | ser_xml, |
| 958 | cat_ax_id=cat_ax_id, |
| 959 | val_ax_id=val_ax_id, |
| 960 | data_labels_xml=data_labels_xml, |
| 961 | )) |
| 962 | else: |
| 963 | raise RuntimeError("Native PPTX combo plots support column, line, and area only") |
| 964 | |
| 965 | has_secondary_axis = any(plot.get("axis") == "secondary" for plot in chart_data["plots"]) |
| 966 | axes_xml = _axis_xml( |
| 967 | primary_cat_ax_id, |
| 968 | primary_val_ax_id, |
| 969 | axis_font_size=axis_font_size, |
| 970 | axis_title_font_size=axis_title_font_size, |
| 971 | axis_titles=axis_titles, |
| 972 | chart_style=chart_style, |
| 973 | chart_type="combo", |
| 974 | grouping=_combo_axis_grouping(chart_data["plots"], "primary"), |
| 975 | axes=axes, |
| 976 | ) |
| 977 | if has_secondary_axis: |
| 978 | axes_xml += _secondary_axis_xml( |
| 979 | secondary_cat_ax_id, |
| 980 | secondary_val_ax_id, |
| 981 | axis_font_size=axis_font_size, |
| 982 | axis_title_font_size=axis_title_font_size, |
| 983 | axis_titles=axis_titles, |
| 984 | chart_style=chart_style, |
| 985 | grouping=_combo_axis_grouping(chart_data["plots"], "secondary"), |
| 986 | axes=axes, |
| 987 | ) |
| 988 | return "".join(parts) + axes_xml |
| 989 | |
| 990 | |
| 991 | def _chart_plot_xml( |
| 992 | chart_data: dict[str, Any], |
| 993 | colors: list[str], |
| 994 | *, |
| 995 | axis_font_size: int, |
| 996 | axis_title_font_size: int, |
| 997 | axis_titles: dict[str, Any], |
| 998 | chart_style: dict[str, str | None], |
| 999 | ) -> str: |
| 1000 | chart_type = chart_data["type"] |
| 1001 | cat_ax_id = "2068027336" |
| 1002 | val_ax_id = "2113994440" |
| 1003 | if chart_data["kind"] == "combo": |
| 1004 | return _combo_plot_xml( |
| 1005 | chart_data, |
| 1006 | colors, |
| 1007 | axis_font_size=axis_font_size, |
| 1008 | axis_title_font_size=axis_title_font_size, |
| 1009 | axis_titles=axis_titles, |
| 1010 | chart_style=chart_style, |
| 1011 | ) |
| 1012 | if chart_data["kind"] == "xy": |
| 1013 | x_ax_id = "2080229232" |
| 1014 | y_ax_id = "2098941040" |
| 1015 | ser_xml = _xy_series_xml( |
| 1016 | chart_data["series"], |
| 1017 | chart_type=chart_type, |
| 1018 | colors=colors, |
| 1019 | scatter_style=chart_data.get("scatter_style", "lineMarker"), |
| 1020 | ) |
| 1021 | if chart_type == "scatter": |
| 1022 | scatter_style = chart_data.get("scatter_style", "lineMarker") |
| 1023 | axes_xml = _xy_axis_xml( |
| 1024 | x_ax_id, |
| 1025 | y_ax_id, |
| 1026 | axis_font_size=axis_font_size, |
| 1027 | axis_title_font_size=axis_title_font_size, |
| 1028 | axis_titles=axis_titles, |
| 1029 | chart_style=chart_style, |
| 1030 | axes=chart_data.get("axes") or {}, |
| 1031 | ) |
| 1032 | return ( |
| 1033 | f'<c:scatterChart><c:scatterStyle val="{scatter_style}"/>' |
| 1034 | '<c:varyColors val="0"/>' |
| 1035 | f"{ser_xml}" |
| 1036 | f'<c:axId val="{x_ax_id}"/><c:axId val="{y_ax_id}"/>' |
| 1037 | "</c:scatterChart>" |
| 1038 | f"{axes_xml}" |
| 1039 | ) |
| 1040 | axes_xml = _xy_axis_xml( |
| 1041 | x_ax_id, |
| 1042 | y_ax_id, |
| 1043 | axis_font_size=axis_font_size, |
| 1044 | axis_title_font_size=axis_title_font_size, |
| 1045 | axis_titles=axis_titles, |
| 1046 | chart_style=chart_style, |
| 1047 | axes=chart_data.get("axes") or {}, |
| 1048 | ) |
| 1049 | return ( |
| 1050 | '<c:bubbleChart><c:varyColors val="0"/>' |
| 1051 | f"{ser_xml}" |
| 1052 | '<c:bubbleScale val="100"/><c:showNegBubbles val="0"/>' |
| 1053 | f'<c:axId val="{x_ax_id}"/><c:axId val="{y_ax_id}"/>' |
| 1054 | "</c:bubbleChart>" |
| 1055 | f"{axes_xml}" |
| 1056 | ) |
| 1057 | |
| 1058 | categories = chart_data["categories"] |
| 1059 | series = chart_data["series"] |
| 1060 | if chart_type == "stock": |
| 1061 | stock_cat_ax_id = "2068027336" |
| 1062 | stock_val_ax_id = "2113994440" |
| 1063 | stock_axes = chart_data.get("axes") or {} |
| 1064 | stock_category_format = None |
| 1065 | if stock_axes: |
| 1066 | stock_category_format = ( |
| 1067 | stock_axes.get("category", {}).get("number_format") |
| 1068 | or "m/d/yyyy" |
| 1069 | ) |
| 1070 | stock_series_xml = _stock_series_xml( |
| 1071 | categories, |
| 1072 | series, |
| 1073 | colors=colors, |
| 1074 | category_number_format=stock_category_format, |
| 1075 | ) |
| 1076 | axes_xml = _stock_axis_xml( |
| 1077 | stock_cat_ax_id, |
| 1078 | stock_val_ax_id, |
| 1079 | axis_font_size=axis_font_size, |
| 1080 | axis_title_font_size=axis_title_font_size, |
| 1081 | axis_titles=axis_titles, |
| 1082 | chart_style=chart_style, |
| 1083 | axes=stock_axes, |
| 1084 | ) |
| 1085 | return ( |
| 1086 | "<c:stockChart>" |
| 1087 | f"{stock_series_xml}" |
| 1088 | '<c:hiLowLines/>' |
| 1089 | '<c:upDownBars><c:gapWidth val="150"/><c:upBars/><c:downBars/></c:upDownBars>' |
| 1090 | f'<c:axId val="{stock_cat_ax_id}"/><c:axId val="{stock_val_ax_id}"/>' |
| 1091 | "</c:stockChart>" |
| 1092 | f"{axes_xml}" |
| 1093 | ) |
| 1094 | series_grouping = chart_data.get("grouping") or ( |
| 1095 | "clustered" if chart_type in {"bar", "column"} else "standard" |
| 1096 | ) |
| 1097 | ser_xml = _series_xml( |
| 1098 | categories, |
| 1099 | series, |
| 1100 | chart_type=chart_type, |
| 1101 | grouping=series_grouping, |
| 1102 | line_style=chart_data.get("line_style", "line"), |
| 1103 | radar_marker_style=chart_data.get("radar_marker_style"), |
| 1104 | radar_style=chart_data.get("radar_style", "marker"), |
| 1105 | colors=colors, |
| 1106 | category_is_numeric=_category_axis_is_date(chart_data.get("axes") or {}), |
| 1107 | category_number_format=( |
| 1108 | (chart_data.get("axes") or {}) |
| 1109 | .get("category", {}) |
| 1110 | .get( |
| 1111 | "number_format", |
| 1112 | "m/d/yyyy" |
| 1113 | if _category_axis_is_date(chart_data.get("axes") or {}) |
| 1114 | else None, |
| 1115 | ) |
| 1116 | ), |
| 1117 | data_labels=chart_data.get("data_labels"), |
| 1118 | data_label_font_size=axis_font_size, |
| 1119 | data_label_color=chart_style.get("text_color"), |
| 1120 | data_label_font_face=chart_style.get("font_face"), |
| 1121 | language=chart_style.get("primary_language"), |
| 1122 | ) |
| 1123 | data_labels_xml = ( |
| 1124 | _data_labels_xml( |
| 1125 | chart_data.get("data_labels"), |
| 1126 | chart_type=chart_type, |
| 1127 | grouping=series_grouping, |
| 1128 | point_count=len(series[0]["values"]), |
| 1129 | font_size=axis_font_size, |
| 1130 | default_color=chart_style.get("text_color"), |
| 1131 | default_font_face=chart_style.get("font_face"), |
| 1132 | language=chart_style.get("primary_language"), |
| 1133 | ) |
| 1134 | if chart_type in {"area", "bar", "column", "line"} |
| 1135 | and not _series_scoped_data_labels(chart_data.get("data_labels")) |
| 1136 | else "" |
| 1137 | ) |
| 1138 | |
| 1139 | if chart_type in {"bar", "column"}: |
| 1140 | bar_dir = "bar" if chart_type == "bar" else "col" |
| 1141 | grouping = series_grouping |
| 1142 | axes_xml = _axis_xml( |
| 1143 | cat_ax_id, |
| 1144 | val_ax_id, |
| 1145 | axis_font_size=axis_font_size, |
| 1146 | axis_title_font_size=axis_title_font_size, |
| 1147 | axis_titles=axis_titles, |
| 1148 | chart_style=chart_style, |
| 1149 | chart_type=chart_type, |
| 1150 | grouping=grouping, |
| 1151 | show_value_axis_labels=chart_data.get("show_value_axis_labels", True), |
| 1152 | axes=chart_data.get("axes") or {}, |
| 1153 | ) |
| 1154 | overlap_xml = ( |
| 1155 | '<c:overlap val="100"/>' |
| 1156 | if grouping in {"stacked", "percentStacked"} |
| 1157 | else "" |
| 1158 | ) |
| 1159 | vary_colors_xml = ( |
| 1160 | '<c:varyColors val="1"/>' |
| 1161 | if any(item.get("point_colors") for item in series) |
| 1162 | else '<c:varyColors val="0"/>' |
| 1163 | ) |
| 1164 | return ( |
| 1165 | "<c:barChart>" |
| 1166 | f'<c:barDir val="{bar_dir}"/><c:grouping val="{grouping}"/>' |
| 1167 | f"{vary_colors_xml}" |
| 1168 | f"{ser_xml}" |
| 1169 | f"{data_labels_xml}" |
| 1170 | '<c:gapWidth val="150"/>' |
| 1171 | f"{overlap_xml}" |
| 1172 | f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' |
| 1173 | "</c:barChart>" |
| 1174 | f"{axes_xml}" |
| 1175 | ) |
| 1176 | if chart_type in {"line", "area"}: |
| 1177 | tag = "lineChart" if chart_type == "line" else "areaChart" |
| 1178 | grouping = series_grouping |
| 1179 | axes_xml = _axis_xml( |
| 1180 | cat_ax_id, |
| 1181 | val_ax_id, |
| 1182 | axis_font_size=axis_font_size, |
| 1183 | axis_title_font_size=axis_title_font_size, |
| 1184 | axis_titles=axis_titles, |
| 1185 | chart_style=chart_style, |
| 1186 | chart_type=chart_type, |
| 1187 | grouping=grouping, |
| 1188 | show_value_axis_labels=chart_data.get("show_value_axis_labels", True), |
| 1189 | axes=chart_data.get("axes") or {}, |
| 1190 | ) |
| 1191 | line_tail_xml = '<c:marker val="1"/><c:smooth val="0"/>' if chart_type == "line" else "" |
| 1192 | return ( |
| 1193 | f'<c:{tag}><c:grouping val="{grouping}"/><c:varyColors val="0"/>' |
| 1194 | f"{ser_xml}" |
| 1195 | f"{data_labels_xml}" |
| 1196 | f"{line_tail_xml}" |
| 1197 | f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' |
| 1198 | f"</c:{tag}>" |
| 1199 | f"{axes_xml}" |
| 1200 | ) |
| 1201 | if chart_type == "doughnut": |
| 1202 | return ( |
| 1203 | '<c:doughnutChart><c:varyColors val="1"/>' |
| 1204 | f"{ser_xml}" |
| 1205 | '<c:firstSliceAng val="0"/>' |
| 1206 | f'<c:holeSize val="{chart_data["hole_size"]}"/>' |
| 1207 | "</c:doughnutChart>" |
| 1208 | ) |
| 1209 | if chart_type == "of_pie": |
| 1210 | of_pie_type = chart_data.get("of_pie_type", "pie") |
| 1211 | return ( |
| 1212 | f'<c:ofPieChart><c:ofPieType val="{of_pie_type}"/>' |
| 1213 | '<c:varyColors val="1"/>' |
| 1214 | f"{ser_xml}" |
| 1215 | '<c:gapWidth val="100"/><c:secondPieSize val="75"/><c:serLines/>' |
| 1216 | "</c:ofPieChart>" |
| 1217 | ) |
| 1218 | if chart_type == "radar": |
| 1219 | radar_style = chart_data.get("radar_style", "marker") |
| 1220 | axes_xml = _axis_xml( |
| 1221 | cat_ax_id, |
| 1222 | val_ax_id, |
| 1223 | axis_font_size=axis_font_size, |
| 1224 | axis_title_font_size=axis_title_font_size, |
| 1225 | axis_titles=axis_titles, |
| 1226 | chart_style=chart_style, |
| 1227 | chart_type=chart_type, |
| 1228 | show_value_axis_labels=chart_data.get("show_value_axis_labels", True), |
| 1229 | axes=chart_data.get("axes") or {}, |
| 1230 | ) |
| 1231 | return ( |
| 1232 | f'<c:radarChart><c:radarStyle val="{radar_style}"/>' |
| 1233 | '<c:varyColors val="0"/>' |
| 1234 | f"{ser_xml}" |
| 1235 | f'<c:axId val="{cat_ax_id}"/><c:axId val="{val_ax_id}"/>' |
| 1236 | "</c:radarChart>" |
| 1237 | f"{axes_xml}" |
| 1238 | ) |
| 1239 | return f'<c:pieChart><c:varyColors val="1"/>{ser_xml}<c:firstSliceAng val="0"/></c:pieChart>' |
| 1240 | |
| 1241 | |
| 1242 | def _axis_xml( |
| 1243 | cat_ax_id: str, |
| 1244 | val_ax_id: str, |
| 1245 | *, |
| 1246 | axis_font_size: int, |
| 1247 | axis_title_font_size: int, |
| 1248 | axis_titles: dict[str, Any], |
| 1249 | chart_style: dict[str, str | None], |
| 1250 | chart_type: str, |
| 1251 | grouping: str | None = None, |
| 1252 | show_value_axis_labels: bool = True, |
| 1253 | axes: dict[str, dict[str, Any]] | None = None, |
| 1254 | ) -> str: |
| 1255 | return _axis_pair_xml( |
| 1256 | cat_ax_id, |
| 1257 | val_ax_id, |
| 1258 | axis_font_size=axis_font_size, |
| 1259 | axis_title_font_size=axis_title_font_size, |
| 1260 | axis_titles=axis_titles, |
| 1261 | chart_style=chart_style, |
| 1262 | chart_type=chart_type, |
| 1263 | grouping=grouping, |
| 1264 | show_value_axis_labels=show_value_axis_labels, |
| 1265 | axes=axes or {}, |
| 1266 | secondary=False, |
| 1267 | ) |
| 1268 | |
| 1269 | |
| 1270 | def _xy_axis_xml( |
| 1271 | x_ax_id: str, |
| 1272 | y_ax_id: str, |
| 1273 | *, |
| 1274 | axis_font_size: int, |
| 1275 | axis_title_font_size: int, |
| 1276 | axis_titles: dict[str, Any], |
| 1277 | chart_style: dict[str, str | None], |
| 1278 | axes: dict[str, dict[str, Any]] | None = None, |
| 1279 | ) -> str: |
| 1280 | primary_language = chart_style.get("primary_language") |
| 1281 | normalized_axes = axes or {} |
| 1282 | x_axis = normalized_axes.get("x", {}) |
| 1283 | y_axis = normalized_axes.get("y", {}) |
| 1284 | axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color")) |
| 1285 | axis_tx_pr = _chart_tx_pr_xml( |
| 1286 | axis_font_size, |
| 1287 | chart_style.get("text_color"), |
| 1288 | font_face=chart_style.get("font_face"), |
| 1289 | language=primary_language, |
| 1290 | ) |
| 1291 | x_title_xml = _axis_title_xml( |
| 1292 | _first_present(axis_titles.get("x"), axis_titles.get("category")), |
| 1293 | font_size=axis_title_font_size, |
| 1294 | color=chart_style.get("text_color"), |
| 1295 | font_face=chart_style.get("font_face"), |
| 1296 | primary_language=primary_language, |
| 1297 | ) |
| 1298 | y_title_xml = _axis_title_xml( |
| 1299 | _first_present(axis_titles.get("y"), axis_titles.get("value")), |
| 1300 | font_size=axis_title_font_size, |
| 1301 | color=chart_style.get("text_color"), |
| 1302 | font_face=chart_style.get("font_face"), |
| 1303 | primary_language=primary_language, |
| 1304 | ) |
| 1305 | |
| 1306 | def value_axis_xml( |
| 1307 | axis_id: str, |
| 1308 | cross_axis_id: str, |
| 1309 | config: dict[str, Any], |
| 1310 | *, |
| 1311 | default_position: str, |
| 1312 | default_gridlines: bool, |
| 1313 | title_xml: str, |
| 1314 | ) -> str: |
| 1315 | delete = _bool_attr(not config.get("visible", True)) |
| 1316 | position = _axis_position(config, default_position) |
| 1317 | gridlines = _axis_major_gridlines_xml( |
| 1318 | config, |
| 1319 | default=default_gridlines, |
| 1320 | color=chart_style.get("grid_color"), |
| 1321 | ) |
| 1322 | number_format = _axis_number_format_xml(config) |
| 1323 | tick_label_position = _axis_label_position(config, "nextTo") |
| 1324 | major_unit = ( |
| 1325 | f'<c:majorUnit val="{config["major_unit"]}"/>' |
| 1326 | if config.get("major_unit") is not None else "" |
| 1327 | ) |
| 1328 | return ( |
| 1329 | "<c:valAx>" |
| 1330 | f'<c:axId val="{axis_id}"/>{_axis_scaling_xml(config)}' |
| 1331 | f'<c:delete val="{delete}"/><c:axPos val="{position}"/>' |
| 1332 | f"{gridlines}{title_xml}{number_format}" |
| 1333 | '<c:majorTickMark val="out"/><c:minorTickMark val="none"/>' |
| 1334 | f'<c:tickLblPos val="{tick_label_position}"/>' |
| 1335 | f"{axis_sp_pr}{axis_tx_pr}" |
| 1336 | f'<c:crossAx val="{cross_axis_id}"/><c:crosses val="autoZero"/>' |
| 1337 | f'<c:crossBetween val="midCat"/>{major_unit}' |
| 1338 | "</c:valAx>" |
| 1339 | ) |
| 1340 | |
| 1341 | return value_axis_xml( |
| 1342 | x_ax_id, |
| 1343 | y_ax_id, |
| 1344 | x_axis, |
| 1345 | default_position="b", |
| 1346 | default_gridlines=False, |
| 1347 | title_xml=x_title_xml, |
| 1348 | ) + value_axis_xml( |
| 1349 | y_ax_id, |
| 1350 | x_ax_id, |
| 1351 | y_axis, |
| 1352 | default_position="l", |
| 1353 | default_gridlines=True, |
| 1354 | title_xml=y_title_xml, |
| 1355 | ) |
| 1356 | |
| 1357 | |
| 1358 | def _stock_series_xml( |
| 1359 | categories: list[int | float], |
| 1360 | series: list[dict[str, Any]], |
| 1361 | *, |
| 1362 | colors: list[str], |
| 1363 | category_number_format: str | None = None, |
| 1364 | ) -> str: |
| 1365 | parts: list[str] = [] |
| 1366 | for index, item in enumerate(series): |
| 1367 | column_index = index + 2 |
| 1368 | parts.append( |
| 1369 | "<c:ser>" |
| 1370 | f'<c:idx val="{index}"/><c:order val="{index}"/>' |
| 1371 | "<c:tx><c:strRef>" |
| 1372 | f"<c:f>Sheet1!${_excel_col(column_index)}$1</c:f>" |
| 1373 | f"{_string_cache([str(item['name'])])}" |
| 1374 | "</c:strRef></c:tx>" |
| 1375 | '<c:spPr><a:ln><a:noFill/></a:ln></c:spPr>' |
| 1376 | '<c:marker><c:symbol val="none"/></c:marker>' |
| 1377 | "<c:cat><c:numRef>" |
| 1378 | f"<c:f>Sheet1!$A$2:$A${len(categories) + 1}</c:f>" |
| 1379 | f"{_number_cache(categories, category_number_format or 'General')}" |
| 1380 | "</c:numRef></c:cat>" |
| 1381 | "<c:val><c:numRef>" |
| 1382 | f"<c:f>Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(categories) + 1}</c:f>" |
| 1383 | f"{_number_cache(item['values'])}" |
| 1384 | "</c:numRef></c:val>" |
| 1385 | '<c:smooth val="0"/>' |
| 1386 | "</c:ser>" |
| 1387 | ) |
| 1388 | return "".join(parts) |
| 1389 | |
| 1390 | |
| 1391 | def _stock_axis_xml( |
| 1392 | cat_ax_id: str, |
| 1393 | val_ax_id: str, |
| 1394 | *, |
| 1395 | axis_font_size: int, |
| 1396 | axis_title_font_size: int, |
| 1397 | axis_titles: dict[str, Any], |
| 1398 | chart_style: dict[str, str | None], |
| 1399 | axes: dict[str, dict[str, Any]] | None = None, |
| 1400 | ) -> str: |
| 1401 | normalized_axes = dict(axes or {}) |
| 1402 | normalized_axes.setdefault("category", {"kind": "date", "position": "bottom"}) |
| 1403 | return _axis_pair_xml( |
| 1404 | cat_ax_id, |
| 1405 | val_ax_id, |
| 1406 | axis_font_size=axis_font_size, |
| 1407 | axis_title_font_size=axis_title_font_size, |
| 1408 | axis_titles=axis_titles, |
| 1409 | chart_style=chart_style, |
| 1410 | chart_type="stock", |
| 1411 | grouping=None, |
| 1412 | show_value_axis_labels=True, |
| 1413 | axes=normalized_axes, |
| 1414 | secondary=False, |
| 1415 | ) |
| 1416 | |
| 1417 | |
| 1418 | def _chart_xml( |
| 1419 | elem: ET.Element, |
| 1420 | payload: dict[str, Any], |
| 1421 | *, |
| 1422 | chart_rels_id: str, |
| 1423 | chart_data: dict[str, Any], |
| 1424 | chart_bounds: tuple[int, int, int, int], |
| 1425 | inherited_styles: dict[str, str] | None = None, |
| 1426 | primary_language: str | None = None, |
| 1427 | ) -> bytes: |
| 1428 | style = payload.get("style") if isinstance(payload.get("style"), dict) else {} |
| 1429 | colors = ( |
| 1430 | [_clean_hex(color, "#4472C4") for color in style.get("colors", [])] |
| 1431 | if isinstance(style.get("colors"), list) |
| 1432 | else [] |
| 1433 | ) |
| 1434 | text_sizes = _chart_text_sizes(payload, elem, inherited_styles) |
| 1435 | axis_titles = _axis_titles(payload) |
| 1436 | chart_style = _classic_chart_style(payload, elem, inherited_styles) |
| 1437 | chart_style["primary_language"] = primary_language |
| 1438 | plot_xml = _chart_plot_xml( |
| 1439 | chart_data, |
| 1440 | colors, |
| 1441 | axis_font_size=text_sizes["axis"], |
| 1442 | axis_title_font_size=text_sizes["axis_title"], |
| 1443 | axis_titles=axis_titles, |
| 1444 | chart_style=chart_style, |
| 1445 | ) |
| 1446 | bounded_title = _chart_title_is_bounded(payload) |
| 1447 | title_xml = _chart_title_xml( |
| 1448 | None if bounded_title else payload.get("title"), |
| 1449 | font_size=text_sizes["title"], |
| 1450 | color=chart_style.get("text_color"), |
| 1451 | subtitle=None if bounded_title else payload.get("subtitle"), |
| 1452 | subtitle_font_size=text_sizes["subtitle"], |
| 1453 | font_face=chart_style.get("font_face"), |
| 1454 | primary_language=primary_language, |
| 1455 | ) |
| 1456 | legend_xml = _chart_legend_xml( |
| 1457 | payload, |
| 1458 | font_size=text_sizes["legend"], |
| 1459 | color=chart_style.get("text_color"), |
| 1460 | font_face=chart_style.get("font_face"), |
| 1461 | primary_language=primary_language, |
| 1462 | ) |
| 1463 | chart_language = primary_language or "en-US" |
| 1464 | base_text_properties_xml = _chart_tx_pr_xml( |
| 1465 | text_sizes["base"], |
| 1466 | chart_style.get("text_color"), |
| 1467 | font_face=chart_style.get("font_face"), |
| 1468 | language=primary_language, |
| 1469 | ) |
| 1470 | xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 1471 | <c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" |
| 1472 | xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" |
| 1473 | xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> |
| 1474 | <c:date1904 val="0"/> |
| 1475 | <c:lang val="{_xml_escape(chart_language)}"/> |
| 1476 | <c:chart> |
| 1477 | {title_xml} |
| 1478 | <c:plotArea>{_plot_area_layout_xml(chart_data, chart_bounds)}{plot_xml}{_chart_area_sp_pr_xml(chart_style.get("plot_fill"))}</c:plotArea> |
| 1479 | {legend_xml} |
| 1480 | <c:plotVisOnly val="1"/> |
| 1481 | <c:dispBlanksAs val="gap"/> |
| 1482 | </c:chart> |
| 1483 | {_chart_area_sp_pr_xml(chart_style.get("chart_fill"))} |
| 1484 | {base_text_properties_xml} |
| 1485 | <c:externalData r:id="{chart_rels_id}"><c:autoUpdate val="0"/></c:externalData> |
| 1486 | </c:chartSpace>''' |
| 1487 | return xml.encode("utf-8") |
| 1488 | |
| 1489 | |
| 1490 | def _chart_rels_xml(workbook_target: str) -> bytes: |
| 1491 | xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 1492 | <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> |
| 1493 | <Relationship Id="rId1" Type="{PACKAGE_REL_TYPE}" Target="{_xml_escape(workbook_target)}"/> |
| 1494 | </Relationships>''' |
| 1495 | return xml.encode("utf-8") |
| 1496 |