pptd-utils 2026.10.2.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1019 @@
1
+ """Rasterized (Pillow) charts: candlestick, waterfall, heatmap, treemap,
2
+ sunburst, sankey + generic cartesian fallback for combos the native path
3
+ rejects (stream stacks, scatter mixed with bar/line/area, pictograph bars).
4
+
5
+ All drawn at 2x supersample, emitted as a PNG picture; chart frame
6
+ fill/border/shadow are applied to the picture's spPr.
7
+ """
8
+ import math
9
+ from pathlib import Path
10
+
11
+ from PIL import Image, ImageDraw, ImageFont
12
+ from pptx.oxml.ns import qn
13
+ from pptx.util import Pt
14
+
15
+ from .charts_native import (_num, _vals, merge_series_defaults,
16
+ _theme_cycle)
17
+ from .style import resolve_color, color_el, fetch_url
18
+ from .xmlutil import sub
19
+
20
+ CACHE = Path.home() / ".cache" / "pptd_utils"
21
+ FONT_CANDIDATES = [
22
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
23
+ "/System/Library/Fonts/Helvetica.ttc",
24
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
25
+ "/usr/share/fonts/dejavu/DejaVuSans.ttf",
26
+ ]
27
+ _FONTS = {}
28
+
29
+
30
+ def _font(size):
31
+ key = int(size)
32
+ if key not in _FONTS:
33
+ for p in FONT_CANDIDATES:
34
+ try:
35
+ _FONTS[key] = ImageFont.truetype(p, key)
36
+ break
37
+ except Exception:
38
+ continue
39
+ else:
40
+ try:
41
+ _FONTS[key] = ImageFont.load_default()
42
+ except Exception:
43
+ _FONTS[key] = None
44
+ return _FONTS[key]
45
+
46
+
47
+ def _fmt_value(v, fmt):
48
+ if v is None:
49
+ return ""
50
+ if not fmt:
51
+ if isinstance(v, float) and v.is_integer():
52
+ return str(int(v))
53
+ return str(round(v, 2))
54
+ try:
55
+ if fmt == "0":
56
+ return str(int(round(v)))
57
+ if fmt == "0.0":
58
+ return f"{v:.1f}"
59
+ if fmt == "0%":
60
+ return f"{v * 100:.0f}%"
61
+ if fmt == "0.0%":
62
+ return f"{v * 100:.1f}%"
63
+ if fmt == "#,##0":
64
+ return f"{int(round(v)):,}"
65
+ if fmt == "0.0E+00":
66
+ return f"{v:.1E}"
67
+ except Exception:
68
+ pass
69
+ return str(v)
70
+
71
+
72
+ def _rgb(color, theme, alpha=1.0):
73
+ rgb, a = resolve_color(color, theme, alpha)
74
+ return (*rgb, int(a * 255))
75
+
76
+
77
+ def _lerp(c0, c1, t):
78
+ return tuple(int(round(a + (b - a) * t)) for a, b in zip(c0, c1))
79
+
80
+
81
+ def render_chart(slide, el, theme, root):
82
+ series = merge_series_defaults(el.get("series", []), el.get("seriesDefaults"))
83
+ x, y, w, h = el["bounds"]
84
+ S = 2
85
+ img = Image.new("RGBA", (int(w) * S, int(h) * S), (255, 255, 255, 0))
86
+ draw = ImageDraw.Draw(img)
87
+ ctx = {"img": img, "draw": draw, "S": S, "w": int(w), "h": int(h),
88
+ "theme": theme, "el": el}
89
+ _title(ctx, el)
90
+ _legend(ctx, merge_series_defaults(el.get("series", []), el.get("seriesDefaults")),
91
+ el, series[0].get("type"))
92
+ t0 = series[0].get("type")
93
+ if t0 == "candlestick":
94
+ _candlestick(ctx, series, el)
95
+ elif t0 == "waterfall":
96
+ _waterfall(ctx, series[0], el)
97
+ elif t0 == "heatmap":
98
+ _heatmap(ctx, series[0], el)
99
+ elif t0 == "treemap":
100
+ _treemap(ctx, series[0], el)
101
+ elif t0 == "sunburst":
102
+ _sunburst(ctx, series[0], el)
103
+ elif t0 == "sankey":
104
+ _sankey(ctx, series[0], el)
105
+ else:
106
+ _cartesian_raster(ctx, series, el)
107
+ png = CACHE / _chart_png_name(el, series)
108
+ CACHE.mkdir(parents=True, exist_ok=True)
109
+ img.resize((int(w), int(h)), Image.LANCZOS).save(png)
110
+ pic = slide.shapes.add_picture(str(png), Pt(x), Pt(y), Pt(w), Pt(h))
111
+ _frame_style(pic, el, theme)
112
+ return pic
113
+
114
+
115
+ def _chart_png_name(el, series):
116
+ import hashlib
117
+ payload = repr(sorted(el.items(), key=lambda kv: kv[0])) + repr(series)
118
+ return "chart_" + hashlib.sha1(payload.encode()).hexdigest()[:20] + ".png"
119
+
120
+
121
+ def _frame_style(pic, el, theme):
122
+ spPr = pic._element.spPr
123
+ if el.get("fill"):
124
+ f = el["fill"]
125
+ if f.get("type") == "solid":
126
+ sf = sub(spPr, "a:solidFill")
127
+ color_el(sf, f.get("color"), theme)
128
+ elif f.get("type") == "gradient":
129
+ gf = sub(spPr, "a:gradFill", {"rotWithShape": "1"})
130
+ gsLst = sub(gf, "a:gsLst")
131
+ for stop in f.get("stops", []):
132
+ gs = sub(gsLst, "a:gs", {"pos": str(int(stop.get("position", 0) * 100000))})
133
+ color_el(gs, stop.get("color"), theme)
134
+ if f.get("gradientType") == "radial":
135
+ path = sub(gf, "a:path", {"path": "circle"})
136
+ sub(path, "a:fillToRect", {"l": "50000", "t": "50000", "r": "50000", "b": "50000"})
137
+ else:
138
+ sub(gf, "a:lin", {"ang": str(int(f.get("angle", 0) * 60000))})
139
+ b = el.get("border")
140
+ if b:
141
+ ln = sub(spPr, "a:ln", {"w": str(int(Pt(b.get("width", 1))))})
142
+ sf = sub(ln, "a:solidFill")
143
+ color_el(sf, b.get("color", "#000000"), theme)
144
+ if b.get("style") in ("dash", "dot"):
145
+ sub(ln, "a:prstDash", {"val": "dash" if b.get("style") == "dash" else "sysDot"})
146
+ sh = el.get("shadow")
147
+ if sh:
148
+ eff = sub(spPr, "a:effectLst")
149
+ off = sh.get("offset") or [0, 0]
150
+ dist = int(Pt(math.hypot(off[0], off[1])))
151
+ dirn = int(math.degrees(math.atan2(off[1], off[0])) % 360 * 60000)
152
+ os_ = sub(eff, "a:outerShdw", {
153
+ "blurRad": str(int(Pt(sh.get("blur", 4)))), "dist": str(dist),
154
+ "dir": str(dirn), "rotWithShape": "0"})
155
+ color_el(os_, sh.get("color", "#000000"), theme)
156
+
157
+
158
+ # ------------------------------------------------------------- scaffolding
159
+
160
+ def _title(ctx, el):
161
+ title = el.get("title")
162
+ if not title:
163
+ return
164
+ text = title if isinstance(title, str) else title.get("text", "")
165
+ color = title.get("color") if isinstance(title, dict) else None
166
+ size = (title.get("fontSize") if isinstance(title, dict) else None) or 16
167
+ d = ctx["draw"]
168
+ S = ctx["S"]
169
+ f = _font(size * S)
170
+ bb = d.textbbox((0, 0), text, font=f)
171
+ tw = bb[2] - bb[0]
172
+ d.text(((ctx["w"] * S - tw) // 2, 8 * S), text, font=f,
173
+ fill=_rgb(color or "#333333", ctx["theme"]))
174
+ ctx["title_h"] = (bb[3] + 12) * S
175
+
176
+
177
+ def _legend(ctx, series, el, typ):
178
+ show = el.get("legend")
179
+ show = (True if show is None else bool(show) if isinstance(show, bool)
180
+ else show.get("show", True))
181
+ if not show:
182
+ return
183
+ names = [s.get("name") or _default_name(s) for s in series]
184
+ if typ in ("waterfall", "treemap", "sunburst", "sankey", "heatmap",
185
+ "candlestick"):
186
+ if typ == "candlestick" and len(series) > 1:
187
+ names = [s.get("name") or _default_name(s) for s in series[1:]]
188
+ else:
189
+ names = []
190
+ if not names:
191
+ return
192
+ d = ctx["draw"]
193
+ S = ctx["S"]
194
+ f = _font(10 * S)
195
+ theme = ctx["theme"]
196
+ cycle = _theme_cycle(theme)
197
+ entries = []
198
+ x = 10 * S
199
+ lh = 0
200
+ for i, name in enumerate(names):
201
+ color = _series_color(series[i + 1] if typ == "candlestick" else series[i],
202
+ i, cycle, theme, typ)
203
+ bb = d.textbbox((0, 0), name, font=f)
204
+ entries.append((x, name, color))
205
+ x += (bb[2] - bb[0]) + 14 * S + 18 * S
206
+ lh = max(lh, bb[3] - bb[1])
207
+ if x > ctx["w"] * S: # wrap not supported: shrink
208
+ pass
209
+ y = ctx["h"] * S - lh - 10 * S
210
+ for ex, name, color in entries:
211
+ d.rectangle([ex, y, ex + 10 * S, y + 10 * S], fill=color)
212
+ d.text((ex + 14 * S, y - 2 * S), name, font=f,
213
+ fill=_rgb("#333333", theme))
214
+ ctx["legend_h"] = lh + 14 * S
215
+
216
+
217
+ def _series_color(s, i, cycle, theme, typ):
218
+ if typ in ("waterfall", "sankey", "treemap", "sunburst", "heatmap"):
219
+ pass
220
+ c = s.get("fill") or s.get("lineColor") or s.get("areaColor") or cycle[i % len(cycle)]
221
+ if isinstance(c, dict):
222
+ stops = c.get("stops", [])
223
+ c = stops[0].get("color") if stops else cycle[i % len(cycle)]
224
+ return _rgb(c, theme)
225
+
226
+
227
+ def _default_name(s):
228
+ enc = s.get("encode") or {}
229
+ return str(enc.get("y") or enc.get("value") or enc.get("flow") or
230
+ enc.get("close") or "series")
231
+
232
+
233
+ def _plot_rect(ctx, el):
234
+ S = ctx["S"]
235
+ left = 46 * S
236
+ right = ctx["w"] * S - 10 * S
237
+ top = ctx.get("title_h", 0) + 8 * S
238
+ bottom = ctx["h"] * S - ctx.get("legend_h", 0) - 30 * S
239
+ return (left, top, right, bottom)
240
+
241
+
242
+ def _nice_ticks(vmin, vmax, n=5):
243
+ if vmax <= vmin:
244
+ vmax = vmin + 1
245
+ span = vmax - vmin
246
+ step = 10 ** math.floor(math.log10(span / n))
247
+ for m in (1, 2, 2.5, 5, 10):
248
+ if span / (m * step) <= n:
249
+ step *= m
250
+ break
251
+ start = math.floor(vmin / step) * step
252
+ ticks = []
253
+ v = start
254
+ while v <= vmax + 1e-9:
255
+ ticks.append(round(v, 10))
256
+ v += step
257
+ return ticks
258
+
259
+
260
+ def _axis_frame(ctx, rect, ticks, cat_labels, el, horizontal=False,
261
+ tick_fmt=None, grid=True):
262
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
263
+ l, t, r, b = rect
264
+ d.rectangle([l, t, r, b], outline=_rgb("#666666", theme), width=S)
265
+ f = _font(9 * S)
266
+ for v in ticks:
267
+ yy = b - (v - ticks[0]) / (ticks[-1] - ticks[0]) * (b - t)
268
+ if grid:
269
+ d.line([l, yy, r, yy], fill=_rgb("#e5e7eb", theme), width=S)
270
+ d.text((l - 6 * S, yy - 5 * S), _fmt_value(v, tick_fmt),
271
+ font=f, fill=_rgb("#333333", theme), anchor="ra")
272
+ if cat_labels:
273
+ n = len(cat_labels)
274
+ step = (r - l) / n
275
+ for i, lab in enumerate(cat_labels):
276
+ cx = l + step * (i + 0.5)
277
+ d.text((cx, b + 4 * S), str(lab), font=f,
278
+ fill=_rgb("#333333", theme), anchor="ma")
279
+ xaxis = el.get("xAxis")
280
+ if isinstance(xaxis, dict) and xaxis.get("title"):
281
+ tf = _font(10 * S)
282
+ d.text(((l + r) // 2, b + 18 * S), str(xaxis["title"]),
283
+ font=tf, fill=_rgb("#333333", theme), anchor="ma")
284
+ yaxis = el.get("yAxis")
285
+ if isinstance(yaxis, dict) and yaxis.get("title"):
286
+ tf = _font(10 * S)
287
+ bb = d.textbbox((0, 0), str(yaxis["title"]), font=tf)
288
+ d.text((l - (bb[2] - bb[0]) - 8 * S, (t + b) // 2), str(yaxis["title"]),
289
+ font=tf, fill=_rgb("#333333", theme))
290
+
291
+
292
+ # ------------------------------------------------------------- candlestick
293
+
294
+ def _candlestick(ctx, series, el):
295
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
296
+ cs = series[0]
297
+ enc = cs["encode"]
298
+ cols, rows, colidx = _cols(el)
299
+ cats = _vals(rows, colidx[enc["x"]])
300
+ highs = [_num(v) for v in _vals(rows, colidx[enc["high"]])]
301
+ lows = [_num(v) for v in _vals(rows, colidx[enc["low"]])]
302
+ closes = [_num(v) for v in _vals(rows, colidx[enc["close"]])]
303
+ opens = ([_num(v) for v in _vals(rows, colidx[enc["open"]])]
304
+ if enc.get("open") else None)
305
+ rect = _plot_rect(ctx, el)
306
+ l, t, r, b = rect
307
+ vmin = min(v for v in lows if v is not None)
308
+ vmax = max(v for v in highs if v is not None)
309
+ ticks = _nice_ticks(vmin, vmax)
310
+ def yv(v):
311
+ return b - (v - ticks[0]) / (ticks[-1] - ticks[0]) * (b - t)
312
+ _axis_frame(ctx, rect, ticks, cats, el)
313
+ up = (cs.get("upBars") or {}).get("fill", "#ef4444")
314
+ down = (cs.get("downBars") or {}).get("fill", "#22c55e")
315
+ wick = (cs.get("wickStyle") or {})
316
+ wick_c = wick.get("color", "#6b7280")
317
+ wick_w = wick.get("width", 1)
318
+ n = len(cats)
319
+ slot = (r - l) / n
320
+ bw = min(slot * 0.6, 18 * S)
321
+ for i in range(n):
322
+ cx = l + slot * (i + 0.5)
323
+ if highs[i] is None or closes[i] is None:
324
+ continue
325
+ d.line([cx, yv(highs[i]), cx, yv(lows[i])],
326
+ fill=_rgb(wick_c, theme), width=max(1, int(wick_w * S)))
327
+ if opens is not None and opens[i] is not None:
328
+ color = up if closes[i] >= opens[i] else down
329
+ y0, y1 = yv(max(opens[i], closes[i])), yv(min(opens[i], closes[i]))
330
+ d.rectangle([cx - bw / 2, y0, cx + bw / 2, y1],
331
+ fill=_rgb(color, theme), outline=_rgb(wick_c, theme))
332
+ else:
333
+ d.ellipse([cx - 3 * S, yv(closes[i]) - 3 * S,
334
+ cx + 3 * S, yv(closes[i]) + 3 * S],
335
+ fill=_rgb(down, theme))
336
+ # overlay line series
337
+ for s in series[1:]:
338
+ vals = [_num(v) for v in _vals(rows, colidx[s["encode"]["y"]])]
339
+ pts = [(l + slot * (i + 0.5), yv(v)) for i, v in enumerate(vals)
340
+ if v is not None and v <= ticks[-1]]
341
+ if len(pts) >= 2:
342
+ color = s.get("lineColor") or "#f59e0b"
343
+ d.line(pts, fill=_rgb(color, theme), width=int(s.get("width", 2) * S),
344
+ joint="curve")
345
+
346
+
347
+ def _cols(el):
348
+ data = el.get("data") or {}
349
+ cols = [str(c) for c in data.get("cols", [])]
350
+ rows = data.get("rows", [])
351
+ return cols, rows, {c: i for i, c in enumerate(cols)}
352
+
353
+
354
+ # ------------------------------------------------------------- waterfall
355
+
356
+ def _waterfall(ctx, s, el):
357
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
358
+ enc = s["encode"]
359
+ cols, rows, colidx = _cols(el)
360
+ cats = _vals(rows, colidx[enc["x"]])
361
+ vals = [_num(v) for v in _vals(rows, colidx[enc["y"]])]
362
+ istotal = ([bool(v) for v in _vals(rows, colidx[enc["isTotal"]])]
363
+ if enc.get("isTotal") else [False] * len(vals))
364
+ rect = _plot_rect(ctx, el)
365
+ l, t, r, b = rect
366
+ cum = 0.0
367
+ bars = [] # (label, y0, y1, class)
368
+ for cat, v, tot in zip(cats, vals, istotal):
369
+ if v is None:
370
+ bars.append((cat, None, None, "total"))
371
+ continue
372
+ if tot:
373
+ bars.append((cat, 0.0, v, "total"))
374
+ cum = v
375
+ else:
376
+ bars.append((cat, cum, cum + v, "inc" if v > 0 else "dec"))
377
+ cum += v
378
+ vmax = max(max(abs(y0), abs(y1)) for _, y0, y1, _ in bars if y0 is not None)
379
+ vmin = min(min(y0, y1) for _, y0, y1, _ in bars if y0 is not None)
380
+ ticks = _nice_ticks(vmin, vmax)
381
+ def yv(v):
382
+ return b - (v - ticks[0]) / (ticks[-1] - ticks[0]) * (b - t)
383
+ _axis_frame(ctx, rect, ticks, cats, el)
384
+ colors = {"total": (s.get("totalBars") or {}).get("fill", "#3b82f6"),
385
+ "inc": (s.get("increaseBars") or {}).get("fill", "#22c55e"),
386
+ "dec": (s.get("decreaseBars") or {}).get("fill", "#ef4444")}
387
+ n = len(bars)
388
+ slot = (r - l) / n
389
+ bw = min(slot * 0.6, 24 * S)
390
+ dl = _dl_config(s, el)
391
+ f = _font(9 * S)
392
+ for i, (cat, y0, y1, cls) in enumerate(bars):
393
+ if y0 is None:
394
+ continue
395
+ cx = l + slot * (i + 0.5)
396
+ ya, yb = yv(max(y0, y1)), yv(min(y0, y1))
397
+ if abs(ya - yb) < S:
398
+ yb = ya + S
399
+ d.rectangle([cx - bw / 2, ya, cx + bw / 2, yb],
400
+ fill=_rgb(colors[cls], theme))
401
+ if dl.get("show"):
402
+ txt = _fmt_value(y1 if cls == "total" else (y1 - y0),
403
+ dl.get("numberFormat"))
404
+ bb = d.textbbox((0, 0), txt, font=f)
405
+ d.text((cx - (bb[2] - bb[0]) / 2, ya - 12 * S), txt, font=f,
406
+ fill=_rgb("#333333", theme))
407
+
408
+
409
+ def _dl_config(s, el):
410
+ merged = {}
411
+ if isinstance(el.get("dataLabels"), dict):
412
+ merged.update(el["dataLabels"])
413
+ if isinstance(s.get("dataLabels"), dict):
414
+ merged.update(s["dataLabels"])
415
+ return merged
416
+
417
+
418
+ # ------------------------------------------------------------- heatmap
419
+
420
+ def _heatmap(ctx, s, el):
421
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
422
+ enc = s["encode"]
423
+ cols, rows, colidx = _cols(el)
424
+ xs, ys, vs = [], [], []
425
+ for row in rows:
426
+ xv, yv_, vv = (row[colidx[enc["x"]]] if colidx[enc["x"]] < len(row) else None,
427
+ row[colidx[enc["y"]]] if colidx[enc["y"]] < len(row) else None,
428
+ row[colidx[enc["value"]]] if colidx[enc["value"]] < len(row) else None)
429
+ xs.append(str(xv))
430
+ ys.append(str(yv_))
431
+ vs.append(_num(vv))
432
+ xcats = list(dict.fromkeys(xs))
433
+ ycats = list(dict.fromkeys(ys))
434
+ scheme = s.get("colorScheme") or ["#ffffff", "#3b82f6"]
435
+ scale = s.get("colorScale") or {}
436
+ stype = scale.get("type", "linear")
437
+ vals = [v for v in vs if v is not None]
438
+ domain = scale.get("domain")
439
+ if domain:
440
+ lo, hi = domain
441
+ elif stype == "diverging":
442
+ m = max(abs(v) for v in vals) if vals else 1
443
+ lo, hi = -m, m
444
+ else:
445
+ lo, hi = (min(vals), max(vals)) if vals else (0, 1)
446
+ cs = [_rgb(c, theme)[:3] for c in scheme]
447
+ rect = _plot_rect(ctx, el)
448
+ l, t, r, b = rect
449
+ f = _font(9 * S)
450
+ # cell grid
451
+ cw = (r - l) / len(xcats)
452
+ chh = (b - t) / len(ycats)
453
+ for xi, xv in enumerate(xcats):
454
+ for yi, yv_ in enumerate(ycats):
455
+ v = None
456
+ for k in range(len(vs)):
457
+ if xs[k] == xv and ys[k] == yv_:
458
+ v = vs[k]
459
+ break
460
+ if v is None:
461
+ continue
462
+ if hi > lo:
463
+ tt = (v - lo) / (hi - lo)
464
+ else:
465
+ tt = 0.5
466
+ tt = max(0.0, min(1.0, tt))
467
+ if stype == "diverging" and len(cs) >= 3:
468
+ if tt < 0.5:
469
+ color = _lerp(cs[0], cs[1], tt * 2)
470
+ else:
471
+ color = _lerp(cs[1], cs[2], (tt - 0.5) * 2)
472
+ else:
473
+ if len(cs) >= 2:
474
+ color = _lerp(cs[0], cs[-1], tt)
475
+ else:
476
+ color = cs[0]
477
+ x0 = l + xi * cw
478
+ y0 = t + yi * chh
479
+ d.rectangle([x0, y0, x0 + cw, y0 + chh], fill=(*color, 255))
480
+ dl = _dl_config(s, el)
481
+ if dl.get("show"):
482
+ txt = _fmt_value(v, dl.get("numberFormat"))
483
+ bb = d.textbbox((0, 0), txt, font=f)
484
+ d.text((x0 + cw / 2 - (bb[2] - bb[0]) / 2,
485
+ y0 + chh / 2 - (bb[3] - bb[1]) / 2), txt, font=f,
486
+ fill=_rgb("#333333", theme))
487
+ d.rectangle([l, t, r, b], outline=_rgb("#666666", theme), width=S)
488
+ for xi, xv in enumerate(xcats):
489
+ d.text((l + xi * cw + cw / 2, b + 4 * S), xv, font=f,
490
+ fill=_rgb("#333333", theme), anchor="ma")
491
+ for yi, yv_ in enumerate(ycats):
492
+ d.text((l - 4 * S, t + yi * chh + chh / 2), yv_, font=f,
493
+ fill=_rgb("#333333", theme), anchor="rm")
494
+ # colorbar
495
+ cb = s.get("colorbar", True)
496
+ if cb is not False:
497
+ cbl = r + 14 * S
498
+ cbt, cbb = t, b - 20 * S
499
+ for yy in range(int(cbt), int(cbb)):
500
+ tt = (yy - cbt) / max(1, cbb - cbt)
501
+ if stype == "diverging" and len(cs) >= 3:
502
+ color = (_lerp(cs[2], cs[1], tt * 2) if tt < 0.5
503
+ else _lerp(cs[1], cs[0], (tt - 0.5) * 2))
504
+ else:
505
+ color = _lerp(cs[-1], cs[0], tt)
506
+ d.line([cbl, yy, cbl + 10 * S, yy], fill=(*color, 255))
507
+ d.text((cbl, cbb + 2 * S), _fmt_value(lo, None), font=f,
508
+ fill=_rgb("#333333", theme))
509
+ d.text((cbl, cbt - 12 * S), _fmt_value(hi, None), font=f,
510
+ fill=_rgb("#333333", theme))
511
+
512
+
513
+ # ------------------------------------------------------------- treemap
514
+
515
+ def _squarify(sizes, x, y, w, h):
516
+ """Classic squarify; sizes: weights (any positive numbers). -> [rect4]*"""
517
+ if not sizes or w <= 0 or h <= 0:
518
+ return []
519
+ total = sum(sizes)
520
+ if total <= 0:
521
+ return []
522
+ rem = [s / total for s in sizes]
523
+ cx, cy, cw, ch = x, y, w, h
524
+ rects = []
525
+ while rem:
526
+ row = []
527
+ row_worst = None
528
+ length = min(cw, ch)
529
+ while rem:
530
+ cand = row + [rem[0]]
531
+ s = sum(cand)
532
+ worst_r = max(cand) * length ** 2 / s ** 2 if s else 1e9
533
+ if row and row_worst is not None and worst_r > row_worst:
534
+ break
535
+ row_worst = worst_r
536
+ row.append(rem.pop(0))
537
+ s = sum(row)
538
+ if s <= 0:
539
+ break
540
+ if cw >= ch: # vertical strip of width s*cw
541
+ rw = s * cw
542
+ ry = cy
543
+ for f in row:
544
+ rr = f / s * ch
545
+ rects.append((cx, ry, rw, rr))
546
+ ry += rr
547
+ cx += rw
548
+ cw -= rw
549
+ else: # horizontal strip of height s*ch
550
+ rh = s * ch
551
+ rx = cx
552
+ for f in row:
553
+ rr = f / s * cw
554
+ rects.append((rx, cy, rr, rh))
555
+ rx += rr
556
+ cy += rh
557
+ ch -= rh
558
+ return rects
559
+
560
+
561
+ def _hsl_shift(rgb, dl):
562
+ """Decrease lightness by dl (fraction) in HSL space."""
563
+ r, g, b = (v / 255 for v in rgb)
564
+ mx, mn = max(r, g, b), min(r, g, b)
565
+ l = (mx + mn) / 2
566
+ l = max(0.0, l - dl)
567
+ h = s = 0.0
568
+ if mx != mn:
569
+ d = mx - mn
570
+ s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
571
+ if mx == r:
572
+ h = ((g - b) / d + (6 if g < b else 0)) / 6
573
+ elif mx == g:
574
+ h = ((b - r) / d + 2) / 6
575
+ else:
576
+ h = ((r - g) / d + 4) / 6
577
+ def f(n):
578
+ k = (n + h * 12) % 12
579
+ a = s * min(l, 1 - l)
580
+ return int(round(255 * (l - a * max(-1, min(k - 3, 9 - k, 1)))))
581
+ return (f(0), f(8), f(4))
582
+
583
+
584
+ def _treemap(ctx, s, el):
585
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
586
+ enc = s["encode"]
587
+ cols, rows, colidx = _cols(el)
588
+ nodes = {}
589
+ for row in rows:
590
+ name = str(row[colidx[enc["category"]]])
591
+ parent = (row[colidx[enc["parent"]]] if enc.get("parent") and
592
+ colidx[enc["parent"]] < len(row) else None)
593
+ val = _num(row[colidx[enc["value"]]])
594
+ nodes.setdefault(name, {"name": name, "parent": None, "value": 0,
595
+ "children": []})
596
+ nodes[name]["value"] += val or 0
597
+ if parent is not None:
598
+ nodes[name]["parent"] = str(parent)
599
+ for n in list(nodes.values()):
600
+ if n["parent"] and n["parent"] in nodes:
601
+ nodes[n["parent"]]["children"].append(n)
602
+ roots = [n for n in nodes.values() if not n["parent"] or n["parent"] not in nodes]
603
+ total = sum(n["value"] for n in roots) or 1
604
+ rect = _plot_rect(ctx, el)
605
+ l, t, r, b = rect
606
+ fills = s.get("fill")
607
+ if isinstance(fills, (str, dict)):
608
+ fills = [fills]
609
+ elif fills is None:
610
+ fills = []
611
+ cycle = _theme_cycle(theme)
612
+ dl = _dl_config(s, el)
613
+ content = dl.get("content", "category") if dl.get("show") else None
614
+ f = _font(10 * S)
615
+ fs = _font(8 * S)
616
+ d.rectangle([l, t, r, b], fill=(255, 255, 255, 0))
617
+
618
+ def base_color(i):
619
+ if fills and i < len(fills):
620
+ c = fills[i % len(fills)]
621
+ elif fills:
622
+ c = fills[i % len(fills)]
623
+ else:
624
+ c = cycle[i % len(cycle)]
625
+ if isinstance(c, dict):
626
+ stops = c.get("stops", [])
627
+ c = stops[0].get("color") if stops else cycle[i % len(cycle)]
628
+ return _rgb(c, theme)[:3]
629
+
630
+ def draw_node(n, rect_, level, color, ri):
631
+ x, y, w_, h_ = rect_
632
+ if w_ <= 0 or h_ <= 0:
633
+ return
634
+ d.rectangle([x, y, x + w_, y + h_], fill=(*color, 235),
635
+ outline=(255, 255, 255, 255), width=2 * S)
636
+ label = n["name"]
637
+ if content == "value":
638
+ label = _fmt_value(n["value"], dl.get("numberFormat"))
639
+ elif content == "category" and dl.get("show"):
640
+ label = n["name"]
641
+ if w_ > 30 * S and h_ > 14 * S:
642
+ d.text((x + 4 * S, y + 3 * S), label, font=f,
643
+ fill=_contrast(color))
644
+ kids = sorted(n["children"], key=lambda c: -c["value"])
645
+ if not kids:
646
+ return
647
+ child_total = sum(k["value"] for k in kids) or 1
648
+ sizes = [k["value"] / child_total for k in kids]
649
+ pad = 2 * S
650
+ inner = (x + pad, y + pad, w_ - 2 * pad, h_ - 2 * pad)
651
+ rects = _squarify(sizes, *inner)
652
+ for k, (rx, ry, rw, rh) in zip(kids, rects):
653
+ draw_node(k, (rx, ry, rw, rh), level + 1,
654
+ _hsl_shift(color, 0.10), ri)
655
+
656
+ lv = s.get("levels")
657
+ for ri, root in enumerate(sorted(roots, key=lambda n: -n["value"])):
658
+ frac = root["value"] / total
659
+ rects = _squarify([frac], l, t, r - l, b - t)
660
+ if not rects:
661
+ continue
662
+ rx, ry, rw, rh = rects[0]
663
+ draw_node(root, (rx, ry, rw, rh), 0, base_color(ri), ri)
664
+
665
+
666
+ def _contrast(rgb):
667
+ lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
668
+ return (255, 255, 255, 255) if lum < 128 else (17, 24, 39, 255)
669
+
670
+
671
+ # ------------------------------------------------------------- sunburst
672
+
673
+ def _sunburst(ctx, s, el):
674
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
675
+ enc = s["encode"]
676
+ cols, rows, colidx = _cols(el)
677
+ nodes = {}
678
+ for row in rows:
679
+ name = str(row[colidx[enc["category"]]])
680
+ parent = (str(row[colidx[enc["parent"]]]) if enc.get("parent") and
681
+ colidx[enc.get("parent")] < len(row) and
682
+ row[colidx[enc["parent"]]] is not None else None)
683
+ val = _num(row[colidx[enc["value"]]]) or 0
684
+ nodes.setdefault(name, {"name": name, "parent": None, "value": 0,
685
+ "children": []})
686
+ nodes[name]["value"] += val
687
+ nodes[name]["parent"] = parent
688
+ for n in list(nodes.values()):
689
+ if n["parent"] and n["parent"] in nodes:
690
+ nodes[n["parent"]]["children"].append(n)
691
+ roots = [n for n in nodes.values() if not n["parent"] or n["parent"] not in nodes]
692
+ total = sum(n["value"] for n in roots) or 1
693
+ rect = _plot_rect(ctx, el)
694
+ l, t, r, b = rect
695
+ cx, cy = (l + r) / 2, (t + b) / 2
696
+ max_r = min(r - l, b - t) / 2 - 4 * S
697
+ ring = max_r / max(1, _depth(roots) or 1)
698
+ fills = s.get("fill")
699
+ if isinstance(fills, (str, dict)):
700
+ fills = [fills]
701
+ elif fills is None:
702
+ fills = []
703
+ cycle = _theme_cycle(theme)
704
+ dl = _dl_config(s, el)
705
+ f = _font(9 * S)
706
+
707
+ def base_color(i):
708
+ c = (fills[i % len(fills)] if fills else cycle[i % len(cycle)])
709
+ if isinstance(c, dict):
710
+ stops = c.get("stops", [])
711
+ c = stops[0].get("color") if stops else cycle[i % len(cycle)]
712
+ return _rgb(c, theme)[:3]
713
+
714
+ def draw_ring(node, a0, a1, depth, color):
715
+ if a1 - a0 < 0.005:
716
+ return
717
+ r0, r1 = depth * ring + 2 * S, (depth + 1) * ring - 2 * S
718
+ d.pieslice([cx - r1, cy - r1, cx + r1, cy + r1],
719
+ math.degrees(a0 - math.pi / 2),
720
+ math.degrees(a1 - math.pi / 2),
721
+ fill=(*color, 235), outline=(255, 255, 255, 255))
722
+ if dl.get("show") and (a1 - a0) > 0.15 and r1 - r0 > 9 * S:
723
+ am = (a0 + a1) / 2
724
+ rm = (r0 + r1) / 2
725
+ txt = node["name"]
726
+ content = dl.get("content", "category")
727
+ if content == "value":
728
+ txt = _fmt_value(node["value"], dl.get("numberFormat"))
729
+ bb = d.textbbox((0, 0), txt, font=f)
730
+ if (a1 - a0) * rm > (bb[2] - bb[0]) + 6 * S:
731
+ d.text((cx + math.cos(am) * rm, cy + math.sin(am) * rm), txt,
732
+ font=f, fill=_contrast(color), anchor="mm")
733
+ kids = sorted(node["children"], key=lambda c: -c["value"])
734
+ vt = node["value"] or 1
735
+ a = a0
736
+ for k in kids:
737
+ span = (a1 - a0) * (k["value"] / vt)
738
+ draw_ring(k, a, a + span, depth + 1, _hsl_shift(color, 0.10))
739
+ a += span
740
+
741
+ a = 0.0
742
+ for i, rootn in enumerate(sorted(roots, key=lambda n: -n["value"])):
743
+ span = 2 * math.pi * (rootn["value"] / total)
744
+ draw_ring(rootn, a, a + span, 0, base_color(i))
745
+ a += span
746
+
747
+
748
+ def _depth(nodes):
749
+ if not nodes:
750
+ return 0
751
+ return 1 + max(_depth(n["children"]) for n in nodes)
752
+
753
+
754
+ # ------------------------------------------------------------- sankey
755
+
756
+ def _sankey(ctx, s, el):
757
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
758
+ enc = s["encode"]
759
+ cols, rows, colidx = _cols(el)
760
+ flows = []
761
+ nodes = {}
762
+ for row in rows:
763
+ src = str(row[colidx[enc["source"]]])
764
+ dst = str(row[colidx[enc["target"]]])
765
+ val = _num(row[colidx[enc["flow"]]]) or 0
766
+ flows.append((src, dst, val))
767
+ nodes.setdefault(src, {"name": src, "out": 0, "in": 0})
768
+ nodes.setdefault(dst, {"name": dst, "out": 0, "in": 0})
769
+ nodes[src]["out"] += val
770
+ nodes[dst]["in"] += val
771
+ # layer via longest path
772
+ layer = {n: 0 for n in nodes}
773
+ changed = True
774
+ while changed:
775
+ changed = False
776
+ for src, dst, v in flows:
777
+ if layer[dst] <= layer[src]:
778
+ layer[dst] = layer[src] + 1
779
+ changed = True
780
+ layers = {}
781
+ for n, ln in layer.items():
782
+ layers.setdefault(ln, []).append(nodes[n])
783
+ rect = _plot_rect(ctx, el)
784
+ l, t, r, b = rect
785
+ n_layers = max(layers) + 1 if layers else 1
786
+ node_w = 14 * S
787
+ gap = (r - l - node_w) / max(1, n_layers - 1) if n_layers > 1 else 0
788
+ total_max = max((max(n["out"], n["in"]) for n in nodes.values()), default=1) or 1
789
+ scale = (b - t - 10 * S) / total_max
790
+ # node positions
791
+ pos = {}
792
+ for ln, ns in layers.items():
793
+ x = l + ln * gap
794
+ ycur = t + 5 * S
795
+ for n in sorted(ns, key=lambda n: -(max(n["out"], n["in"]))):
796
+ size = max(n["out"], n["in"]) * scale
797
+ pos[n["name"]] = (x, ycur, node_w, size)
798
+ ycur += size + 6 * S
799
+ fills = s.get("fill")
800
+ fill_map = {}
801
+ if isinstance(fills, dict):
802
+ fill_map = {k: v for k, v in fills.items()}
803
+ elif isinstance(fills, (str, dict)):
804
+ fills = [fills] if isinstance(fills, str) else [fills]
805
+ elif fills is None:
806
+ fills = []
807
+ cycle = _theme_cycle(theme)
808
+ dl = _dl_config(s, el)
809
+ f = _font(9 * S)
810
+ fsmall = _font(8 * S)
811
+ # ribbons
812
+ for src, dst, v in flows:
813
+ if v <= 0 or src not in pos or dst not in pos:
814
+ continue
815
+ sx, sy, sw, sh = pos[src]
816
+ tx, ty, tw, th = pos[dst]
817
+ thickness = v * scale
818
+ # track used offsets
819
+ key = (src, dst)
820
+ so = ctx.setdefault("_sankey_off", {}).get(src, 0)
821
+ to = ctx["_sankey_off"].get(dst, 0)
822
+ x0 = sx + sw
823
+ x1 = tx
824
+ y0 = sy + so
825
+ y1 = ty + to
826
+ ctx["_sankey_off"][src] = so + thickness
827
+ ctx["_sankey_off"][dst] = to + thickness
828
+ c = fill_map.get(src) or (fills[len(ctx["_sankey_seen"])] if fills else
829
+ cycle[len(ctx.setdefault("_sankey_seen", [])) % len(cycle)])
830
+ if isinstance(c, dict):
831
+ stops = c.get("stops", [])
832
+ c = stops[0].get("color") if stops else "#94a3b8"
833
+ rgb = _rgb(c, theme)
834
+ mx = (x0 + x1) / 2
835
+ ribbon = [(x0, y0), (mx, y0), (mx, y1), (x1, y1),
836
+ (x1, y1 + thickness), (mx, y1 + thickness),
837
+ (mx, y0 + thickness), (x0, y0 + thickness)]
838
+ d.polygon(ribbon, fill=(*rgb[:3], 120))
839
+ ctx["_sankey_seen"].append(src)
840
+ if dl.get("show"):
841
+ txt = _fmt_value(v, dl.get("numberFormat"))
842
+ bb = d.textbbox((0, 0), txt, font=fsmall)
843
+ d.text((mx - (bb[2] - bb[0]) / 2, (y0 + y1) / 2), txt, font=fsmall,
844
+ fill=_rgb("#333333", theme))
845
+ # nodes + labels
846
+ for name, (nx, ny, nw, nh) in pos.items():
847
+ c = fill_map.get(name) or cycle[0]
848
+ rgb = _rgb(c, theme)
849
+ d.rectangle([nx, ny, nx + nw, ny + nh], fill=rgb)
850
+ out_v = nodes[name]["out"] or nodes[name]["in"]
851
+ label = name
852
+ if dl.get("show") and dl.get("content", "value") == "value":
853
+ label = f"{name} {_fmt_value(out_v, dl.get('numberFormat'))}"
854
+ if layer[name] == 0:
855
+ d.text((nx - 4 * S, ny + nh / 2), label, font=f,
856
+ fill=_rgb("#333333", theme), anchor="rm")
857
+ else:
858
+ d.text((nx + nw + 4 * S, ny + nh / 2), label, font=f,
859
+ fill=_rgb("#333333", theme), anchor="lm")
860
+
861
+
862
+ # ------------------------------------------------------------- generic cartesian
863
+
864
+ def _cartesian_raster(ctx, series, el):
865
+ d, S, theme = ctx["draw"], ctx["S"], ctx["theme"]
866
+ cols, rows, colidx = _cols(el)
867
+ s0 = series[0]
868
+ enc0 = s0["encode"]
869
+ catcol = enc0.get("x") or enc0.get("category")
870
+ cats = _vals(rows, colidx[catcol])
871
+ n = len(cats)
872
+ # gather all values
873
+ all_vals = []
874
+ per_series = []
875
+ stacked = any(s.get("stack") for s in series if s.get("type") == "area")
876
+ stream = any(s.get("stack") == "stream" for s in series)
877
+ for s in series:
878
+ vcol = s["encode"].get("y") or s["encode"].get("value")
879
+ vals = [_num(v) for v in _vals(rows, colidx[vcol])]
880
+ per_series.append(vals)
881
+ all_vals.extend(v for v in vals if v is not None)
882
+ vmin = min(all_vals, default=0)
883
+ vmax = max(all_vals, default=1)
884
+ if stacked or stream:
885
+ # totals per category
886
+ for i in range(n):
887
+ tot = sum(sv[i] or 0 for sv in per_series
888
+ if sv[i] is not None)
889
+ vmax = max(vmax, tot)
890
+ vmin = min(vmin, 0 if not stream else -tot / 2)
891
+ ticks = _nice_ticks(vmin, vmax)
892
+ rect = _plot_rect(ctx, el)
893
+ l, t, r, b = rect
894
+ _axis_frame(ctx, rect, ticks, cats, el)
895
+ slot = (r - l) / max(1, n)
896
+ cycle = _theme_cycle(theme)
897
+ f = _font(9 * S)
898
+ # compute stacking
899
+ cum = [0.0] * n
900
+ if stream:
901
+ totals = [sum((sv[i] or 0) for sv in per_series) for i in range(n)]
902
+ cum = [-(tot / 2) for tot in totals]
903
+ def yv(v):
904
+ return b - (v - ticks[0]) / (ticks[-1] - ticks[0]) * (b - t)
905
+ for si, s in enumerate(series):
906
+ vals = per_series[si]
907
+ stype = s.get("type")
908
+ color = (s.get("fill") or s.get("lineColor") or s.get("areaColor")
909
+ or cycle[si % len(cycle)])
910
+ if isinstance(color, dict):
911
+ stops = color.get("stops", [])
912
+ color = stops[0].get("color") if stops else cycle[si % len(cycle)]
913
+ rgb = _rgb(color, theme)
914
+ dl = _dl_config(s, el)
915
+ if stype in ("bar",):
916
+ bw = min(slot * 0.6, 26 * S)
917
+ for i, v in enumerate(vals):
918
+ if v is None:
919
+ continue
920
+ cx = l + slot * (i + 0.5)
921
+ if s.get("stack"):
922
+ y0, y1 = cum[i], cum[i] + v
923
+ cum[i] = y1
924
+ else:
925
+ y0, y1 = 0, v
926
+ d.rectangle([cx - bw / 2, yv(max(y0, y1)),
927
+ cx + bw / 2, yv(min(y0, y1))], fill=rgb)
928
+ if dl.get("show"):
929
+ txt = _fmt_value(v, dl.get("numberFormat"))
930
+ d.text((cx, yv(max(y0, y1)) - 11 * S), txt, font=f,
931
+ fill=_rgb("#333333", theme), anchor="ma")
932
+ elif stype in ("line", "area"):
933
+ pts = [(l + slot * (i + 0.5), yv(v if not s.get("stack") else cum[i] + v))
934
+ for i, v in enumerate(vals) if v is not None]
935
+ if s.get("stack"):
936
+ for i, v in enumerate(vals):
937
+ if v is not None:
938
+ cum[i] += v
939
+ pts = [(l + slot * (i + 0.5), yv(cum[i] - v))
940
+ for i, v in enumerate(vals) if v is not None]
941
+ if stype == "area":
942
+ if len(pts) >= 2:
943
+ poly = pts + [(pts[-1][0], yv(0)), (pts[0][0], yv(0))]
944
+ overlay = Image.new("RGBA", ctx["img"].size, (0, 0, 0, 0))
945
+ od = ImageDraw.Draw(overlay)
946
+ od.polygon(poly, fill=(*rgb[:3], 140))
947
+ ctx["img"].alpha_composite(overlay)
948
+ d = ImageDraw.Draw(ctx["img"])
949
+ ctx["draw"] = d
950
+ if len(pts) >= 2:
951
+ wdt = max(1, int(s.get("width", 2) * S))
952
+ if s.get("smooth") and len(pts) > 2:
953
+ pts = _smooth_pts(pts)
954
+ d.line(pts, fill=rgb, width=wdt, joint="curve")
955
+ marker = s.get("marker")
956
+ if marker is not False:
957
+ msize = int((marker or {}).get("size", 5) * S) if marker else 3 * S
958
+ for px, py in pts:
959
+ shape = (marker or {}).get("shape", "circle")
960
+ if shape == "rect":
961
+ d.rectangle([px - msize, py - msize, px + msize, py + msize],
962
+ fill=rgb)
963
+ else:
964
+ d.ellipse([px - msize, py - msize, px + msize, py + msize],
965
+ fill=rgb)
966
+ if dl.get("show") and pts:
967
+ for i, (px, py) in enumerate(pts):
968
+ txt = _fmt_value(vals[i], dl.get("numberFormat"))
969
+ d.text((px, py - 10 * S), txt, font=f,
970
+ fill=_rgb("#333333", theme), anchor="ma")
971
+ elif stype in ("scatter", "bubble"):
972
+ xcol = s["encode"]["x"]
973
+ xs = [_num(v) for v in _vals(rows, colidx[xcol])]
974
+ xs_all = [v for v in xs if v is not None]
975
+ if xs_all:
976
+ xlo, xhi = min(xs_all), max(xs_all)
977
+ else:
978
+ xlo, xhi = 0, 1
979
+ scol = s["encode"].get("size")
980
+ sizes = ([_num(v) for v in _vals(rows, colidx[scol])] if scol
981
+ else [None] * n)
982
+ smax = max((v for v in sizes if v is not None), default=1) or 1
983
+ for xv, yv_, sv in zip(xs, vals, sizes):
984
+ if xv is None or yv_ is None:
985
+ continue
986
+ px = l + (xv - xlo) / max(1e-9, xhi - xlo) * (r - l)
987
+ py = yv(yv_)
988
+ if stype == "bubble":
989
+ rr = math.sqrt(max(0, sv or 0) / smax) * 14 * S + 2 * S
990
+ else:
991
+ rr = ((marker or {}).get("size", 6) if marker else 6) * S / 2
992
+ d.ellipse([px - rr, py - rr, px + rr, py + rr], fill=rgb)
993
+ if stream:
994
+ pass # already drawn via stacked areas
995
+
996
+
997
+ def _smooth_pts(pts):
998
+ """Catmull-Rom smoothing for polyline."""
999
+ if len(pts) < 3:
1000
+ return pts
1001
+ out = [pts[0]]
1002
+ for i in range(len(pts) - 1):
1003
+ p0 = pts[max(0, i - 1)]
1004
+ p1 = pts[i]
1005
+ p2 = pts[i + 1]
1006
+ p3 = pts[min(len(pts) - 1, i + 2)]
1007
+ for t in range(1, 21):
1008
+ tt = t / 20
1009
+ x = _cr(p0[0], p1[0], p2[0], p3[0], tt)
1010
+ y = _cr(p0[1], p1[1], p2[1], p3[1], tt)
1011
+ out.append((x, y))
1012
+ out.append(pts[-1])
1013
+ return out
1014
+
1015
+
1016
+ def _cr(p0, p1, p2, p3, t):
1017
+ return (0.5 * ((2 * p1) + (-p0 + p2) * t +
1018
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t ** 2 +
1019
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t ** 3))