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