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,847 @@
1
+ """Native editable pptx charts for: bar/line/area/scatter/bubble/pie/radar.
2
+
3
+ Strategy: python-pptx add_chart() creates the chart part + embedded workbook
4
+ with all series data; then we replace c:chart's plotArea/legend/title with
5
+ hand-built XML (full control: grouping, secondary axes, styling, labels).
6
+
7
+ Workbook layout (python-pptx writer):
8
+ CategoryChartData: cats A2:An; series i name at {L_i}1, values {L_i}2:{L_i}n+1
9
+ XyChartData: series i -> name B{off+1}; x A{off+2}:A{...}; y B{...}
10
+ BubbleChartData: same + size C{...}; each table = len+2 rows (title+data+spacer)
11
+ """
12
+ import math
13
+
14
+ from lxml import etree
15
+ from pptx.chart.data import (BubbleChartData, CategoryChartData,
16
+ XyChartData)
17
+ from pptx.enum.chart import XL_CHART_TYPE
18
+ from pptx.oxml.ns import qn
19
+ from pptx.util import Pt
20
+
21
+ from .style import color_el
22
+ from .xmlutil import sub
23
+
24
+ RASTER_ONLY = {"candlestick", "waterfall", "heatmap", "treemap", "sunburst",
25
+ "sankey"}
26
+ NATIVE_TYPES = {"bar", "line", "area", "scatter", "bubble", "pie", "radar"}
27
+
28
+ DEFAULT_CYCLE = ["#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5",
29
+ "#70AD47"]
30
+
31
+ DL_POS = {"bar": "outEnd", "line": "t", "area": "ctr", "scatter": "r",
32
+ "bubble": "r", "pie": "bestFit", "radar": "r"}
33
+
34
+
35
+ def col_letter(n): # 0 -> A
36
+ s = ""
37
+ n += 1
38
+ while n:
39
+ n, r = divmod(n - 1, 26)
40
+ s = chr(65 + r) + s
41
+ return s
42
+
43
+
44
+ def _num(v):
45
+ if v is None:
46
+ return None
47
+ if isinstance(v, (int, float)):
48
+ return v
49
+ try:
50
+ return float(v)
51
+ except (TypeError, ValueError):
52
+ return None
53
+
54
+
55
+ def merge_series_defaults(series, defaults):
56
+ """pptd 3.4 one-level deep merge of seriesDefaults[type] into each series."""
57
+ out = []
58
+ for s in series:
59
+ d = (defaults or {}).get(s.get("type")) or {}
60
+ m = dict(s)
61
+ for k, v in d.items():
62
+ if k in ("type", "encode") or v is None:
63
+ continue
64
+ if k not in m or m[k] is None:
65
+ m[k] = v
66
+ elif isinstance(v, dict) and isinstance(m.get(k), dict):
67
+ merged = dict(v)
68
+ merged.update(m[k])
69
+ m[k] = merged
70
+ out.append(m)
71
+ return out
72
+
73
+
74
+ def can_render_native(el):
75
+ series = merge_series_defaults(el.get("series", []), el.get("seriesDefaults"))
76
+ types = {s.get("type") for s in series}
77
+ if not types or not types <= NATIVE_TYPES or types & RASTER_ONLY:
78
+ return False
79
+ if any(s.get("symbol") for s in series if s.get("type") == "bar"):
80
+ return False
81
+ if any(s.get("stack") == "stream" for s in series):
82
+ return False
83
+ stacks = {s.get("stack") for s in series if s.get("type") == "area" and s.get("stack")}
84
+ if len(stacks) > 1:
85
+ return False
86
+ if types & {"scatter", "bubble"} and types & {"bar", "line", "area"}:
87
+ return False
88
+ if types & {"pie", "radar"} and len(types) > 1:
89
+ return False
90
+ if types == {"pie"} and len(series) > 1:
91
+ return False
92
+ if types & {"scatter", "bubble"} and any(
93
+ (s.get("yAxisIndex") or 0) > 0 or (s.get("xAxisIndex") or 0) > 0
94
+ for s in series):
95
+ return False
96
+ return True
97
+
98
+
99
+ def _vals(rows, i):
100
+ return [r[i] if i < len(r) else None for r in rows]
101
+
102
+
103
+ def _theme_cycle(theme):
104
+ colors = list((theme.get("colors") or {}).values())
105
+ return colors or list(DEFAULT_CYCLE)
106
+
107
+
108
+ def _font(ff):
109
+ if isinstance(ff, dict):
110
+ return ff.get("latin"), ff.get("ea")
111
+ return ff, ff
112
+
113
+
114
+ # ------------------------------------------------------------- xml helpers
115
+
116
+ def _txpr(parent, style, theme, tag="c:txPr", default_sz=12):
117
+ txPr = sub(parent, tag)
118
+ sub(txPr, "a:bodyPr")
119
+ sub(txPr, "a:lstStyle")
120
+ p = sub(txPr, "a:p")
121
+ pPr = sub(p, "a:pPr")
122
+ defRPr = sub(pPr, "a:defRPr")
123
+ sz = style.get("fontSize")
124
+ defRPr.set("sz", str(int(sz if sz is not None else default_sz)))
125
+ if style.get("bold"):
126
+ defRPr.set("b", "1")
127
+ if style.get("color"):
128
+ sf = sub(defRPr, "a:solidFill")
129
+ color_el(sf, style["color"], theme)
130
+ latin, ea = _font(style.get("fontFamily"))
131
+ if latin:
132
+ sub(defRPr, "a:latin", {"typeface": str(latin)})
133
+ if ea:
134
+ sub(defRPr, "a:ea", {"typeface": str(ea)})
135
+ sub(p, "a:endParaRPr", {"lang": "en-US"})
136
+ return txPr
137
+
138
+
139
+ def _fill_solid(spPr, fill, theme, alpha=1.0):
140
+ """Color|GradientFill under a chart spPr (solid default)."""
141
+ if isinstance(fill, str):
142
+ sf = sub(spPr, "a:solidFill")
143
+ color_el(sf, fill, theme, alpha)
144
+ elif isinstance(fill, dict):
145
+ gf = sub(spPr, "a:gradFill", {"rotWithShape": "1"})
146
+ gsLst = sub(gf, "a:gsLst")
147
+ for stop in fill.get("stops", []):
148
+ gs = sub(gsLst, "a:gs", {"pos": str(int(stop.get("position", 0) * 100000))})
149
+ color_el(gs, stop.get("color", "#000"), theme, alpha)
150
+ if fill.get("gradientType") == "radial":
151
+ path = sub(gf, "a:path", {"path": "circle"})
152
+ sub(path, "a:fillToRect", {"l": "50000", "t": "50000", "r": "50000", "b": "50000"})
153
+ else:
154
+ sub(gf, "a:lin", {"ang": str(int(fill.get("angle", 0) * 60000)), "scaled": "1"})
155
+ else:
156
+ sub(spPr, "a:noFill")
157
+
158
+
159
+ def _ln(el, parent, color, width, theme, dash=None):
160
+ lnel = sub(parent, el, {"w": str(int(Pt(width if width is not None else 2)))})
161
+ if color is None:
162
+ sub(lnel, "a:noFill")
163
+ else:
164
+ sf = sub(lnel, "a:solidFill")
165
+ color_el(sf, str(color), theme)
166
+ if dash in ("dash", "dot"):
167
+ sub(lnel, "a:prstDash", {"val": "dash" if dash == "dash" else "sysDot"})
168
+ return lnel
169
+
170
+
171
+ def _ref_cache(parent, ref, values, numeric):
172
+ tag = "c:numRef" if numeric else "c:strRef"
173
+ r = sub(parent, tag)
174
+ sub(r, "c:f", text=ref)
175
+ cache = sub(r, "c:numCache" if numeric else "c:strCache")
176
+ if numeric:
177
+ sub(cache, "c:formatCode", text="General")
178
+ sub(cache, "c:ptCount", {"val": str(len(values))})
179
+ for i, v in enumerate(values):
180
+ if v is None:
181
+ continue
182
+ pt = sub(cache, "c:pt", {"idx": str(i)})
183
+ sub(pt, "c:v", text=(_fmt(v) if numeric else str(v)))
184
+
185
+
186
+ def _fmt(v):
187
+ if isinstance(v, float) and v.is_integer():
188
+ return str(int(v))
189
+ return str(v)
190
+
191
+
192
+ def _ser_head(ser, i, name, name_ref):
193
+ sub(ser, "c:idx", {"val": str(i)})
194
+ sub(ser, "c:order", {"val": str(i)})
195
+ tx = sub(ser, "c:tx")
196
+ sref = sub(tx, "c:strRef")
197
+ sub(sref, "c:f", text=name_ref)
198
+ sc = sub(sref, "c:strCache")
199
+ sub(sc, "c:ptCount", {"val": "1"})
200
+ pt = sub(sc, "c:pt", {"idx": "0"})
201
+ sub(pt, "c:v", text=name)
202
+
203
+
204
+ def _dlbls(ser, dl, theme, typ):
205
+ """DataLabelConfig -> c:dLbls (child of ser, before cat/val)."""
206
+ d = sub(ser, "c:dLbls")
207
+ content = dl.get("content", "value")
208
+ if dl.get("numberFormat"):
209
+ sub(d, "c:numFmt", {"formatCode": str(dl["numberFormat"]), "sourceLinked": "0"})
210
+ pos = DL_POS.get(typ)
211
+ if pos and typ in ("bar", "pie", "line"):
212
+ sub(d, "c:dLblPos", {"val": pos})
213
+ sub(d, "c:showLegendKey", {"val": "0"})
214
+ sub(d, "c:showVal", {"val": "1" if content in ("value", None) else "0"})
215
+ sub(d, "c:showCatName", {"val": "1" if content == "category" else "0"})
216
+ sub(d, "c:showSerName", {"val": "0"})
217
+ sub(d, "c:showPercent", {"val": "1" if content == "percentage" else "0"})
218
+ sub(d, "c:showBubbleSize", {"val": "0"})
219
+ _txpr(d, dl, theme, default_sz=10)
220
+
221
+
222
+ def _merged_dlbls(series_dl, chart_dl):
223
+ merged = {}
224
+ if isinstance(chart_dl, dict):
225
+ merged.update({k: v for k, v in chart_dl.items() if v is not None})
226
+ if isinstance(series_dl, dict):
227
+ merged.update({k: v for k, v in series_dl.items() if v is not None})
228
+ return merged
229
+
230
+
231
+ def _marker_cfg(s):
232
+ marker = s.get("marker")
233
+ if marker is False:
234
+ return None
235
+ if marker is True or marker is None:
236
+ marker = {}
237
+ return marker
238
+
239
+
240
+ def _emit_marker(ser, marker, fill, border, theme):
241
+ mk = sub(ser, "c:marker")
242
+ if marker is None:
243
+ sub(mk, "c:symbol", {"val": "none"})
244
+ return
245
+ shape = {"circle": "circle", "rect": "square", "diamond": "diamond",
246
+ "triangle": "triangle"}.get(marker.get("shape", "circle"), "circle")
247
+ sub(mk, "c:symbol", {"val": shape})
248
+ sub(mk, "c:size", {"val": str(int(marker.get("size", 6)))})
249
+ spPr = sub(mk, "c:spPr")
250
+ _fill_solid(spPr, marker.get("fill") or fill, theme)
251
+ mb = marker.get("border") or border
252
+ _ln("a:ln", spPr, mb.get("color") if mb else None,
253
+ mb.get("width", 1) if mb else None, theme)
254
+
255
+
256
+ def _emit_ln_style(ser, s, color, theme, default_w=2):
257
+ spPr = sub(ser, "c:spPr")
258
+ return spPr
259
+
260
+
261
+ def _axis_common(ax, cfg, theme, ax_pos, is_val):
262
+ """catAx/valAx shared children in schema order (crossAx added by caller)."""
263
+ cfg = cfg or {}
264
+ scaling = sub(ax, "c:scaling")
265
+ sub(scaling, "c:orientation",
266
+ {"val": "maxMin" if cfg.get("reverse") else "minMax"})
267
+ if is_val:
268
+ if cfg.get("min") is not None:
269
+ sub(scaling, "c:min", {"val": str(cfg["min"])})
270
+ if cfg.get("max") is not None:
271
+ sub(scaling, "c:max", {"val": str(cfg["max"])})
272
+ sub(ax, "c:delete", {"val": "0" if cfg.get("show", True) else "1"})
273
+ sub(ax, "c:axPos", {"val": ax_pos})
274
+ gl = cfg.get("gridLine")
275
+ if gl is not False and gl is not None:
276
+ mg = sub(ax, "c:majorGridlines")
277
+ if isinstance(gl, dict):
278
+ spPr = sub(mg, "c:spPr")
279
+ _ln("a:ln", spPr, gl.get("color"), gl.get("width", 1), theme,
280
+ gl.get("style"))
281
+ title = cfg.get("title")
282
+ if title:
283
+ tcfg = title if isinstance(title, dict) else {"text": title}
284
+ t = sub(ax, "c:title")
285
+ sub(t, "c:overlay", {"val": "0"})
286
+ tx = sub(t, "c:tx")
287
+ rich = sub(tx, "c:rich")
288
+ sub(rich, "a:bodyPr")
289
+ sub(rich, "a:lstStyle")
290
+ ap = sub(rich, "a:p")
291
+ ar = sub(ap, "a:r")
292
+ rPr = sub(ar, "a:rPr", {"lang": "en-US"})
293
+ if tcfg.get("fontSize"):
294
+ rPr.set("sz", str(int(tcfg["fontSize"])))
295
+ if tcfg.get("color"):
296
+ sf = sub(rPr, "a:solidFill")
297
+ color_el(sf, tcfg["color"], theme)
298
+ if tcfg.get("bold"):
299
+ rPr.set("b", "1")
300
+ sub(ar, "a:t", text=str(tcfg.get("text", "")))
301
+ label = cfg.get("label", True)
302
+ if is_val and isinstance(label, dict) and label.get("numberFormat"):
303
+ sub(ax, "c:numFmt", {"formatCode": str(label["numberFormat"]),
304
+ "sourceLinked": "0"})
305
+ al = cfg.get("axisLine")
306
+ tick = "out" if al is not False else "none"
307
+ sub(ax, "c:majorTickMark", {"val": tick})
308
+ sub(ax, "c:minorTickMark", {"val": "none"})
309
+ sub(ax, "c:tickLblPos", {"val": "none" if label is False else "nextTo"})
310
+ spPr = sub(ax, "c:spPr")
311
+ if al is False:
312
+ sub(spPr, "a:ln").append(sub(spPr, "a:noFill"))
313
+ else:
314
+ a = al if isinstance(al, dict) else {}
315
+ ln = _ln("a:ln", spPr, a.get("color", "#000000"), a.get("width", 1), theme)
316
+ arrow = a.get("arrow")
317
+ if arrow in (True, "end", "both"):
318
+ sub(ln, "a:tailEnd", {"type": "triangle", "w": "med", "len": "med"})
319
+ if arrow in ("start", "both"):
320
+ sub(ln, "a:headEnd", {"type": "triangle", "w": "med", "len": "med"})
321
+ if isinstance(label, dict) and label:
322
+ _txpr(ax, label, theme, default_sz=10)
323
+
324
+
325
+ def _axis_cfg(cfg, index=0):
326
+ if isinstance(cfg, list):
327
+ cfg = cfg[index] if index < len(cfg) else {}
328
+ if cfg is True:
329
+ return {}
330
+ if cfg is False:
331
+ return {"show": False}
332
+ return cfg if isinstance(cfg, dict) else {}
333
+
334
+
335
+ # ------------------------------------------------------------- main entry
336
+
337
+ def render_chart(slide, el, theme):
338
+ series = merge_series_defaults(el.get("series", []), el.get("seriesDefaults"))
339
+ if not series:
340
+ raise ValueError("chart needs >= 1 series")
341
+ types = {s["type"] for s in series}
342
+ cols, rows, colidx = _cols(el)
343
+ x, y, w, h = el["bounds"]
344
+
345
+ horizontal = (types <= {"bar", "line", "area"} and
346
+ _is_horizontal(series, rows, colidx, el))
347
+ cd, template, sdata = _chart_data(series, types, rows, colidx, horizontal)
348
+ gf = slide.shapes.add_chart(template, Pt(x), Pt(y), Pt(w), Pt(h), cd)
349
+ chart = gf.chart
350
+ cs = chart._chartSpace
351
+ ch = cs.find(qn("c:chart"))
352
+ for tag in ("c:title", "c:autoTitleDeleted", "c:plotArea", "c:legend",
353
+ "c:plotVisOnly", "c:dispBlanksAs"):
354
+ for e in ch.findall(qn(tag)):
355
+ ch.remove(e)
356
+ for e in cs.findall(qn("c:spPr")):
357
+ cs.remove(e)
358
+ for e in cs.findall(qn("c:txPr")):
359
+ cs.remove(e)
360
+
361
+ # chartSpace frame styling (spPr right after c:chart) + global font txPr
362
+ spPr = None
363
+ if el.get("fill") or el.get("border") or el.get("shadow"):
364
+ spPr = etree.Element(qn("c:spPr"))
365
+ ch.addnext(spPr)
366
+ if el.get("fill"):
367
+ _fill_solid(spPr, el["fill"], theme)
368
+ else:
369
+ sub(spPr, "a:noFill")
370
+ b = el.get("border")
371
+ _ln("a:ln", spPr, b.get("color") if b else None,
372
+ b.get("width", 1) if b else None, theme,
373
+ b.get("style") if b else None)
374
+ sh = el.get("shadow")
375
+ if sh:
376
+ eff = sub(spPr, "a:effectLst")
377
+ off = sh.get("offset") or [0, 0]
378
+ dist = int(Pt(math.hypot(off[0], off[1]))) if (off[0] or off[1]) else 0
379
+ dirn = int(math.degrees(math.atan2(off[1], off[0])) % 360 * 60000) if (off[0] or off[1]) else 2700000
380
+ os_ = sub(eff, "a:outerShdw", {
381
+ "blurRad": str(int(Pt(sh.get("blur", 4)))),
382
+ "dist": str(dist), "dir": str(dirn), "rotWithShape": "0"})
383
+ color_el(os_, sh.get("color", "#000000"), theme)
384
+ if el.get("fontFamily"):
385
+ txPr = etree.Element(qn("c:txPr"))
386
+ (spPr if spPr is not None else ch).addnext(txPr)
387
+ _txpr_into(txPr, el, theme)
388
+
389
+ plotArea = etree.SubElement(ch, qn("c:plotArea"))
390
+ sub(plotArea, "c:layout")
391
+ theme_cycle = _theme_cycle(theme)
392
+
393
+ if types == {"pie"}:
394
+ _build_pie(plotArea, series[0], rows, colidx, theme, theme_cycle,
395
+ el.get("dataLabels"))
396
+ elif types == {"radar"}:
397
+ _build_radar(plotArea, series, rows, colidx, theme, theme_cycle,
398
+ el.get("spokeAxis") or {}, el.get("dataLabels"))
399
+ elif types == {"scatter"}:
400
+ _build_scatter(plotArea, series, sdata, theme, theme_cycle,
401
+ el.get("dataLabels"))
402
+ _build_val_axes(plotArea, _axis_cfg(el.get("xAxis")),
403
+ _axis_cfg(el.get("yAxis")), theme)
404
+ elif types == {"bubble"}:
405
+ _build_bubble(plotArea, series, sdata, theme, theme_cycle, el, el.get("dataLabels"))
406
+ _build_val_axes(plotArea, _axis_cfg(el.get("xAxis")),
407
+ _axis_cfg(el.get("yAxis")), theme)
408
+ else:
409
+ horizontal = _is_horizontal(series, rows, colidx, el)
410
+ _build_cartesian(plotArea, series, rows, colidx, theme, theme_cycle,
411
+ el, horizontal)
412
+
413
+ # title (first child of c:chart)
414
+ title = el.get("title")
415
+ if title:
416
+ tcfg = title if isinstance(title, dict) else {"text": title}
417
+ t = etree.Element(qn("c:title"))
418
+ ch.insert(0, t)
419
+ sub(t, "c:overlay", {"val": "0"})
420
+ tx = sub(t, "c:tx")
421
+ rich = sub(tx, "c:rich")
422
+ sub(rich, "a:bodyPr")
423
+ sub(rich, "a:lstStyle")
424
+ ap = sub(rich, "a:p")
425
+ apPr = sub(ap, "a:pPr")
426
+ defRPr = sub(apPr, "a:defRPr")
427
+ defRPr.set("sz", str(int(tcfg.get("fontSize") or 14)))
428
+ if tcfg.get("color"):
429
+ sf = sub(defRPr, "a:solidFill")
430
+ color_el(sf, tcfg["color"], theme)
431
+ if tcfg.get("bold"):
432
+ defRPr.set("b", "1")
433
+ latin, ea = _font(tcfg.get("fontFamily"))
434
+ if latin:
435
+ sub(defRPr, "a:latin", {"typeface": str(latin)})
436
+ ar = sub(ap, "a:r")
437
+ rPr = sub(ar, "a:rPr", {"lang": "en-US"})
438
+ sub(ar, "a:t", text=str(tcfg.get("text", "")))
439
+ else:
440
+ ch.insert(0, etree.Element(qn("c:autoTitleDeleted")))
441
+ ch.find(qn("c:autoTitleDeleted")).set("val", "1")
442
+
443
+ legend = el.get("legend")
444
+ show = (True if legend is None else bool(legend) if isinstance(legend, bool)
445
+ else legend.get("show", True))
446
+ if show:
447
+ leg = etree.SubElement(ch, qn("c:legend"))
448
+ pos = {"top": "t", "bottom": "b", "left": "l", "right": "r"}
449
+ lpos = (legend.get("position", "bottom") if isinstance(legend, dict)
450
+ else "bottom")
451
+ sub(leg, "c:legendPos", {"val": pos.get(lpos, "b")})
452
+ sub(leg, "c:overlay", {"val": "0"})
453
+ if isinstance(legend, dict):
454
+ _txpr(leg, legend, theme, default_sz=10)
455
+
456
+ sub(ch, "c:plotVisOnly", {"val": "1"})
457
+ nb = "gap"
458
+ for s in series:
459
+ if s.get("nullHandling"):
460
+ nb = {"zero": "zero", "gap": "gap", "connect": "span"}.get(
461
+ s["nullHandling"], "gap")
462
+ break
463
+ sub(ch, "c:dispBlanksAs", {"val": nb})
464
+ return gf
465
+
466
+
467
+ def _txpr_into(txPr, style, theme):
468
+ sub(txPr, "a:bodyPr")
469
+ sub(txPr, "a:lstStyle")
470
+ p = sub(txPr, "a:p")
471
+ pPr = sub(p, "a:pPr")
472
+ defRPr = sub(pPr, "a:defRPr")
473
+ latin, ea = _font(style.get("fontFamily"))
474
+ if latin:
475
+ sub(defRPr, "a:latin", {"typeface": str(latin)})
476
+ if ea:
477
+ sub(defRPr, "a:ea", {"typeface": str(ea)})
478
+ sub(p, "a:endParaRPr", {"lang": "en-US"})
479
+
480
+
481
+ def _cols(el):
482
+ data = el.get("data") or {}
483
+ cols = [str(c) for c in data.get("cols", [])]
484
+ rows = data.get("rows", [])
485
+ idx = {c: i for i, c in enumerate(cols)}
486
+ return cols, rows, idx
487
+
488
+
489
+ def _chart_data(series, types, rows, colidx, horizontal=False):
490
+ """Build python-pptx chart data + workbook refs info (sdata)."""
491
+ if types == {"scatter"} or types == {"bubble"}:
492
+ sdata = []
493
+ if types == {"bubble"}:
494
+ cd = BubbleChartData()
495
+ else:
496
+ cd = XyChartData()
497
+ for s in series:
498
+ xs = _vals(rows, colidx[s["encode"]["x"]])
499
+ ys = _vals(rows, colidx[s["encode"]["y"]])
500
+ pts = [(_num(a), _num(b)) for a, b in zip(xs, ys)
501
+ if a is not None or b is not None]
502
+ if types == {"bubble"}:
503
+ ss = _vals(rows, colidx[s["encode"]["size"]])
504
+ pts = [(_num(a), _num(b), _num(c)) for a, b, c in zip(xs, ys, ss)
505
+ if a is not None or b is not None]
506
+ sd = cd.add_series(s.get("name") or s["encode"]["y"])
507
+ for pt in pts:
508
+ sd.add_data_point(*pt)
509
+ sdata.append({"name": s.get("name") or s["encode"]["y"],
510
+ "pts": pts})
511
+ template = XL_CHART_TYPE.BUBBLE if types == {"bubble"} else XL_CHART_TYPE.XY_SCATTER
512
+ return cd, template, sdata
513
+
514
+ if types == {"pie"}:
515
+ s = series[0]
516
+ cats = _vals(rows, colidx[s["encode"]["category"]])
517
+ vals = [_num(v) for v in _vals(rows, colidx[s["encode"]["value"]])]
518
+ cd = CategoryChartData()
519
+ cd.categories = [str(c) if c is not None else "" for c in cats]
520
+ cd.add_series(s.get("name") or s["encode"]["value"], vals)
521
+ template = (XL_CHART_TYPE.DOUGHNUT if (s.get("innerRadius") or 0) > 0
522
+ else XL_CHART_TYPE.PIE)
523
+ return cd, template, None
524
+
525
+ # cartesian / radar
526
+ if types == {"radar"}:
527
+ catcol = series[0]["encode"].get("category")
528
+ else:
529
+ catcol = (series[0]["encode"].get("y") if horizontal
530
+ else series[0]["encode"].get("x")) or \
531
+ series[0]["encode"].get("category")
532
+ cats = _vals(rows, colidx[catcol])
533
+ cd = CategoryChartData()
534
+ cd.categories = [str(c) if c is not None else "" for c in cats]
535
+ for s in series:
536
+ if types == {"radar"}:
537
+ vcol = s["encode"]["y"]
538
+ else:
539
+ vcol = s["encode"]["x"] if horizontal else s["encode"]["y"]
540
+ vals = [_num(v) for v in _vals(rows, colidx[vcol])]
541
+ cd.add_series(s.get("name") or vcol, vals)
542
+ if types == {"radar"}:
543
+ template = XL_CHART_TYPE.RADAR
544
+ elif horizontal:
545
+ template = XL_CHART_TYPE.BAR_CLUSTERED
546
+ else:
547
+ template = XL_CHART_TYPE.COLUMN_CLUSTERED
548
+ return cd, template, None
549
+
550
+
551
+ def _is_horizontal(series, rows, colidx, chart_cfg):
552
+ s0 = series[0]
553
+ xax = chart_cfg.get("xAxis") if chart_cfg else None
554
+ yax = chart_cfg.get("yAxis") if chart_cfg else None
555
+ x_type = xax.get("type") if isinstance(xax, dict) else None
556
+ y_type = yax.get("type") if isinstance(yax, dict) else None
557
+ if y_type == "category" and x_type != "category":
558
+ return True
559
+ if x_type == "category":
560
+ return False
561
+ xv = _vals(rows, colidx[s0["encode"]["x"]])
562
+ yv = _vals(rows, colidx[s0["encode"]["y"]])
563
+ xn = all(_num(v) is not None for v in xv if v is not None)
564
+ ys = all(_num(v) is None for v in yv if v is not None)
565
+ return xn and ys and any(v is not None for v in yv)
566
+
567
+
568
+ # ------------------------------------------------------------- pie / radar
569
+
570
+ def _build_pie(plotArea, s, rows, colidx, theme, cycle, chart_dl):
571
+ donut = (s.get("innerRadius") or 0) > 0
572
+ pie = sub(plotArea, "c:doughnutChart" if donut else "c:pieChart")
573
+ sub(pie, "c:varyColors", {"val": "1"})
574
+ ser = sub(pie, "c:ser")
575
+ _ser_head(ser, 0, s.get("name") or "series", "Sheet1!$B$1")
576
+ fills = s.get("fill")
577
+ if isinstance(fills, (str, dict)):
578
+ fills = [fills]
579
+ elif fills is None:
580
+ fills = []
581
+ cats = _vals(rows, colidx[s["encode"]["category"]])
582
+ vals = [_num(v) for v in _vals(rows, colidx[s["encode"]["value"]])]
583
+ cat_numeric = all(_num(c) is not None for c in cats if c is not None)
584
+ for i in range(len(vals)):
585
+ dpt = sub(ser, "c:dPt")
586
+ sub(dpt, "c:idx", {"val": str(i)})
587
+ sub(dpt, "c:bubble3D", {"val": "0"})
588
+ spPr = sub(dpt, "c:spPr")
589
+ f = (fills[i % len(fills)] if fills else
590
+ cycle[i % len(cycle)])
591
+ _fill_solid(spPr, f, theme)
592
+ b = s.get("border")
593
+ _ln("a:ln", spPr, b.get("color", "#FFFFFF") if b else None,
594
+ b.get("width", 1) if b else None, theme)
595
+ dl = _merged_dlbls(s.get("dataLabels"), chart_dl)
596
+ if dl.get("show"):
597
+ _dlbls(ser, dl, theme, "pie")
598
+ cat = sub(ser, "c:cat")
599
+ _ref_cache(cat, "Sheet1!$A$2:$A$%d" % (len(cats) + 1), cats, cat_numeric)
600
+ val = sub(ser, "c:val")
601
+ _ref_cache(val, "Sheet1!$B$2:$B$%d" % (len(vals) + 1), vals, True)
602
+ sub(pie, "c:firstSliceAng", {"val": str(int((s.get("startAngle") or 0) * 60000))})
603
+ if donut:
604
+ sub(pie, "c:holeSize", {"val": str(int(s["innerRadius"] * 100))})
605
+
606
+
607
+ def _build_radar(plotArea, series, rows, colidx, theme, cycle, spoke, chart_dl):
608
+ rc = sub(plotArea, "c:radarChart")
609
+ filled = any(s.get("areaColor") is not None for s in series)
610
+ sub(rc, "c:radarStyle", {"val": "filled" if filled else "marker"})
611
+ sub(rc, "c:varyColors", {"val": "0"})
612
+ cats = _vals(rows, colidx[series[0]["encode"]["category"]])
613
+ for i, s in enumerate(series):
614
+ ser = sub(rc, "c:ser")
615
+ L = col_letter(i + 1)
616
+ _ser_head(ser, i, s.get("name") or s["encode"]["y"],
617
+ "Sheet1!$%s$1" % L)
618
+ spPr = sub(ser, "c:spPr")
619
+ color = s.get("lineColor") or s.get("areaColor") or cycle[i % len(cycle)]
620
+ if s.get("areaColor") is not None:
621
+ _fill_solid(spPr, s["areaColor"], theme, 0.4)
622
+ else:
623
+ sub(spPr, "a:noFill")
624
+ _ln("a:ln", spPr, color, s.get("width", 2), theme, s.get("lineStyle"))
625
+ _emit_marker(ser, _marker_cfg(s), color, s.get("border"), theme)
626
+ dl = _merged_dlbls(s.get("dataLabels"), chart_dl)
627
+ if dl.get("show"):
628
+ _dlbls(ser, dl, theme, "radar")
629
+ cat = sub(ser, "c:cat")
630
+ _ref_cache(cat, "Sheet1!$A$2:$A$%d" % (len(cats) + 1), cats, False)
631
+ vals = [_num(v) for v in _vals(rows, colidx[s["encode"]["y"]])]
632
+ val = sub(ser, "c:val")
633
+ _ref_cache(val, "Sheet1!$%s$2:$%s$%d" % (L, L, len(vals) + 1), vals, True)
634
+ sub(ser, "c:smooth", {"val": "1" if s.get("smooth") else "0"})
635
+ sub(rc, "c:axId", {"val": "111"})
636
+ sub(rc, "c:axId", {"val": "112"})
637
+ catAx = sub(plotArea, "c:catAx")
638
+ sub(catAx, "c:axId", {"val": "111"})
639
+ _axis_common(catAx, spoke if isinstance(spoke, dict) else {}, theme, "b", False)
640
+ sub(catAx, "c:crossAx", {"val": "112"})
641
+ valAx = sub(plotArea, "c:valAx")
642
+ sub(valAx, "c:axId", {"val": "112"})
643
+ vcfg = {"show": False,
644
+ "min": spoke.get("min") if isinstance(spoke, dict) else None,
645
+ "max": spoke.get("max") if isinstance(spoke, dict) else None,
646
+ "gridLine": spoke.get("gridLine", True) if isinstance(spoke, dict) else True}
647
+ _axis_common(valAx, vcfg, theme, "l", True)
648
+ sub(valAx, "c:crossAx", {"val": "111"})
649
+
650
+
651
+ # ------------------------------------------------------------- scatter/bubble
652
+
653
+ def _build_scatter(plotArea, series, sdata, theme, cycle, chart_dl):
654
+ sc = sub(plotArea, "c:scatterChart")
655
+ sub(sc, "c:scatterStyle", {"val": "lineMarker"})
656
+ offset = 0
657
+ for i, (s, sd) in enumerate(zip(series, sdata)):
658
+ npts = len(sd["pts"])
659
+ top = offset + 2
660
+ ser = sub(sc, "c:ser")
661
+ _ser_head(ser, i, sd["name"], "Sheet1!$B$%d" % (offset + 1))
662
+ spPr = sub(ser, "c:spPr")
663
+ sub(spPr, "a:ln")
664
+ spPr.find(qn("a:ln")).append(etree.Element(qn("a:noFill")))
665
+ _emit_marker(ser, s.get("marker") or {},
666
+ s.get("fill") or cycle[i % len(cycle)],
667
+ s.get("border"), theme)
668
+ dl = _merged_dlbls(s.get("dataLabels"), chart_dl)
669
+ if dl.get("show"):
670
+ _dlbls(ser, dl, theme, "scatter")
671
+ xv = sub(ser, "c:xVal")
672
+ _ref_cache(xv, "Sheet1!$A$%d:$A$%d" % (top, top + npts - 1),
673
+ [p[0] for p in sd["pts"]], True)
674
+ yv = sub(ser, "c:yVal")
675
+ _ref_cache(yv, "Sheet1!$B$%d:$B$%d" % (top, top + npts - 1),
676
+ [p[1] for p in sd["pts"]], True)
677
+ offset += npts + 2
678
+ sub(sc, "c:axId", {"val": "201"})
679
+ sub(sc, "c:axId", {"val": "202"})
680
+
681
+
682
+ def _build_bubble(plotArea, series, sdata, theme, cycle, el, chart_dl):
683
+ bc = sub(plotArea, "c:bubbleChart")
684
+ sub(bc, "c:varyColors", {"val": "0"})
685
+ offset = 0
686
+ for i, (s, sd) in enumerate(zip(series, sdata)):
687
+ npts = len(sd["pts"])
688
+ top = offset + 2
689
+ ser = sub(bc, "c:ser")
690
+ _ser_head(ser, i, sd["name"], "Sheet1!$B$%d" % (offset + 1))
691
+ spPr = sub(ser, "c:spPr")
692
+ _fill_solid(spPr, s.get("fill") or cycle[i % len(cycle)], theme)
693
+ b = s.get("border")
694
+ _ln("a:ln", spPr, b.get("color") if b else None,
695
+ b.get("width", 1) if b else None, theme)
696
+ dl = _merged_dlbls(s.get("dataLabels"), chart_dl)
697
+ if dl.get("show"):
698
+ _dlbls(ser, dl, theme, "bubble")
699
+ xv = sub(ser, "c:xVal")
700
+ _ref_cache(xv, "Sheet1!$A$%d:$A$%d" % (top, top + npts - 1),
701
+ [p[0] for p in sd["pts"]], True)
702
+ yv = sub(ser, "c:yVal")
703
+ _ref_cache(yv, "Sheet1!$B$%d:$B$%d" % (top, top + npts - 1),
704
+ [p[1] for p in sd["pts"]], True)
705
+ sv = sub(ser, "c:bubbleSize")
706
+ _ref_cache(sv, "Sheet1!$C$%d:$C$%d" % (top, top + npts - 1),
707
+ [p[2] for p in sd["pts"]], True)
708
+ offset += npts + 2
709
+ sr = None
710
+ for s in series:
711
+ if s.get("sizeRange"):
712
+ sr = s["sizeRange"]
713
+ sub(bc, "c:scale", {"val": str(int(sr[1]) if sr else 100)})
714
+ sub(bc, "c:axId", {"val": "201"})
715
+ sub(bc, "c:axId", {"val": "202"})
716
+
717
+
718
+ def _build_val_axes(plotArea, xcfg, ycfg, theme):
719
+ ax = sub(plotArea, "c:valAx")
720
+ sub(ax, "c:axId", {"val": "201"})
721
+ _axis_common(ax, xcfg, theme, "b", True)
722
+ sub(ax, "c:crossAx", {"val": "202"})
723
+ sub(ax, "c:crosses", {"val": "autoZero"})
724
+ ax2 = sub(plotArea, "c:valAx")
725
+ sub(ax2, "c:axId", {"val": "202"})
726
+ _axis_common(ax2, ycfg, theme, "l", True)
727
+ sub(ax2, "c:crossAx", {"val": "201"})
728
+ sub(ax2, "c:crosses", {"val": "autoZero"})
729
+
730
+
731
+ # ------------------------------------------------------------- cartesian
732
+
733
+ def _build_cartesian(plotArea, series, rows, colidx, theme, cycle, el,
734
+ horizontal):
735
+ groups = []
736
+ for s in series:
737
+ ai = (s.get("yAxisIndex") or 0) or (s.get("xAxisIndex") or 0)
738
+ key = (s["type"], s.get("stack"), ai)
739
+ for g in groups:
740
+ if g["key"] == key:
741
+ g["series"].append(s)
742
+ break
743
+ else:
744
+ groups.append({"key": key, "series": [s]})
745
+ axis_indexes = sorted({g["key"][2] for g in groups})
746
+ ax_ids = {ai: (300 + ai * 10 + 1, 300 + ai * 10 + 2) for ai in axis_indexes}
747
+
748
+ catcol = series[0]["encode"].get("y" if horizontal else "x") or \
749
+ series[0]["encode"].get("category")
750
+ cats = _vals(rows, colidx[catcol])
751
+ cat_numeric = all(_num(c) is not None for c in cats if c is not None)
752
+
753
+ ser_i = 0
754
+ for g in groups:
755
+ stype, stack, ai = g["key"]
756
+ catId, valId = ax_ids[ai]
757
+ if stype == "bar":
758
+ plot = sub(plotArea, "c:barChart")
759
+ sub(plot, "c:barDir", {"val": "bar" if horizontal else "col"})
760
+ grouping = {"value": "stacked", "percent": "percentStacked"}.get(
761
+ stack, "clustered")
762
+ sub(plot, "c:grouping", {"val": grouping})
763
+ sub(plot, "c:varyColors", {"val": "0"})
764
+ elif stype == "line":
765
+ plot = sub(plotArea, "c:lineChart")
766
+ sub(plot, "c:grouping", {"val": "standard"})
767
+ sub(plot, "c:varyColors", {"val": "0"})
768
+ else:
769
+ plot = sub(plotArea, "c:areaChart")
770
+ sub(plot, "c:grouping", {"val": "stacked" if stack else "standard"})
771
+ sub(plot, "c:varyColors", {"val": "0"})
772
+ g["grouping"] = grouping if stype == "bar" else None
773
+ for s in g["series"]:
774
+ vcol = s["encode"]["x"] if horizontal else s["encode"]["y"]
775
+ L = col_letter(ser_i + 1)
776
+ ser = sub(plot, "c:ser")
777
+ _ser_head(ser, ser_i, s.get("name") or vcol, "Sheet1!$%s$1" % L)
778
+ ci = ser_i % len(cycle)
779
+ spPr = sub(ser, "c:spPr")
780
+ if stype == "bar":
781
+ _fill_solid(spPr, s.get("fill") or cycle[ci], theme)
782
+ b = s.get("border")
783
+ _ln("a:ln", spPr, b.get("color") if b else None,
784
+ b.get("width", 1) if b else None, theme)
785
+ else:
786
+ color = s.get("lineColor") or s.get("areaColor") or cycle[ci]
787
+ if stype == "area":
788
+ area = s.get("areaColor")
789
+ if area is None:
790
+ sf = sub(spPr, "a:solidFill")
791
+ color_el(sf, str(color), theme, 0.35)
792
+ else:
793
+ _fill_solid(spPr, area, theme)
794
+ else:
795
+ sub(spPr, "a:noFill")
796
+ _ln("a:ln", spPr, color, s.get("width", 2), theme, s.get("lineStyle"))
797
+ if stype == "bar":
798
+ sub(ser, "c:invertIfNegative", {"val": "0"})
799
+ if stype in ("line", "area", "radar"):
800
+ _emit_marker(ser, _marker_cfg(s),
801
+ s.get("lineColor") or cycle[ci], s.get("border"), theme)
802
+ dl = _merged_dlbls(s.get("dataLabels"), el.get("dataLabels"))
803
+ if dl.get("show"):
804
+ _dlbls(ser, dl, theme, stype)
805
+ cat = sub(ser, "c:cat")
806
+ _ref_cache(cat, "Sheet1!$A$2:$A$%d" % (len(cats) + 1), cats, cat_numeric)
807
+ vals = [_num(v) for v in _vals(rows, colidx[vcol])]
808
+ val = sub(ser, "c:val")
809
+ _ref_cache(val, "Sheet1!$%s$2:$%s$%d" % (L, L, len(vals) + 1), vals, True)
810
+ if stype == "line":
811
+ sub(ser, "c:smooth", {"val": "1" if s.get("smooth") else "0"})
812
+ ser_i += 1
813
+ if stype == "bar":
814
+ bw = el.get("barWidth")
815
+ cg = el.get("categoryGap", 0.2)
816
+ gap = (1.0 / bw - 1.0) * (1.0 - cg) if bw else cg / 0.6
817
+ sub(plot, "c:gapWidth", {"val": str(max(0, int(gap * 100)))})
818
+ if grouping in ("stacked", "percentStacked"):
819
+ sub(plot, "c:overlap", {"val": "100"})
820
+ elif len(g["series"]) > 1:
821
+ sub(plot, "c:overlap", {"val": str(-int((el.get("barGap") or 0) * 100))})
822
+ sub(plot, "c:axId", {"val": str(catId)})
823
+ sub(plot, "c:axId", {"val": str(valId)})
824
+
825
+ for ai in axis_indexes:
826
+ catId, valId = ax_ids[ai]
827
+ xcfg = _axis_cfg(el.get("xAxis"), ai)
828
+ ycfg = _axis_cfg(el.get("yAxis"), ai)
829
+ if horizontal:
830
+ cat_cfg, cat_pos = ycfg, "l"
831
+ val_cfg, val_pos = xcfg, "b"
832
+ else:
833
+ cat_cfg, cat_pos = xcfg, "b"
834
+ val_cfg, val_pos = ycfg, "l"
835
+ if ai > 0:
836
+ cat_cfg = dict(cat_cfg or {})
837
+ cat_cfg["show"] = False
838
+ val_pos = "t" if horizontal else "r"
839
+ catAx = sub(plotArea, "c:catAx")
840
+ sub(catAx, "c:axId", {"val": str(catId)})
841
+ _axis_common(catAx, cat_cfg, theme, cat_pos, False)
842
+ sub(catAx, "c:crossAx", {"val": str(valId)})
843
+ valAx = sub(plotArea, "c:valAx")
844
+ sub(valAx, "c:axId", {"val": str(valId)})
845
+ _axis_common(valAx, val_cfg or {}, theme, val_pos, True)
846
+ sub(valAx, "c:crossAx", {"val": str(catId)})
847
+ sub(valAx, "c:crosses", {"val": "max" if ai > 0 else "autoZero"})