返回 ppt-master
chart_xml.py
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
1472 lines PYTHON