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