返回 ppt-master
normalized_chart_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / normalized_chart_svg.py
1 """Deterministic normalized SVG fallback for parsed classic charts.
2
3 The renderer is intentionally independent from the native-chart OOXML
4 emitter. It visualizes only data and semantics that ``chart_to_svg`` has
5 already accepted, so a rendering failure can never invalidate an otherwise
6 valid editable-chart payload.
7 """
8
9 from __future__ import annotations
10
11 import html
12 import math
13 import re
14 from dataclasses import dataclass
15 from datetime import date, timedelta
16 from decimal import Decimal, ROUND_HALF_UP
17 from typing import Any
18
19
20 _DEFAULT_COLORS = (
21 "#4472C4",
22 "#ED7D31",
23 "#A5A5A5",
24 "#FFC000",
25 "#5B9BD5",
26 "#70AD47",
27 "#264478",
28 "#9E480E",
29 )
30
31
32 @dataclass(frozen=True)
33 class SeriesVisualStyle:
34 """Resolved paint used only by the normalized SVG fallback."""
35
36 fill: str | None
37 fill_opacity: float = 1.0
38 stroke: str | None = None
39 stroke_opacity: float = 1.0
40 stroke_width: float = 1.5
41 line_cap: str = "round"
42 marker_fill: str | None = None
43 marker_fill_opacity: float = 1.0
44 marker_stroke: str | None = None
45 marker_stroke_opacity: float = 1.0
46 marker_stroke_width: float = 1.0
47 marker_size: float = 5.0
48
49
50 @dataclass(frozen=True)
51 class _Rect:
52 x: float
53 y: float
54 w: float
55 h: float
56
57
58 def render_normalized_chart_svg(
59 payload: dict[str, Any],
60 styles: list[SeriesVisualStyle],
61 ) -> str | None:
62 """Render a readable normalized fallback for supported classic charts."""
63 chart_type = str(payload.get("type") or "")
64 if chart_type not in {
65 "area", "bar", "column", "doughnut", "line", "pie",
66 "scatter", "bubble",
67 }:
68 return None
69
70 try:
71 bounds = _Rect(
72 float(payload["x"]),
73 float(payload["y"]),
74 float(payload["width"]),
75 float(payload["height"]),
76 )
77 except (KeyError, TypeError, ValueError, OverflowError):
78 return None
79 if (
80 bounds.w <= 0
81 or bounds.h <= 0
82 or not all(math.isfinite(value) for value in vars(bounds).values())
83 ):
84 return None
85
86 legend_entries = _legend_entries(payload, styles)
87 content, legend_rect, title_parts = _outer_layout(
88 payload,
89 bounds,
90 len(legend_entries),
91 )
92 parts = [*title_parts]
93 if chart_type in {"pie", "doughnut"}:
94 parts.extend(_render_pie(payload, styles, content, chart_type == "doughnut"))
95 elif chart_type in {"scatter", "bubble"}:
96 parts.extend(_render_xy(payload, styles, content, chart_type))
97 else:
98 parts.extend(_render_category(payload, styles, content, chart_type))
99 if legend_rect is not None:
100 parts.extend(
101 _render_legend(
102 legend_entries,
103 legend_rect,
104 str(payload.get("legend_position") or "b"),
105 )
106 )
107 return "\n".join(part for part in parts if part)
108
109
110 def _outer_layout(
111 payload: dict[str, Any],
112 bounds: _Rect,
113 legend_count: int,
114 ) -> tuple[_Rect, _Rect | None, list[str]]:
115 pad = max(4.0, min(bounds.w, bounds.h) * 0.018)
116 content = _Rect(
117 bounds.x + pad,
118 bounds.y + pad,
119 max(1.0, bounds.w - 2 * pad),
120 max(1.0, bounds.h - 2 * pad),
121 )
122 title_parts: list[str] = []
123 title = _entry_text(payload.get("title"))
124 subtitle = _entry_text(payload.get("subtitle"))
125 if title:
126 title_size = _font_size(
127 _entry_value(payload.get("title"), "font_size"),
128 min(18.0, max(10.0, bounds.h * 0.055)),
129 bounds,
130 )
131 title_y = content.y + title_size
132 title_parts.append(
133 _text(
134 content.x + content.w / 2,
135 title_y,
136 title,
137 size=title_size,
138 anchor="middle",
139 weight="600",
140 fill=_entry_color(payload.get("title"), "#333333"),
141 )
142 )
143 used = title_size + max(4.0, title_size * 0.35)
144 content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used))
145 if subtitle:
146 subtitle_size = _font_size(
147 _entry_value(payload.get("subtitle"), "font_size"),
148 min(12.0, max(8.0, bounds.h * 0.035)),
149 bounds,
150 )
151 title_parts.append(
152 _text(
153 content.x + content.w / 2,
154 content.y + subtitle_size,
155 subtitle,
156 size=subtitle_size,
157 anchor="middle",
158 fill=_entry_color(payload.get("subtitle"), "#666666"),
159 )
160 )
161 used = subtitle_size + max(3.0, subtitle_size * 0.3)
162 content = _Rect(content.x, content.y + used, content.w, max(1.0, content.h - used))
163
164 if not payload.get("show_legend") or legend_count <= 0:
165 return content, None, title_parts
166 position = str(payload.get("legend_position") or "b").lower()
167 if position in {"l", "r", "left", "right"}:
168 legend_w = min(max(86.0, content.w * 0.24), max(1.0, content.w * 0.38))
169 if position in {"l", "left"}:
170 legend = _Rect(content.x, content.y, legend_w, content.h)
171 content = _Rect(
172 content.x + legend_w,
173 content.y,
174 max(1.0, content.w - legend_w),
175 content.h,
176 )
177 else:
178 legend = _Rect(content.x + content.w - legend_w, content.y, legend_w, content.h)
179 content = _Rect(content.x, content.y, max(1.0, content.w - legend_w), content.h)
180 return content, legend, title_parts
181
182 columns = max(1, min(4, int(content.w // 130) or 1))
183 rows = math.ceil(legend_count / columns)
184 legend_h = min(content.h * 0.32, max(20.0, rows * 17.0 + 4.0))
185 if position in {"t", "top"}:
186 legend = _Rect(content.x, content.y, content.w, legend_h)
187 content = _Rect(
188 content.x,
189 content.y + legend_h,
190 content.w,
191 max(1.0, content.h - legend_h),
192 )
193 else:
194 legend = _Rect(content.x, content.y + content.h - legend_h, content.w, legend_h)
195 content = _Rect(content.x, content.y, content.w, max(1.0, content.h - legend_h))
196 return content, legend, title_parts
197
198
199 def _render_category(
200 payload: dict[str, Any],
201 styles: list[SeriesVisualStyle],
202 content: _Rect,
203 chart_type: str,
204 ) -> list[str]:
205 categories = _category_labels(payload)
206 series = payload.get("series") or []
207 if categories is None or not categories or not series:
208 return []
209 styles = _complete_styles(styles, len(series))
210 axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {}
211 is_bar = chart_type == "bar"
212 label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021))
213 left = max(34.0, content.w * 0.075)
214 bottom = max(25.0, content.h * 0.105)
215 if is_bar:
216 left = min(content.w * 0.34, max(66.0, content.w * 0.18))
217 bottom = max(24.0, content.h * 0.085)
218 if axis_titles.get("value"):
219 left += 14.0 if not is_bar else 0.0
220 bottom += 14.0 if is_bar else 0.0
221 if axis_titles.get("category"):
222 bottom += 14.0 if not is_bar else 0.0
223 left += 14.0 if is_bar else 0.0
224 plot = _Rect(
225 content.x + left,
226 content.y + 5.0,
227 max(12.0, content.w - left - 10.0),
228 max(12.0, content.h - bottom - 8.0),
229 )
230
231 grouping = str(payload.get("grouping") or ("clustered" if chart_type in {"bar", "column"} else "standard"))
232 segments, percent = _category_segments(series, len(categories), grouping)
233 scale_values = [value for row in segments for pair in row for value in pair]
234 lo, hi, ticks = _nice_scale(scale_values, include_zero=True, percent=percent)
235 parts: list[str] = []
236 show_value_labels = payload.get("show_value_axis_labels") is not False
237
238 if is_bar:
239 parts.extend(_horizontal_grid_and_ticks(
240 plot,
241 ticks,
242 lo,
243 hi,
244 label_size,
245 percent,
246 show_labels=show_value_labels,
247 ))
248 parts.extend(_bar_category_labels(plot, categories, label_size))
249 else:
250 parts.extend(_vertical_grid_and_ticks(
251 plot,
252 ticks,
253 lo,
254 hi,
255 label_size,
256 percent,
257 show_labels=show_value_labels,
258 ))
259 parts.extend(_column_category_labels(
260 plot,
261 categories,
262 label_size,
263 point_aligned=chart_type in {"line", "area"},
264 ))
265 parts.extend(_axis_titles(plot, content, axis_titles, label_size, is_bar=is_bar))
266
267 if chart_type in {"bar", "column"}:
268 parts.extend(
269 _render_bars(
270 payload,
271 categories,
272 series,
273 styles,
274 segments,
275 plot,
276 lo,
277 hi,
278 grouping,
279 horizontal=is_bar,
280 label_size=label_size,
281 percent=percent,
282 )
283 )
284 else:
285 parts.extend(
286 _render_lines_or_areas(
287 payload,
288 categories,
289 series,
290 styles,
291 segments,
292 plot,
293 lo,
294 hi,
295 chart_type,
296 label_size,
297 percent,
298 )
299 )
300 return parts
301
302
303 def _category_labels(payload: dict[str, Any]) -> list[str] | None:
304 raw_categories = payload.get("categories") or []
305 axes = payload.get("axes")
306 category_axis = axes.get("category") if isinstance(axes, dict) else None
307 if not isinstance(category_axis, dict) or category_axis.get("kind") != "date":
308 return [str(value) for value in raw_categories]
309
310 number_format = str(category_axis.get("number_format") or "").lower()
311 normalized_format = re.sub(r'\\.|"[^"]*"|\[[^]]*]', "", number_format)
312 labels: list[str] = []
313 for raw_value in raw_categories:
314 try:
315 serial = float(raw_value)
316 except (TypeError, ValueError, OverflowError):
317 return None
318 if not math.isfinite(serial):
319 return None
320 date_parts = _excel_1900_date_parts(serial)
321 if date_parts is None:
322 return None
323 year, month, day = date_parts
324 if "yyyy-mm-dd" in normalized_format:
325 labels.append(f"{year:04d}-{month:02d}-{day:02d}")
326 elif "mm/dd/yyyy" in normalized_format:
327 labels.append(f"{month:02d}/{day:02d}/{year:04d}")
328 elif "m/d/yyyy" in normalized_format:
329 labels.append(f"{month}/{day}/{year:04d}")
330 else:
331 labels.append(f"{year:04d}-{month:02d}-{day:02d}")
332 return labels
333
334
335 def _excel_1900_date_parts(serial: float) -> tuple[int, int, int] | None:
336 day_number = math.floor(serial)
337 if day_number < 1:
338 return None
339 if day_number == 60:
340 return 1900, 2, 29
341 epoch = date(1899, 12, 31) if day_number < 60 else date(1899, 12, 30)
342 try:
343 value = epoch + timedelta(days=day_number)
344 except (OverflowError, ValueError):
345 return None
346 return value.year, value.month, value.day
347
348
349 def _category_segments(
350 series: list[dict[str, Any]],
351 count: int,
352 grouping: str,
353 ) -> tuple[list[list[tuple[float, float]]], bool]:
354 raw = [
355 [float(value) for value in item.get("values", [])[:count]]
356 for item in series
357 ]
358 percent = grouping == "percentStacked"
359 if percent:
360 positive = [sum(max(row[idx], 0.0) for row in raw) for idx in range(count)]
361 negative = [sum(abs(min(row[idx], 0.0)) for row in raw) for idx in range(count)]
362 for row in raw:
363 for idx, value in enumerate(row):
364 denominator = positive[idx] if value >= 0 else negative[idx]
365 row[idx] = value / denominator if denominator else 0.0
366 if grouping not in {"stacked", "percentStacked"}:
367 return [[(0.0, value) for value in row] for row in raw], percent
368
369 positive_base = [0.0] * count
370 negative_base = [0.0] * count
371 result: list[list[tuple[float, float]]] = []
372 for row in raw:
373 segments: list[tuple[float, float]] = []
374 for idx, value in enumerate(row):
375 if value >= 0:
376 start = positive_base[idx]
377 positive_base[idx] += value
378 end = positive_base[idx]
379 else:
380 start = negative_base[idx]
381 negative_base[idx] += value
382 end = negative_base[idx]
383 segments.append((start, end))
384 result.append(segments)
385 return result, percent
386
387
388 def _render_bars(
389 payload: dict[str, Any],
390 categories: list[str],
391 series: list[dict[str, Any]],
392 styles: list[SeriesVisualStyle],
393 segments: list[list[tuple[float, float]]],
394 plot: _Rect,
395 lo: float,
396 hi: float,
397 grouping: str,
398 *,
399 horizontal: bool,
400 label_size: float,
401 percent: bool,
402 ) -> list[str]:
403 parts: list[str] = []
404 stacked = grouping in {"stacked", "percentStacked"}
405 category_span = (plot.h if horizontal else plot.w) / max(len(categories), 1)
406 cluster = category_span * 0.72
407 bar_span = cluster if stacked else cluster / max(len(series), 1)
408 labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None
409 for series_index, (item, style, row) in enumerate(zip(series, styles, segments)):
410 fill = style.fill or "none"
411 stroke = style.stroke or "none"
412 for category_index, (start, end) in enumerate(row):
413 offset = 0.0 if stacked else series_index * bar_span
414 display_index = (
415 len(categories) - 1 - category_index
416 if horizontal else category_index
417 )
418 cluster_start = display_index * category_span + (category_span - cluster) / 2
419 if horizontal:
420 x1 = _map(end, lo, hi, plot.x, plot.x + plot.w)
421 x0 = _map(start, lo, hi, plot.x, plot.x + plot.w)
422 x = min(x0, x1)
423 y = plot.y + cluster_start + offset
424 w = max(abs(x1 - x0), 0.35)
425 h = max(bar_span * 0.9, 0.5)
426 else:
427 y1 = _map(end, lo, hi, plot.y + plot.h, plot.y)
428 y0 = _map(start, lo, hi, plot.y + plot.h, plot.y)
429 x = plot.x + cluster_start + offset
430 y = min(y0, y1)
431 w = max(bar_span * 0.9, 0.5)
432 h = max(abs(y1 - y0), 0.35)
433 parts.append(
434 f'<rect x="{_fmt(x)}" y="{_fmt(y)}" width="{_fmt(w)}" '
435 f'height="{_fmt(h)}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
436 f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
437 f'stroke-width="{_fmt(max(0.4, style.stroke_width))}"/>'
438 )
439 if labels:
440 value = float(item["values"][category_index])
441 normalized_percent = end - start if percent else None
442 label = _data_label(
443 labels,
444 str(item.get("name") or ""),
445 categories[category_index],
446 value,
447 percent_value=normalized_percent,
448 )
449 if label:
450 if horizontal:
451 tx = x1 + (4.0 if end >= start else -4.0)
452 ty = y + h / 2 + label_size * 0.32
453 anchor = "start" if end >= start else "end"
454 else:
455 tx = x + w / 2
456 ty = y1 - 4.0 if end >= start else y1 + label_size + 3.0
457 anchor = "middle"
458 parts.append(_text(tx, ty, label, size=max(6.0, label_size - 1), anchor=anchor))
459 return parts
460
461
462 def _render_lines_or_areas(
463 payload: dict[str, Any],
464 categories: list[str],
465 series: list[dict[str, Any]],
466 styles: list[SeriesVisualStyle],
467 segments: list[list[tuple[float, float]]],
468 plot: _Rect,
469 lo: float,
470 hi: float,
471 chart_type: str,
472 label_size: float,
473 percent: bool,
474 ) -> list[str]:
475 parts: list[str] = []
476 count = len(categories)
477 x_positions = [
478 _category_point_x(plot, idx, count)
479 for idx in range(count)
480 ]
481 labels = payload.get("data_labels") if isinstance(payload.get("data_labels"), dict) else None
482 show_markers = chart_type == "line" and payload.get("line_style") == "lineMarker"
483 for series_index, (item, style, row) in enumerate(zip(series, styles, segments)):
484 fill_color = style.fill or "none"
485 line_color = style.stroke
486 top = [
487 (x_positions[idx], _map(end, lo, hi, plot.y + plot.h, plot.y))
488 for idx, (_start, end) in enumerate(row)
489 ]
490 if chart_type == "area":
491 bottom = [
492 (x_positions[idx], _map(start, lo, hi, plot.y + plot.h, plot.y))
493 for idx, (start, _end) in enumerate(row)
494 ]
495 points = top + list(reversed(bottom))
496 parts.append(
497 f'<polygon points="{_points(points)}" fill="{fill_color}" '
498 f'fill-opacity="{_fmt(min(style.fill_opacity, 0.58))}" '
499 f'stroke="{line_color or "none"}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
500 f'stroke-width="{_fmt(max(0.7, style.stroke_width))}" '
501 'stroke-linejoin="round"/>'
502 )
503 elif line_color is not None:
504 parts.append(
505 f'<polyline points="{_points(top)}" fill="none" stroke="{line_color}" '
506 f'stroke-opacity="{_fmt(style.stroke_opacity)}" '
507 f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" '
508 f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>'
509 )
510 if show_markers:
511 for x, y in top:
512 radius = max(2.0, style.marker_size / 2)
513 parts.append(
514 f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" '
515 f'fill="{style.marker_fill or "none"}" '
516 f'fill-opacity="{_fmt(style.marker_fill_opacity)}" '
517 f'stroke="{style.marker_stroke or "none"}" '
518 f'stroke-opacity="{_fmt(style.marker_stroke_opacity)}" '
519 f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>'
520 )
521 if labels:
522 for idx, (x, y) in enumerate(top):
523 value = float(item["values"][idx])
524 start, end = row[idx]
525 normalized_percent = end - start if percent else None
526 label = _data_label(
527 labels,
528 str(item.get("name") or ""),
529 categories[idx],
530 value,
531 percent_value=normalized_percent,
532 )
533 if label:
534 parts.append(_text(x, y - 5.0, label, size=max(6.0, label_size - 1), anchor="middle"))
535 return parts
536
537
538 def _render_pie(
539 payload: dict[str, Any],
540 styles: list[SeriesVisualStyle],
541 content: _Rect,
542 doughnut: bool,
543 ) -> list[str]:
544 categories = [str(value) for value in payload.get("categories") or []]
545 series = payload.get("series") or []
546 if not categories or len(series) != 1:
547 return []
548 values = [abs(float(value)) for value in series[0].get("values") or []]
549 total = sum(values)
550 styles = _complete_styles(styles, len(categories))
551 label_size = max(6.0, min(10.0, content.h * 0.035, content.w * 0.018))
552 radius = max(5.0, min(content.w, content.h) * 0.34)
553 cx = content.x + content.w / 2
554 cy = content.y + content.h / 2
555 if total <= 0:
556 parts = [
557 f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius)}" '
558 'fill="none" stroke="#B8C0CC" stroke-width="1.2" stroke-dasharray="4 3"/>'
559 ]
560 if doughnut:
561 parts.append(
562 f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius * 0.75)}" '
563 'fill="none" stroke="#D5DAE1" stroke-width="1"/>'
564 )
565 parts.append(
566 _text(cx, cy + label_size * 0.32, "0", size=label_size, anchor="middle")
567 )
568 return parts
569 parts: list[str] = []
570 angle = -math.pi / 2
571 for idx, (category, value, style) in enumerate(zip(categories, values, styles)):
572 sweep = math.tau * value / total
573 end = angle + sweep
574 # Pie styles are completed before rendering, so ``None`` here means
575 # the source explicitly used ``noFill`` rather than an absent style.
576 fill = style.fill or "none"
577 stroke = style.stroke or "none"
578 if sweep >= math.tau - 1e-9:
579 if doughnut:
580 inner = radius * 0.75
581 path = _full_ring_path(cx, cy, radius, inner)
582 parts.append(
583 f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
584 f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}" '
585 'fill-rule="evenodd"/>'
586 )
587 else:
588 parts.append(
589 f'<circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="{_fmt(radius)}" '
590 f'fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
591 f'stroke="{stroke}" stroke-width="{_fmt(max(0.6, style.stroke_width))}"/>'
592 )
593 else:
594 path = _sector_path(cx, cy, radius, angle, end, radius * 0.75 if doughnut else 0.0)
595 parts.append(
596 f'<path d="{path}" fill="{fill}" fill-opacity="{_fmt(style.fill_opacity)}" '
597 f'stroke="{stroke}" stroke-opacity="{_fmt(style.stroke_opacity)}" '
598 f'stroke-width="{_fmt(max(0.6, style.stroke_width))}" fill-rule="evenodd"/>'
599 )
600 mid = angle + sweep / 2
601 label_radius = radius * (0.87 if doughnut else 0.68)
602 lx = cx + math.cos(mid) * label_radius
603 ly = cy + math.sin(mid) * label_radius + label_size * 0.32
604 percent = value / total
605 label = f"{category} {_format_percent(percent)}"
606 parts.append(
607 _text(
608 lx,
609 ly,
610 label,
611 size=label_size,
612 anchor="middle",
613 fill="#444444" if fill == "none" else _contrast_color(fill),
614 )
615 )
616 angle = end
617 return parts
618
619
620 def _render_xy(
621 payload: dict[str, Any],
622 styles: list[SeriesVisualStyle],
623 content: _Rect,
624 chart_type: str,
625 ) -> list[str]:
626 series = payload.get("series") or []
627 if not series:
628 return []
629 styles = _complete_styles(styles, len(series))
630 axis_titles = payload.get("axis_titles") if isinstance(payload.get("axis_titles"), dict) else {}
631 label_size = max(6.0, min(11.0, content.h * 0.037, content.w * 0.021))
632 left = max(40.0, content.w * 0.09) + (14.0 if axis_titles.get("y") else 0.0)
633 bottom = max(26.0, content.h * 0.1) + (14.0 if axis_titles.get("x") else 0.0)
634 plot = _Rect(
635 content.x + left,
636 content.y + 6.0,
637 max(12.0, content.w - left - 10.0),
638 max(12.0, content.h - bottom - 8.0),
639 )
640 x_values = [float(value) for item in series for value in item.get("x") or []]
641 y_values = [float(value) for item in series for value in item.get("y") or []]
642 x_lo, x_hi, x_ticks = _nice_scale(x_values, include_zero=False)
643 y_lo, y_hi, y_ticks = _nice_scale(y_values, include_zero=False)
644 axes = payload.get("axes")
645 x_axis = axes.get("x") if isinstance(axes, dict) else None
646 y_axis = axes.get("y") if isinstance(axes, dict) else None
647 x_major_gridlines = (
648 bool(x_axis.get("major_gridlines", False))
649 if isinstance(x_axis, dict)
650 else False
651 )
652 y_major_gridlines = (
653 bool(y_axis.get("major_gridlines", True))
654 if isinstance(y_axis, dict)
655 else True
656 )
657 parts = _xy_grid_and_ticks(
658 plot,
659 x_ticks,
660 y_ticks,
661 x_lo,
662 x_hi,
663 y_lo,
664 y_hi,
665 label_size,
666 show_x_gridlines=x_major_gridlines,
667 show_y_gridlines=y_major_gridlines,
668 )
669 parts.extend(_xy_axis_titles(plot, content, axis_titles, label_size))
670 nonnegative_bubble_sizes = [
671 float(value)
672 for item in series
673 for value in item.get("sizes") or []
674 if float(value) >= 0
675 ]
676 bubble_max = max(max(nonnegative_bubble_sizes or [0.0]), 1e-12)
677 scatter_style = str(payload.get("scatter_style") or "marker")
678 has_line = chart_type == "scatter" and scatter_style in {"line", "lineMarker", "smooth", "smoothMarker"}
679 has_marker = chart_type == "bubble" or scatter_style in {"marker", "lineMarker", "smoothMarker"}
680 for idx, (item, style) in enumerate(zip(series, styles)):
681 color = style.stroke or style.fill or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)]
682 points = [
683 (
684 _map(float(x), x_lo, x_hi, plot.x, plot.x + plot.w),
685 _map(float(y), y_lo, y_hi, plot.y + plot.h, plot.y),
686 )
687 for x, y in zip(item.get("x") or [], item.get("y") or [])
688 ]
689 if has_line and style.stroke is not None and len(points) >= 2:
690 parts.append(
691 f'<polyline points="{_points(points)}" fill="none" stroke="{color}" '
692 f'stroke-opacity="{_fmt(style.stroke_opacity)}" '
693 f'stroke-width="{_fmt(max(1.0, style.stroke_width))}" '
694 f'stroke-linecap="{style.line_cap}" stroke-linejoin="round"/>'
695 )
696 if has_marker:
697 sizes = item.get("sizes") or [style.marker_size * style.marker_size] * len(points)
698 for point_index, (x, y) in enumerate(points):
699 if chart_type == "bubble":
700 bubble_size = float(sizes[point_index])
701 if bubble_size < 0:
702 continue
703 radius = 3.0 + 13.0 * math.sqrt(bubble_size / bubble_max)
704 else:
705 radius = max(2.0, style.marker_size / 2)
706 fill = (
707 style.fill or "none"
708 if chart_type == "bubble"
709 else style.marker_fill or "none"
710 )
711 stroke = (
712 style.stroke or "none"
713 if chart_type == "bubble"
714 else style.marker_stroke or "none"
715 )
716 parts.append(
717 f'<circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="{_fmt(radius)}" '
718 f'fill="{fill}" fill-opacity="{_fmt(style.marker_fill_opacity if chart_type == "scatter" else style.fill_opacity)}" '
719 f'stroke="{stroke}" stroke-opacity="{_fmt(style.marker_stroke_opacity)}" '
720 f'stroke-width="{_fmt(max(0.6, style.marker_stroke_width))}"/>'
721 )
722 return parts
723
724
725 def _vertical_grid_and_ticks(
726 plot: _Rect,
727 ticks: list[float],
728 lo: float,
729 hi: float,
730 size: float,
731 percent: bool,
732 *,
733 show_labels: bool,
734 show_gridlines: bool = True,
735 ) -> list[str]:
736 parts: list[str] = []
737 for tick in ticks:
738 y = _map(tick, lo, hi, plot.y + plot.h, plot.y)
739 if show_gridlines:
740 parts.append(
741 f'<line x1="{_fmt(plot.x)}" y1="{_fmt(y)}" x2="{_fmt(plot.x + plot.w)}" '
742 f'y2="{_fmt(y)}" stroke="#D9D9D9" stroke-width="0.7"/>'
743 )
744 if show_labels:
745 label = _format_percent(tick) if percent else _format_number(tick)
746 parts.append(_text(plot.x - 5.0, y + size * 0.32, label, size=size, anchor="end", fill="#666666"))
747 parts.append(
748 f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y)}" x2="{_fmt(plot.x)}" '
749 f'y2="{_fmt(plot.y + plot.h)}" stroke="#808080" stroke-width="1"/>'
750 )
751 return parts
752
753
754 def _horizontal_grid_and_ticks(
755 plot: _Rect,
756 ticks: list[float],
757 lo: float,
758 hi: float,
759 size: float,
760 percent: bool,
761 *,
762 show_labels: bool,
763 ) -> list[str]:
764 parts: list[str] = []
765 for tick in ticks:
766 x = _map(tick, lo, hi, plot.x, plot.x + plot.w)
767 parts.append(
768 f'<line x1="{_fmt(x)}" y1="{_fmt(plot.y)}" x2="{_fmt(x)}" '
769 f'y2="{_fmt(plot.y + plot.h)}" stroke="#D9D9D9" stroke-width="0.7"/>'
770 )
771 if show_labels:
772 label = _format_percent(tick) if percent else _format_number(tick)
773 parts.append(_text(x, plot.y + plot.h + size + 4.0, label, size=size, anchor="middle", fill="#666666"))
774 parts.append(
775 f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y + plot.h)}" '
776 f'x2="{_fmt(plot.x + plot.w)}" y2="{_fmt(plot.y + plot.h)}" '
777 'stroke="#808080" stroke-width="1"/>'
778 )
779 return parts
780
781
782 def _column_category_labels(
783 plot: _Rect,
784 categories: list[str],
785 size: float,
786 *,
787 point_aligned: bool,
788 ) -> list[str]:
789 parts: list[str] = []
790 span = plot.w / max(len(categories), 1)
791 rotate = len(categories) > 8 or any(len(value) > 10 for value in categories)
792 for idx, category in enumerate(categories):
793 if point_aligned:
794 x = _category_point_x(plot, idx, len(categories))
795 else:
796 x = plot.x + span * (idx + 0.5)
797 y = plot.y + plot.h + size + 5.0
798 transform = f' transform="rotate(-35 {_fmt(x)} {_fmt(y)})"' if rotate else ""
799 anchor = "end" if rotate else "middle"
800 parts.append(
801 f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" '
802 f'font-family="Arial" font-size="{_fmt(size)}" fill="#555555"{transform}>'
803 f'{html.escape(category)}</text>'
804 )
805 return parts
806
807
808 def _category_point_x(plot: _Rect, index: int, count: int) -> float:
809 """Align line/area data points and their category labels."""
810 if count <= 1:
811 return plot.x + plot.w / 2
812 return plot.x + plot.w * index / (count - 1)
813
814
815 def _bar_category_labels(plot: _Rect, categories: list[str], size: float) -> list[str]:
816 span = plot.h / max(len(categories), 1)
817 return [
818 _text(
819 plot.x - 6.0,
820 plot.y + span * (len(categories) - idx - 0.5) + size * 0.32,
821 category,
822 size=size,
823 anchor="end",
824 fill="#555555",
825 )
826 for idx, category in enumerate(categories)
827 ]
828
829
830 def _xy_grid_and_ticks(
831 plot: _Rect,
832 x_ticks: list[float],
833 y_ticks: list[float],
834 x_lo: float,
835 x_hi: float,
836 y_lo: float,
837 y_hi: float,
838 size: float,
839 *,
840 show_x_gridlines: bool,
841 show_y_gridlines: bool,
842 ) -> list[str]:
843 parts = _vertical_grid_and_ticks(
844 plot,
845 y_ticks,
846 y_lo,
847 y_hi,
848 size,
849 False,
850 show_labels=True,
851 show_gridlines=show_y_gridlines,
852 )
853 for tick in x_ticks:
854 x = _map(tick, x_lo, x_hi, plot.x, plot.x + plot.w)
855 if show_x_gridlines:
856 parts.append(
857 f'<line x1="{_fmt(x)}" y1="{_fmt(plot.y)}" x2="{_fmt(x)}" '
858 f'y2="{_fmt(plot.y + plot.h)}" stroke="#D9D9D9" stroke-width="0.7"/>'
859 )
860 parts.append(_text(x, plot.y + plot.h + size + 4.0, _format_number(tick), size=size, anchor="middle", fill="#666666"))
861 parts.append(
862 f'<line x1="{_fmt(plot.x)}" y1="{_fmt(plot.y + plot.h)}" '
863 f'x2="{_fmt(plot.x + plot.w)}" y2="{_fmt(plot.y + plot.h)}" '
864 'stroke="#808080" stroke-width="1"/>'
865 )
866 return parts
867
868
869 def _axis_titles(
870 plot: _Rect,
871 content: _Rect,
872 titles: dict[str, Any],
873 size: float,
874 *,
875 is_bar: bool,
876 ) -> list[str]:
877 category = str(titles.get("category") or "")
878 value = str(titles.get("value") or "")
879 x_title = value if is_bar else category
880 y_title = category if is_bar else value
881 return _draw_axis_titles(plot, content, x_title, y_title, size)
882
883
884 def _xy_axis_titles(
885 plot: _Rect,
886 content: _Rect,
887 titles: dict[str, Any],
888 size: float,
889 ) -> list[str]:
890 return _draw_axis_titles(
891 plot,
892 content,
893 str(titles.get("x") or ""),
894 str(titles.get("y") or ""),
895 size,
896 )
897
898
899 def _draw_axis_titles(
900 plot: _Rect,
901 content: _Rect,
902 x_title: str,
903 y_title: str,
904 size: float,
905 ) -> list[str]:
906 parts: list[str] = []
907 if x_title:
908 parts.append(_text(plot.x + plot.w / 2, content.y + content.h - 2.0, x_title, size=size, anchor="middle", weight="600"))
909 if y_title:
910 x = content.x + size
911 y = plot.y + plot.h / 2
912 parts.append(
913 f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="middle" '
914 f'font-family="Arial" font-size="{_fmt(size)}" font-weight="600" '
915 f'fill="#444444" transform="rotate(-90 {_fmt(x)} {_fmt(y)})">'
916 f'{html.escape(y_title)}</text>'
917 )
918 return parts
919
920
921 def _legend_entries(
922 payload: dict[str, Any],
923 styles: list[SeriesVisualStyle],
924 ) -> list[tuple[str, str]]:
925 chart_type = str(payload.get("type") or "")
926 if chart_type in {"pie", "doughnut"}:
927 labels = [str(value) for value in payload.get("categories") or []]
928 else:
929 labels = [str(item.get("name") or f"Series {idx + 1}") for idx, item in enumerate(payload.get("series") or [])]
930 completed = _complete_styles(styles, len(labels))
931 return [
932 (
933 label,
934 style.fill or style.stroke or style.marker_fill or _DEFAULT_COLORS[idx % len(_DEFAULT_COLORS)],
935 )
936 for idx, (label, style) in enumerate(zip(labels, completed))
937 ]
938
939
940 def _render_legend(
941 entries: list[tuple[str, str]],
942 rect: _Rect,
943 position: str,
944 ) -> list[str]:
945 if not entries:
946 return []
947 size = max(6.0, min(10.0, rect.h * 0.18 if position.lower() in {"l", "r", "left", "right"} else 9.0))
948 parts: list[str] = []
949 if position.lower() in {"l", "r", "left", "right"}:
950 row_h = min(18.0, rect.h / max(len(entries), 1))
951 for idx, (label, color) in enumerate(entries):
952 y = rect.y + row_h * (idx + 0.5)
953 parts.append(
954 f'<rect x="{_fmt(rect.x + 5)}" y="{_fmt(y - 4)}" width="8" height="8" fill="{color}"/>'
955 )
956 parts.append(_text(rect.x + 18.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444"))
957 return parts
958
959 columns = max(1, min(4, int(rect.w // 130) or 1))
960 rows = math.ceil(len(entries) / columns)
961 row_h = rect.h / max(rows, 1)
962 col_w = rect.w / columns
963 for idx, (label, color) in enumerate(entries):
964 row = idx // columns
965 col = idx % columns
966 x = rect.x + col * col_w + 5.0
967 y = rect.y + row_h * (row + 0.5)
968 parts.append(
969 f'<rect x="{_fmt(x)}" y="{_fmt(y - 4)}" width="8" height="8" fill="{color}"/>'
970 )
971 parts.append(_text(x + 13.0, y + size * 0.32, label, size=size, anchor="start", fill="#444444"))
972 return parts
973
974
975 def _data_label(
976 config: dict[str, Any],
977 series: str,
978 category: str,
979 value: float,
980 *,
981 percent_value: float | None,
982 ) -> str:
983 fields: list[str] = []
984 if config.get("show_series"):
985 fields.append(series)
986 if config.get("show_category"):
987 fields.append(category)
988 if config.get("show_value"):
989 fields.append(_format_data_label_value(
990 value,
991 config.get("number_format"),
992 ))
993 if config.get("show_percent"):
994 if percent_value is None:
995 raise ValueError(
996 "normalized percent labels require percent-stacked segments"
997 )
998 number_format = config.get("number_format")
999 normalized_format = str(number_format or "").strip()
1000 percent_format = (
1001 "0%"
1002 if not normalized_format or normalized_format.lower() == "general"
1003 else normalized_format
1004 )
1005 fields.append(_format_data_label_value(
1006 percent_value,
1007 percent_format,
1008 ))
1009 return " · ".join(field for field in fields if field)
1010
1011
1012 def _format_data_label_value(value: float, number_format: Any) -> str:
1013 """Render the safe numeric subset used by normalized data labels.
1014
1015 Unknown Excel format programs raise so the caller falls back to the
1016 reconstruction-only route instead of displaying a materially wrong label.
1017 """
1018 code = str(number_format or "").strip()
1019 if not code or code.lower() == "general":
1020 return _format_number(value)
1021 match = re.fullmatch(
1022 r"(?P<prefix>[$¥¥€£]?)(?P<group>#,##)?0"
1023 r"(?P<decimals>\.0+)?(?P<percent>%?)",
1024 code,
1025 )
1026 if match is None:
1027 raise ValueError(f"unsupported normalized data-label format: {code!r}")
1028 scaled = value * 100.0 if match.group("percent") else value
1029 decimals = len((match.group("decimals") or "").lstrip("."))
1030 grouping = "," if match.group("group") else ""
1031 quantum = Decimal(1).scaleb(-decimals)
1032 rounded = Decimal(str(abs(scaled))).quantize(
1033 quantum,
1034 rounding=ROUND_HALF_UP,
1035 )
1036 absolute = format(rounded, f"{grouping}.{decimals}f")
1037 sign = "-" if scaled < 0 else ""
1038 return (
1039 f"{sign}{match.group('prefix')}{absolute}{match.group('percent')}"
1040 )
1041
1042
1043 def _nice_scale(
1044 values: list[float],
1045 *,
1046 include_zero: bool,
1047 percent: bool = False,
1048 ) -> tuple[float, float, list[float]]:
1049 finite = [value for value in values if math.isfinite(value)]
1050 if not finite:
1051 finite = [0.0, 1.0]
1052 lo = min(finite)
1053 hi = max(finite)
1054 if include_zero:
1055 lo = min(lo, 0.0)
1056 hi = max(hi, 0.0)
1057 if percent:
1058 lo = min(lo, 0.0)
1059 hi = max(hi, 1.0)
1060 if math.isclose(lo, hi):
1061 delta = max(abs(lo) * 0.1, 1.0)
1062 lo -= delta
1063 hi += delta
1064 raw_step = (hi - lo) / 5.0
1065 magnitude = 10 ** math.floor(math.log10(raw_step))
1066 normalized = raw_step / magnitude
1067 nice = 1.0 if normalized <= 1 else 2.0 if normalized <= 2 else 2.5 if normalized <= 2.5 else 5.0 if normalized <= 5 else 10.0
1068 step = nice * magnitude
1069 nice_lo = math.floor(lo / step) * step
1070 nice_hi = math.ceil(hi / step) * step
1071 count = max(1, int(round((nice_hi - nice_lo) / step)))
1072 ticks = [nice_lo + idx * step for idx in range(count + 1)]
1073 return nice_lo, nice_hi, ticks
1074
1075
1076 def _sector_path(
1077 cx: float,
1078 cy: float,
1079 outer: float,
1080 start: float,
1081 end: float,
1082 inner: float,
1083 ) -> str:
1084 x1 = cx + math.cos(start) * outer
1085 y1 = cy + math.sin(start) * outer
1086 x2 = cx + math.cos(end) * outer
1087 y2 = cy + math.sin(end) * outer
1088 large = 1 if end - start > math.pi else 0
1089 if inner <= 0:
1090 return (
1091 f"M {_fmt(cx)} {_fmt(cy)} L {_fmt(x1)} {_fmt(y1)} "
1092 f"A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} Z"
1093 )
1094 ix2 = cx + math.cos(end) * inner
1095 iy2 = cy + math.sin(end) * inner
1096 ix1 = cx + math.cos(start) * inner
1097 iy1 = cy + math.sin(start) * inner
1098 return (
1099 f"M {_fmt(x1)} {_fmt(y1)} A {_fmt(outer)} {_fmt(outer)} 0 {large} 1 {_fmt(x2)} {_fmt(y2)} "
1100 f"L {_fmt(ix2)} {_fmt(iy2)} A {_fmt(inner)} {_fmt(inner)} 0 {large} 0 {_fmt(ix1)} {_fmt(iy1)} Z"
1101 )
1102
1103
1104 def _full_ring_path(cx: float, cy: float, outer: float, inner: float) -> str:
1105 return (
1106 f"M {_fmt(cx + outer)} {_fmt(cy)} "
1107 f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx - outer)} {_fmt(cy)} "
1108 f"A {_fmt(outer)} {_fmt(outer)} 0 1 1 {_fmt(cx + outer)} {_fmt(cy)} Z "
1109 f"M {_fmt(cx + inner)} {_fmt(cy)} "
1110 f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx - inner)} {_fmt(cy)} "
1111 f"A {_fmt(inner)} {_fmt(inner)} 0 1 0 {_fmt(cx + inner)} {_fmt(cy)} Z"
1112 )
1113
1114
1115 def _complete_styles(
1116 styles: list[SeriesVisualStyle],
1117 count: int,
1118 ) -> list[SeriesVisualStyle]:
1119 result = list(styles[:count])
1120 while len(result) < count:
1121 color = _DEFAULT_COLORS[len(result) % len(_DEFAULT_COLORS)]
1122 result.append(
1123 SeriesVisualStyle(
1124 fill=color,
1125 stroke=color,
1126 marker_fill=color,
1127 marker_stroke=color,
1128 )
1129 )
1130 return result
1131
1132
1133 def _entry_text(value: Any) -> str:
1134 if isinstance(value, dict):
1135 return str(value.get("text") or "")
1136 return str(value or "")
1137
1138
1139 def _entry_value(value: Any, key: str) -> Any:
1140 return value.get(key) if isinstance(value, dict) else None
1141
1142
1143 def _entry_color(value: Any, default: str) -> str:
1144 color = _entry_value(value, "color")
1145 return str(color) if isinstance(color, str) and color.startswith("#") else default
1146
1147
1148 def _font_size(value: Any, default: float, bounds: _Rect) -> float:
1149 try:
1150 size = float(value)
1151 except (TypeError, ValueError, OverflowError):
1152 size = default
1153 return max(6.0, min(size, max(8.0, bounds.h * 0.18)))
1154
1155
1156 def _text(
1157 x: float,
1158 y: float,
1159 value: str,
1160 *,
1161 size: float,
1162 anchor: str = "start",
1163 fill: str = "#444444",
1164 weight: str | None = None,
1165 ) -> str:
1166 weight_attr = f' font-weight="{weight}"' if weight else ""
1167 return (
1168 f'<text x="{_fmt(x)}" y="{_fmt(y)}" text-anchor="{anchor}" '
1169 f'font-family="Arial" font-size="{_fmt(size)}" fill="{fill}"{weight_attr}>'
1170 f'{html.escape(str(value))}</text>'
1171 )
1172
1173
1174 def _map(value: float, lo: float, hi: float, out_lo: float, out_hi: float) -> float:
1175 if math.isclose(lo, hi):
1176 return (out_lo + out_hi) / 2
1177 return out_lo + (value - lo) * (out_hi - out_lo) / (hi - lo)
1178
1179
1180 def _points(points: list[tuple[float, float]]) -> str:
1181 return " ".join(f"{_fmt(x)},{_fmt(y)}" for x, y in points)
1182
1183
1184 def _format_number(value: float) -> str:
1185 if abs(value) >= 1_000_000:
1186 return f"{value / 1_000_000:.1f}M".replace(".0M", "M")
1187 if abs(value) >= 1_000:
1188 return f"{value / 1_000:.1f}K".replace(".0K", "K")
1189 if math.isclose(value, round(value)):
1190 return str(int(round(value)))
1191 return f"{value:.2f}".rstrip("0").rstrip(".")
1192
1193
1194 def _format_percent(value: float) -> str:
1195 percent = value * 100.0
1196 return f"{percent:.1f}%".replace(".0%", "%")
1197
1198
1199 def _contrast_color(color: str) -> str:
1200 token = color.lstrip("#")
1201 try:
1202 r, g, b = (int(token[idx:idx + 2], 16) for idx in (0, 2, 4))
1203 except (ValueError, TypeError):
1204 return "#FFFFFF"
1205 luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
1206 return "#222222" if luminance > 0.62 else "#FFFFFF"
1207
1208
1209 def _fmt(value: float) -> str:
1210 if not math.isfinite(float(value)):
1211 return "0"
1212 rounded = round(float(value), 3)
1213 if rounded == 0:
1214 return "0"
1215 if rounded.is_integer():
1216 return str(int(rounded))
1217 return f"{rounded:.3f}".rstrip("0").rstrip(".")
1218
1218 lines PYTHON