ilmplot 0.1.0__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.
ilmplot/document.py ADDED
@@ -0,0 +1,2064 @@
1
+ """Native plot document model + strict (de)serialization.
2
+
3
+ A ``PlotDocument`` describes one line plot. It serializes to a JSON payload
4
+ embedded in the SVG ``<metadata id="ilm-plot-document">`` element, making the
5
+ ``*.ilmplot.svg`` file both a portable vector image and an editable document.
6
+
7
+ Parsing is strict by design: unknown fields, wrong types, non-finite numbers,
8
+ duplicate series ids and future schema versions raise
9
+ :class:`PlotDocumentError` with an actionable message rather than silently
10
+ losing data. Loading never executes code — no eval, pickle, or scripts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import io
17
+ import json
18
+ import math
19
+ import os
20
+ import re
21
+ import uuid
22
+ import xml.etree.ElementTree as ET
23
+ from dataclasses import dataclass, field, fields
24
+
25
+ SCHEMA_VERSION = 1
26
+ METADATA_ID = 'ilm-plot-document'
27
+ FORMAT_NAME = 'ilm-plot'
28
+
29
+ # Optional-feature ids the reader understands beyond the schema-1
30
+ # baseline. Every new optional document field added after the baseline
31
+ # must get a capability id in CAPABILITIES, and writers must add it to
32
+ # ``requires`` whenever the field is emitted.
33
+ CAPABILITIES = frozenset({'bands', 'error_band', 'spans', 'box_plot',
34
+ 'column_points', 'histogram',
35
+ 'horizontal_bars', 'sample_counts'})
36
+
37
+ MAX_FILE_BYTES = 16 * 1024 * 1024
38
+ MAX_SERIES = 100
39
+ MAX_TOTAL_POINTS = 100_000
40
+ MAX_DIMENSION_MM = 1000.0
41
+
42
+ LINESTYLES = ('-', '--', '-.', ':', '')
43
+ MARKERS = ('', 'o', 's', '^', 'v', 'D', '+', 'x', '.')
44
+ LEGEND_LOCATIONS = (
45
+ 'best', 'upper right', 'upper left', 'lower left', 'lower right',
46
+ 'right', 'center left', 'center right', 'lower center',
47
+ 'upper center', 'center', 'outside right',
48
+ )
49
+
50
+ # ``kind``: 'line' and 'ridgeline' plot ``series`` (LineSeries), 'violin'
51
+ # and 'histogram' plot ``groups`` (ViolinGroup), 'stacked_column' plots
52
+ # ``categories`` (StackCategory). Schema stays 1 — older builds reject
53
+ # the new kinds.
54
+ KINDS = ('line', 'violin', 'ridgeline', 'stacked_column', 'histogram')
55
+
56
+ ANNOTATION_ANCHORS = _ANCHORS = (
57
+ 'upper left', 'upper center', 'upper right',
58
+ 'lower left', 'lower center', 'lower right',
59
+ 'center left', 'center right')
60
+ _BANDWIDTHS = ('scott', 'silverman')
61
+ _BODIES = ('violin', 'none', 'bar')
62
+ _BAR_ERRORS = ('sd', 'sem', 'none')
63
+ _HIST_STYLES = ('bars', 'step')
64
+ _N_POSITIONS = ('tick', 'top', 'bottom')
65
+ MAX_N_FORMAT = 40
66
+ _N_OPTION_KEYS = ('show_n', 'n_position', 'n_format', 'n_size_pt',
67
+ 'n_color')
68
+ MAX_ANNOTATIONS = 20
69
+ MAX_BRACKETS = 20
70
+ MAX_BANDS = 20
71
+ MAX_SPANS = 20
72
+
73
+
74
+ class PlotDocumentError(ValueError):
75
+ pass
76
+
77
+
78
+ class PlotVersionError(PlotDocumentError):
79
+ """The file needs a newer Plot Editor (schema or capabilities)."""
80
+
81
+ def __init__(self, message, schema_version=None, missing=()):
82
+ super().__init__(message)
83
+ self.schema_version = schema_version
84
+ self.missing = sorted(missing)
85
+ """Raised when a plot document is malformed or unsupported."""
86
+
87
+
88
+ def _err(msg: str) -> PlotDocumentError:
89
+ return PlotDocumentError(msg)
90
+
91
+
92
+ def _is_num(v) -> bool:
93
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
94
+
95
+
96
+ def _req_number(data: dict, key: str, ctx: str,
97
+ lo: float | None = None, hi: float | None = None,
98
+ lo_exclusive: bool = False) -> float:
99
+ v = data.get(key)
100
+ if not _is_num(v) or not math.isfinite(v):
101
+ raise _err(f"{ctx}.{key}: expected a finite number, got {v!r}")
102
+ v = float(v)
103
+ if lo is not None and (v <= lo if lo_exclusive else v < lo):
104
+ op = '>' if lo_exclusive else '>='
105
+ raise _err(f"{ctx}.{key}: expected {op} {lo}, got {v}")
106
+ if hi is not None and v > hi:
107
+ raise _err(f"{ctx}.{key}: expected <= {hi}, got {v}")
108
+ return v
109
+
110
+
111
+ def _req_str(data: dict, key: str, ctx: str, allow_empty: bool = True) -> str:
112
+ v = data.get(key)
113
+ if not isinstance(v, str) or (not allow_empty and not v):
114
+ raise _err(f"{ctx}.{key}: expected a string, got {v!r}")
115
+ return v
116
+
117
+
118
+ def _req_bool(data: dict, key: str, ctx: str) -> bool:
119
+ v = data.get(key)
120
+ if not isinstance(v, bool):
121
+ raise _err(f"{ctx}.{key}: expected true/false, got {v!r}")
122
+ return v
123
+
124
+
125
+ def _check_color(value: str, ctx: str) -> str:
126
+ """Validate and normalize a color to '#rrggbb' / '#rrggbbaa'."""
127
+ if not isinstance(value, str):
128
+ raise _err(f"{ctx}: expected a hex color string, got {value!r}")
129
+ v = value.strip()
130
+ try:
131
+ from matplotlib.colors import to_rgba
132
+ r, g, b, a = to_rgba(v)
133
+ except Exception:
134
+ raise _err(f"{ctx}: unsupported color {value!r}; use #rgb/#rrggbb/#rrggbbaa")
135
+ if not v.startswith('#'):
136
+ # Only accept literal hex in the file format — named colors and
137
+ # expressions are normalized to hex so the document is stable.
138
+ return '#{:02x}{:02x}{:02x}{:02x}'.format(
139
+ round(r * 255), round(g * 255), round(b * 255), round(a * 255))
140
+ if len(v) not in (4, 5, 7, 9):
141
+ raise _err(f"{ctx}: invalid hex color {value!r}")
142
+ try:
143
+ int(v[1:], 16)
144
+ except ValueError:
145
+ raise _err(f"{ctx}: invalid hex color {value!r}")
146
+ return v.lower()
147
+
148
+
149
+ def _check_limit(value, ctx: str):
150
+ if value is None:
151
+ return None
152
+ if (not isinstance(value, (list, tuple)) or len(value) != 2
153
+ or any(not _is_num(v) or not math.isfinite(v) for v in value)):
154
+ raise _err(f"{ctx}: expected null or [min, max] of finite numbers")
155
+ lo, hi = float(value[0]), float(value[1])
156
+ if lo == hi:
157
+ raise _err(f"{ctx}: axis limits must differ (got {lo} == {hi})")
158
+ return [lo, hi] # inverted axes are allowed
159
+
160
+
161
+ def _check_yerr(value, expected_len: int, ctx: str):
162
+ """Optional y-error array: finite numbers >= 0 matching the series
163
+ length, or None."""
164
+ if value is None:
165
+ return None
166
+ if not isinstance(value, list) or len(value) != expected_len \
167
+ or any(not _is_num(v) or not math.isfinite(v) or v < 0
168
+ for v in value):
169
+ raise _err(f"{ctx}: expected null or an array of {expected_len} "
170
+ "finite numbers >= 0")
171
+ return [float(v) for v in value]
172
+
173
+
174
+ def _check_yerr_pair(yerr, yerr_minus, yerr_plus, ctx: str):
175
+ if (yerr_minus is None) != (yerr_plus is None):
176
+ raise _err(f"{ctx}: yerr_minus and yerr_plus must be given "
177
+ "together")
178
+ if yerr is not None and yerr_minus is not None:
179
+ raise _err(f"{ctx}: use either yerr or yerr_minus/yerr_plus")
180
+
181
+
182
+ _REQUIRE_RE = re.compile(r'^[a-z0-9_.-]{1,40}$')
183
+
184
+
185
+ def _check_requires(value, ctx: str):
186
+ """Optional capability-id list: unique [a-z0-9_.-]{1,40}, <= 50."""
187
+ if value is None:
188
+ return []
189
+ if not isinstance(value, list) or len(value) > 50 \
190
+ or any(not isinstance(v, str)
191
+ or not _REQUIRE_RE.match(v) for v in value) \
192
+ or len(set(value)) != len(value):
193
+ raise _err(f"{ctx}: expected an array of unique capability ids "
194
+ "([a-z0-9_.-]{1,40}, at most 50)")
195
+ return list(value)
196
+
197
+
198
+ def required_capabilities(document) -> list:
199
+ """Capability ids that must be stamped on *document*'s output."""
200
+ req = []
201
+ if getattr(document, 'bands', None):
202
+ req.append('bands')
203
+ if getattr(document, 'spans', None):
204
+ req.append('spans')
205
+ for s in getattr(document, 'series', None) or ():
206
+ if getattr(s, 'error_style', 'bars') == 'band':
207
+ req.append('error_band')
208
+ break
209
+ v = getattr(document, 'violin', None)
210
+ if v is not None:
211
+ d = v.to_dict()
212
+ if d.get('body') == 'none' or 'box_width' in d \
213
+ or 'show_outliers' in d:
214
+ req.append('box_plot')
215
+ if d.get('body') == 'bar' or 'bar_width' in d \
216
+ or 'bar_error' in d:
217
+ req.append('column_points')
218
+ if any(k in d for k in _N_OPTION_KEYS):
219
+ req.append('sample_counts')
220
+ if getattr(document, 'kind', None) == 'histogram':
221
+ req.append('histogram')
222
+ h = getattr(document, 'histogram', None)
223
+ if h is not None:
224
+ d = h.to_dict()
225
+ if any(k in d for k in _N_OPTION_KEYS):
226
+ req.append('sample_counts')
227
+ st = getattr(document, 'stacked', None)
228
+ if st is not None and getattr(st, 'horizontal', False):
229
+ req.append('horizontal_bars')
230
+ return sorted(req)
231
+
232
+
233
+ MAX_TICK_LABEL = 1000
234
+
235
+
236
+ def _check_tick_labels(value, ctx):
237
+ """Validate an optional categorical x-axis tick list.
238
+
239
+ Shape: ``[[position, label], ...]`` with unique finite positions and
240
+ labels of at most ``MAX_TICK_LABEL`` characters. ``None`` (absent)
241
+ passes through unchanged.
242
+ """
243
+ if value is None:
244
+ return None
245
+ if not isinstance(value, (list, tuple)) or not value:
246
+ raise _err(f"{ctx}: expected a non-empty array of "
247
+ "[position, label] pairs")
248
+ if len(value) > MAX_TOTAL_POINTS:
249
+ raise _err(f"{ctx}: too many entries ({len(value)} > "
250
+ f"{MAX_TOTAL_POINTS})")
251
+ seen = set()
252
+ out = []
253
+ for i, pair in enumerate(value):
254
+ pc = f"{ctx}[{i}]"
255
+ if not isinstance(pair, (list, tuple)) or len(pair) != 2:
256
+ raise _err(f"{pc}: expected a [position, label] pair")
257
+ pos, label = pair
258
+ if not _is_num(pos) or not math.isfinite(pos):
259
+ raise _err(f"{pc}: position must be a finite number, "
260
+ f"got {pos!r}")
261
+ if pos in seen:
262
+ raise _err(f"{pc}: duplicate position {pos!r}")
263
+ seen.add(pos)
264
+ if not isinstance(label, str) or len(label) > MAX_TICK_LABEL:
265
+ raise _err(f"{pc}: label must be a string of at most "
266
+ f"{MAX_TICK_LABEL} characters")
267
+ out.append([float(pos), label])
268
+ return out
269
+
270
+
271
+ # ── optional per-element styling ──────────────────────────────────────────
272
+ #
273
+ # A ``PlotDocument.style`` object holds per-element overrides. Every field
274
+ # is emitted only when it differs from its default, so a document without
275
+ # styling serializes byte-identically to the pre-style schema. Older builds
276
+ # reject styled files (unknown key) — that is the accepted boundary.
277
+
278
+ _ALIGN = ('left', 'center', 'right')
279
+ _TICK_DIRECTIONS = ('out', 'in', 'inout')
280
+ _AXIS_SCALES = ('linear', 'log')
281
+ _GRID_AXES = ('both', 'x', 'y')
282
+ _GRID_WHICH = ('major', 'both')
283
+ _GRID_LINESTYLES = tuple(ls for ls in LINESTYLES if ls)
284
+
285
+ _MAX_STYLE_FAMILY = 100
286
+ _MAX_AFFIX = 50
287
+
288
+
289
+ def _style_bool(data: dict, key: str, ctx: str) -> bool:
290
+ v = data.get(key)
291
+ if v is not None and not isinstance(v, bool):
292
+ raise _err(f"{ctx}.{key}: expected true/false, got {v!r}")
293
+ return bool(v)
294
+
295
+
296
+ def _style_str(data: dict, key: str, ctx: str, max_len: int):
297
+ v = data.get(key)
298
+ if v is not None and (not isinstance(v, str) or len(v) > max_len):
299
+ raise _err(f"{ctx}.{key}: expected a string of at most {max_len} "
300
+ f"characters, got {v!r}")
301
+ return v
302
+
303
+
304
+ def _style_num(data: dict, key: str, ctx: str, lo=None, hi=None,
305
+ lo_exclusive=False):
306
+ v = data.get(key)
307
+ if v is None:
308
+ return None
309
+ if not _is_num(v) or not math.isfinite(v):
310
+ raise _err(f"{ctx}.{key}: expected a finite number, got {v!r}")
311
+ v = float(v)
312
+ if lo is not None and (v <= lo if lo_exclusive else v < lo):
313
+ op = '>' if lo_exclusive else '>='
314
+ raise _err(f"{ctx}.{key}: expected {op} {lo}, got {v}")
315
+ if hi is not None and v > hi:
316
+ raise _err(f"{ctx}.{key}: expected <= {hi}, got {v}")
317
+ return v
318
+
319
+
320
+ def _style_choice(data: dict, key: str, ctx: str, choices):
321
+ v = data.get(key)
322
+ if v is not None and v not in choices:
323
+ raise _err(f"{ctx}.{key}: unsupported {v!r}; one of {choices}")
324
+ return v
325
+
326
+
327
+ def _check_n_format(value, ctx: str) -> str:
328
+ """Validate an ``{n}`` sample-count template (1..MAX_N_FORMAT)."""
329
+ if not isinstance(value, str) or not value \
330
+ or len(value) > MAX_N_FORMAT:
331
+ raise _err(f"{ctx}: expected a template containing {{n}}")
332
+ try:
333
+ if '{n}' not in value:
334
+ raise ValueError
335
+ value.format(n=1)
336
+ except (KeyError, IndexError, ValueError):
337
+ raise _err(f"{ctx}: expected a template containing {{n}}")
338
+ return value
339
+
340
+
341
+ def _unknown_style_keys(data: dict, allowed: set, ctx: str):
342
+ unknown = sorted(set(data) - allowed)
343
+ if unknown:
344
+ raise _err(f"{ctx}: unknown field(s) {unknown}")
345
+
346
+
347
+ def _style_dict(data, ctx: str) -> dict:
348
+ if not isinstance(data, dict):
349
+ raise _err(f"{ctx}: expected an object, got {type(data).__name__}")
350
+ return data
351
+
352
+
353
+ @dataclass
354
+ class TextStyle:
355
+ """Per-text overrides; ``None`` fields inherit document defaults."""
356
+ family: 'str | None' = None
357
+ size_pt: 'float | None' = None
358
+ bold: bool = False
359
+ italic: bool = False
360
+ underline: bool = False
361
+ color: str = '#000000'
362
+
363
+ _KEYS = ('family', 'size_pt', 'bold', 'italic', 'underline', 'color')
364
+
365
+ def to_dict(self) -> dict:
366
+ d = {}
367
+ if self.family is not None:
368
+ d['family'] = self.family
369
+ if self.size_pt is not None:
370
+ d['size_pt'] = self.size_pt
371
+ if self.bold:
372
+ d['bold'] = True
373
+ if self.italic:
374
+ d['italic'] = True
375
+ if self.underline:
376
+ d['underline'] = True
377
+ if self.color != '#000000':
378
+ d['color'] = self.color
379
+ return d
380
+
381
+ @classmethod
382
+ def from_dict(cls, data, ctx):
383
+ data = _style_dict(data, ctx)
384
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
385
+ s = cls()
386
+ family = _style_str(data, 'family', ctx, _MAX_STYLE_FAMILY)
387
+ if family is not None and not family:
388
+ raise _err(f"{ctx}.family: expected a non-empty string")
389
+ s.family = family
390
+ s.size_pt = _style_num(data, 'size_pt', ctx, lo=0, hi=200,
391
+ lo_exclusive=True)
392
+ s.bold = _style_bool(data, 'bold', ctx)
393
+ s.italic = _style_bool(data, 'italic', ctx)
394
+ s.underline = _style_bool(data, 'underline', ctx)
395
+ if data.get('color') is not None:
396
+ s.color = _check_color(data['color'], f"{ctx}.color")
397
+ return s
398
+
399
+
400
+ @dataclass
401
+ class TitleStyle(TextStyle):
402
+ align: str = 'center'
403
+
404
+ _KEYS = TextStyle._KEYS + ('align',)
405
+
406
+ def to_dict(self) -> dict:
407
+ d = super().to_dict()
408
+ if self.align != 'center':
409
+ d['align'] = self.align
410
+ return d
411
+
412
+ @classmethod
413
+ def from_dict(cls, data, ctx):
414
+ # super() resolves ``cls._KEYS``/``cls()`` as TitleStyle already.
415
+ s = super().from_dict(data, ctx)
416
+ align = _style_choice(data, 'align', ctx, _ALIGN)
417
+ s.align = align or 'center'
418
+ return s
419
+
420
+
421
+ @dataclass
422
+ class AxisStyle:
423
+ """Tick/axis presentation for one axis."""
424
+ ticks: 'TextStyle | None' = None
425
+ rotation: float = 0.0
426
+ prefix: str = ''
427
+ suffix: str = ''
428
+ decimals: 'int | None' = None
429
+ step: 'float | None' = None
430
+ minor: bool = False
431
+ direction: str = 'out'
432
+ length_pt: float = 3.5
433
+ scale: str = 'linear'
434
+ reversed: bool = False
435
+
436
+ _KEYS = ('ticks', 'rotation', 'prefix', 'suffix', 'decimals', 'step',
437
+ 'minor', 'direction', 'length_pt', 'scale', 'reversed')
438
+
439
+ def to_dict(self) -> dict:
440
+ d = {}
441
+ if self.ticks is not None:
442
+ td = self.ticks.to_dict()
443
+ if td:
444
+ d['ticks'] = td
445
+ if self.rotation != 0:
446
+ d['rotation'] = self.rotation
447
+ if self.prefix:
448
+ d['prefix'] = self.prefix
449
+ if self.suffix:
450
+ d['suffix'] = self.suffix
451
+ if self.decimals is not None:
452
+ d['decimals'] = self.decimals
453
+ if self.step is not None:
454
+ d['step'] = self.step
455
+ if self.minor:
456
+ d['minor'] = True
457
+ if self.direction != 'out':
458
+ d['direction'] = self.direction
459
+ if self.length_pt != 3.5:
460
+ d['length_pt'] = self.length_pt
461
+ if self.scale != 'linear':
462
+ d['scale'] = self.scale
463
+ if self.reversed:
464
+ d['reversed'] = True
465
+ return d
466
+
467
+ @classmethod
468
+ def from_dict(cls, data, ctx):
469
+ data = _style_dict(data, ctx)
470
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
471
+ s = cls()
472
+ if data.get('ticks') is not None:
473
+ s.ticks = TextStyle.from_dict(data['ticks'], f"{ctx}.ticks")
474
+ rot = _style_num(data, 'rotation', ctx, lo=-90, hi=90)
475
+ s.rotation = rot if rot is not None else 0.0
476
+ s.prefix = _style_str(data, 'prefix', ctx, _MAX_AFFIX) or ''
477
+ s.suffix = _style_str(data, 'suffix', ctx, _MAX_AFFIX) or ''
478
+ dec = data.get('decimals')
479
+ if dec is not None:
480
+ if not isinstance(dec, int) or isinstance(dec, bool) \
481
+ or not (0 <= dec <= 10):
482
+ raise _err(f"{ctx}.decimals: expected an integer in "
483
+ f"0..10, got {dec!r}")
484
+ s.decimals = dec
485
+ s.step = _style_num(data, 'step', ctx, lo=0, lo_exclusive=True)
486
+ s.minor = _style_bool(data, 'minor', ctx)
487
+ s.direction = _style_choice(data, 'direction', ctx,
488
+ _TICK_DIRECTIONS) or 'out'
489
+ length = _style_num(data, 'length_pt', ctx, lo=0, hi=20)
490
+ s.length_pt = length if length is not None else 3.5
491
+ s.scale = _style_choice(data, 'scale', ctx, _AXIS_SCALES) \
492
+ or 'linear'
493
+ s.reversed = _style_bool(data, 'reversed', ctx)
494
+ return s
495
+
496
+
497
+ @dataclass
498
+ class GridStyle:
499
+ axis: str = 'both'
500
+ which: str = 'major'
501
+ color: str = '#b0b0b0'
502
+ linestyle: str = '-'
503
+ linewidth_pt: float = 0.8
504
+ alpha: float = 1.0
505
+
506
+ _KEYS = ('axis', 'which', 'color', 'linestyle', 'linewidth_pt',
507
+ 'alpha')
508
+
509
+ def to_dict(self) -> dict:
510
+ d = {}
511
+ if self.axis != 'both':
512
+ d['axis'] = self.axis
513
+ if self.which != 'major':
514
+ d['which'] = self.which
515
+ if self.color != '#b0b0b0':
516
+ d['color'] = self.color
517
+ if self.linestyle != '-':
518
+ d['linestyle'] = self.linestyle
519
+ if self.linewidth_pt != 0.8:
520
+ d['linewidth_pt'] = self.linewidth_pt
521
+ if self.alpha != 1.0:
522
+ d['alpha'] = self.alpha
523
+ return d
524
+
525
+ @classmethod
526
+ def from_dict(cls, data, ctx):
527
+ data = _style_dict(data, ctx)
528
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
529
+ s = cls()
530
+ s.axis = _style_choice(data, 'axis', ctx, _GRID_AXES) or 'both'
531
+ s.which = _style_choice(data, 'which', ctx, _GRID_WHICH) or 'major'
532
+ if data.get('color') is not None:
533
+ s.color = _check_color(data['color'], f"{ctx}.color")
534
+ ls = _style_choice(data, 'linestyle', ctx, _GRID_LINESTYLES)
535
+ if ls is not None:
536
+ s.linestyle = ls
537
+ lw = _style_num(data, 'linewidth_pt', ctx, lo=0, hi=20,
538
+ lo_exclusive=True)
539
+ if lw is not None:
540
+ s.linewidth_pt = lw
541
+ alpha = _style_num(data, 'alpha', ctx, lo=0, hi=1)
542
+ if alpha is not None:
543
+ s.alpha = alpha
544
+ return s
545
+
546
+
547
+ @dataclass
548
+ class LegendStyle:
549
+ frame: bool = True
550
+ frame_color: str = '#cccccc'
551
+ ncols: int = 1
552
+ text: 'TextStyle | None' = None
553
+
554
+ _KEYS = ('frame', 'frame_color', 'ncols', 'text')
555
+
556
+ def to_dict(self) -> dict:
557
+ d = {}
558
+ if not self.frame:
559
+ d['frame'] = False
560
+ if self.frame_color != '#cccccc':
561
+ d['frame_color'] = self.frame_color
562
+ if self.ncols != 1:
563
+ d['ncols'] = self.ncols
564
+ if self.text is not None:
565
+ td = self.text.to_dict()
566
+ if td:
567
+ d['text'] = td
568
+ return d
569
+
570
+ @classmethod
571
+ def from_dict(cls, data, ctx):
572
+ data = _style_dict(data, ctx)
573
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
574
+ s = cls()
575
+ if data.get('frame') is not None and not isinstance(
576
+ data['frame'], bool):
577
+ raise _err(f"{ctx}.frame: expected true/false, "
578
+ f"got {data['frame']!r}")
579
+ if data.get('frame') is not None:
580
+ s.frame = data['frame']
581
+ if data.get('frame_color') is not None:
582
+ s.frame_color = _check_color(data['frame_color'],
583
+ f"{ctx}.frame_color")
584
+ n = data.get('ncols')
585
+ if n is not None:
586
+ if not isinstance(n, int) or isinstance(n, bool) \
587
+ or not (1 <= n <= 10):
588
+ raise _err(f"{ctx}.ncols: expected an integer in 1..10, "
589
+ f"got {n!r}")
590
+ s.ncols = n
591
+ if data.get('text') is not None:
592
+ s.text = TextStyle.from_dict(data['text'], f"{ctx}.text")
593
+ return s
594
+
595
+
596
+ @dataclass
597
+ class FrameStyle:
598
+ color: str = '#000000'
599
+ linewidth_pt: float = 0.8
600
+ hide_top: bool = False
601
+ hide_right: bool = False
602
+ hide_left: bool = False
603
+ hide_bottom: bool = False
604
+
605
+ _KEYS = ('color', 'linewidth_pt', 'hide_top', 'hide_right',
606
+ 'hide_left', 'hide_bottom')
607
+
608
+ def to_dict(self) -> dict:
609
+ d = {}
610
+ if self.color != '#000000':
611
+ d['color'] = self.color
612
+ if self.linewidth_pt != 0.8:
613
+ d['linewidth_pt'] = self.linewidth_pt
614
+ if self.hide_top:
615
+ d['hide_top'] = True
616
+ if self.hide_right:
617
+ d['hide_right'] = True
618
+ if self.hide_left:
619
+ d['hide_left'] = True
620
+ if self.hide_bottom:
621
+ d['hide_bottom'] = True
622
+ return d
623
+
624
+ @classmethod
625
+ def from_dict(cls, data, ctx):
626
+ data = _style_dict(data, ctx)
627
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
628
+ s = cls()
629
+ if data.get('color') is not None:
630
+ s.color = _check_color(data['color'], f"{ctx}.color")
631
+ lw = _style_num(data, 'linewidth_pt', ctx, lo=0, hi=20)
632
+ if lw is not None:
633
+ s.linewidth_pt = lw
634
+ s.hide_top = _style_bool(data, 'hide_top', ctx)
635
+ s.hide_right = _style_bool(data, 'hide_right', ctx)
636
+ s.hide_left = _style_bool(data, 'hide_left', ctx)
637
+ s.hide_bottom = _style_bool(data, 'hide_bottom', ctx)
638
+ return s
639
+
640
+
641
+ # ── kind options ─────────────────────────────────────────────────────
642
+ # One options object per kind; emitted only when non-default so files
643
+ # without them stay parseable by older builds (unknown fields rejected).
644
+
645
+ @dataclass
646
+ class ViolinOptions:
647
+ show_box: bool = True
648
+ show_points: bool = True
649
+ points_beside: bool = False
650
+ bandwidth: object = 'scott' # 'scott' | 'silverman' | float > 0
651
+ fill_alpha: float = 0.3
652
+ edge_color: 'str | None' = None # absent → the group's colour
653
+ edge_width_pt: float = 1.0 # 0 → no border
654
+ point_size_pt: float = 6.0 # marker diameter ≡ scatter s=12
655
+ point_alpha: float = 1.0
656
+ point_edge_color: 'str | None' = None # absent → the point colour
657
+ point_edge_width_pt: float = 1.0 # 0 → no outline
658
+ enhance_contrast: bool = False
659
+ body: str = 'violin' # 'violin' | 'none' (box) | 'bar'
660
+ box_width: 'float | None' = None # full width in category units;
661
+ # None → 0.16 for 'violin'/'bar' bodies, 0.5 for 'none'
662
+ show_outliers: bool = True # 'none' body: outlier markers
663
+ bar_width: float = 0.6 # 'bar' body column width
664
+ bar_error: str = 'sd' # 'sd' | 'sem' | 'none'
665
+ show_n: bool = False # per-group sample-size marks
666
+ n_position: str = 'tick' # 'tick' | 'top' | 'bottom'
667
+ n_format: str = 'n = {n}'
668
+ n_size_pt: 'float | None' = None # None → document.font_size_pt
669
+ n_color: 'str | None' = None # None → '#333333'
670
+
671
+ _KEYS = ('show_box', 'show_points', 'points_beside', 'bandwidth',
672
+ 'fill_alpha', 'edge_color', 'edge_width_pt',
673
+ 'point_size_pt', 'point_alpha', 'point_edge_color',
674
+ 'point_edge_width_pt', 'enhance_contrast',
675
+ 'body', 'box_width', 'show_outliers', 'bar_width',
676
+ 'bar_error', 'show_n', 'n_position', 'n_format',
677
+ 'n_size_pt', 'n_color')
678
+
679
+ def to_dict(self) -> dict:
680
+ d = {}
681
+ if not self.show_box:
682
+ d['show_box'] = False
683
+ if not self.show_points:
684
+ d['show_points'] = False
685
+ if self.points_beside:
686
+ d['points_beside'] = True
687
+ if self.bandwidth != 'scott':
688
+ d['bandwidth'] = self.bandwidth
689
+ if self.fill_alpha != 0.3:
690
+ d['fill_alpha'] = self.fill_alpha
691
+ if self.edge_color is not None:
692
+ d['edge_color'] = self.edge_color
693
+ if self.edge_width_pt != 1.0:
694
+ d['edge_width_pt'] = self.edge_width_pt
695
+ if self.point_size_pt != 6.0:
696
+ d['point_size_pt'] = self.point_size_pt
697
+ if self.point_alpha != 1.0:
698
+ d['point_alpha'] = self.point_alpha
699
+ if self.point_edge_color is not None:
700
+ d['point_edge_color'] = self.point_edge_color
701
+ if self.point_edge_width_pt != 1.0:
702
+ d['point_edge_width_pt'] = self.point_edge_width_pt
703
+ if self.enhance_contrast:
704
+ d['enhance_contrast'] = True
705
+ if self.body != 'violin':
706
+ d['body'] = self.body
707
+ if self.box_width is not None:
708
+ d['box_width'] = self.box_width
709
+ if not self.show_outliers:
710
+ d['show_outliers'] = False
711
+ if self.bar_width != 0.6:
712
+ d['bar_width'] = self.bar_width
713
+ if self.bar_error != 'sd':
714
+ d['bar_error'] = self.bar_error
715
+ if self.show_n:
716
+ d['show_n'] = True
717
+ if self.n_position != 'tick':
718
+ d['n_position'] = self.n_position
719
+ if self.n_format != 'n = {n}':
720
+ d['n_format'] = self.n_format
721
+ if self.n_size_pt is not None:
722
+ d['n_size_pt'] = self.n_size_pt
723
+ if self.n_color is not None:
724
+ d['n_color'] = self.n_color
725
+ return d
726
+
727
+ @classmethod
728
+ def from_dict(cls, data, ctx='document.violin'):
729
+ data = _style_dict(data, ctx)
730
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
731
+ o = cls()
732
+ for key in ('show_box', 'show_points', 'points_beside',
733
+ 'enhance_contrast', 'show_outliers', 'show_n'):
734
+ v = data.get(key)
735
+ if v is not None:
736
+ if not isinstance(v, bool):
737
+ raise _err(f"{ctx}.{key}: expected true/false, "
738
+ f"got {v!r}")
739
+ setattr(o, key, v)
740
+ bw = data.get('bandwidth')
741
+ if bw is not None:
742
+ if isinstance(bw, str):
743
+ if bw not in _BANDWIDTHS:
744
+ raise _err(f"{ctx}.bandwidth: unsupported {bw!r}")
745
+ o.bandwidth = bw
746
+ elif _is_num(bw) and math.isfinite(bw) and bw > 0:
747
+ o.bandwidth = float(bw)
748
+ else:
749
+ raise _err(f"{ctx}.bandwidth: expected scott/silverman "
750
+ f"or a positive finite number, got {bw!r}")
751
+ v = _style_num(data, 'fill_alpha', ctx, lo=0, hi=1)
752
+ if v is not None:
753
+ o.fill_alpha = v
754
+ if data.get('edge_color') is not None:
755
+ o.edge_color = _check_color(data['edge_color'],
756
+ f"{ctx}.edge_color")
757
+ v = _style_num(data, 'edge_width_pt', ctx, lo=0, hi=10)
758
+ if v is not None:
759
+ o.edge_width_pt = v
760
+ v = _style_num(data, 'point_size_pt', ctx, lo=0, hi=20)
761
+ if v is not None:
762
+ o.point_size_pt = v
763
+ v = _style_num(data, 'point_alpha', ctx, lo=0, hi=1)
764
+ if v is not None:
765
+ o.point_alpha = v
766
+ if data.get('point_edge_color') is not None:
767
+ o.point_edge_color = _check_color(
768
+ data['point_edge_color'], f"{ctx}.point_edge_color")
769
+ v = _style_num(data, 'point_edge_width_pt', ctx, lo=0, hi=10)
770
+ if v is not None:
771
+ o.point_edge_width_pt = v
772
+ v = _style_choice(data, 'body', ctx, _BODIES)
773
+ if v is not None:
774
+ o.body = v
775
+ o.box_width = _style_num(data, 'box_width', ctx, lo=0, hi=1,
776
+ lo_exclusive=True)
777
+ v = _style_num(data, 'bar_width', ctx, lo=0, hi=1,
778
+ lo_exclusive=True)
779
+ if v is not None:
780
+ o.bar_width = v
781
+ v = _style_choice(data, 'bar_error', ctx, _BAR_ERRORS)
782
+ if v is not None:
783
+ o.bar_error = v
784
+ v = _style_choice(data, 'n_position', ctx, _N_POSITIONS)
785
+ if v is not None:
786
+ o.n_position = v
787
+ if data.get('n_format') is not None:
788
+ o.n_format = _check_n_format(data['n_format'],
789
+ f"{ctx}.n_format")
790
+ o.n_size_pt = _style_num(data, 'n_size_pt', ctx, lo=0, hi=72,
791
+ lo_exclusive=True)
792
+ if data.get('n_color') is not None:
793
+ o.n_color = _check_color(data['n_color'],
794
+ f"{ctx}.n_color")
795
+ return o
796
+
797
+
798
+ @dataclass
799
+ class RidgeOptions:
800
+ offset: 'float | None' = None # absent → max series range × 1.05
801
+ fill_alpha: float = 0.22
802
+ reverse: bool = False
803
+ baseline_color: str = '#444444'
804
+ baseline_width_pt: float = 0.6
805
+ labels: bool = True
806
+
807
+ _KEYS = ('offset', 'fill_alpha', 'reverse', 'baseline_color',
808
+ 'baseline_width_pt', 'labels')
809
+
810
+ def to_dict(self) -> dict:
811
+ d = {}
812
+ if self.offset is not None:
813
+ d['offset'] = self.offset
814
+ if self.fill_alpha != 0.22:
815
+ d['fill_alpha'] = self.fill_alpha
816
+ if self.reverse:
817
+ d['reverse'] = True
818
+ if self.baseline_color != '#444444':
819
+ d['baseline_color'] = self.baseline_color
820
+ if self.baseline_width_pt != 0.6:
821
+ d['baseline_width_pt'] = self.baseline_width_pt
822
+ if not self.labels:
823
+ d['labels'] = False
824
+ return d
825
+
826
+ @classmethod
827
+ def from_dict(cls, data, ctx='document.ridgeline'):
828
+ data = _style_dict(data, ctx)
829
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
830
+ o = cls()
831
+ o.offset = _style_num(data, 'offset', ctx, lo=0,
832
+ lo_exclusive=True)
833
+ v = _style_num(data, 'fill_alpha', ctx, lo=0, hi=1)
834
+ if v is not None:
835
+ o.fill_alpha = v
836
+ o.reverse = _style_bool(data, 'reverse', ctx)
837
+ if data.get('baseline_color') is not None:
838
+ o.baseline_color = _check_color(data['baseline_color'],
839
+ f"{ctx}.baseline_color")
840
+ v = _style_num(data, 'baseline_width_pt', ctx, lo=0, hi=10)
841
+ if v is not None:
842
+ o.baseline_width_pt = v
843
+ if data.get('labels') is not None:
844
+ if not isinstance(data['labels'], bool):
845
+ raise _err(f"{ctx}.labels: expected true/false, "
846
+ f"got {data['labels']!r}")
847
+ o.labels = data['labels']
848
+ return o
849
+
850
+
851
+ @dataclass
852
+ class StackOptions:
853
+ percent: bool = True
854
+ grouped: bool = False # side-by-side columns, not stacked
855
+ bar_width: float = 0.8
856
+ show_values: bool = False
857
+ value_threshold: float = 5.0
858
+ value_decimals: int = 1
859
+ value_color: str = '#ffffff'
860
+ value_bold: bool = True
861
+ value_size_pt: 'float | None' = None # absent → font_size_pt × 6/7
862
+ edge_color: str = '#ffffff'
863
+ edge_width_pt: float = 0.0
864
+ horizontal: bool = False # bars run left-to-right
865
+
866
+ _KEYS = ('percent', 'grouped', 'bar_width', 'show_values',
867
+ 'value_threshold', 'value_decimals', 'value_color',
868
+ 'value_bold', 'value_size_pt', 'edge_color',
869
+ 'edge_width_pt', 'horizontal')
870
+
871
+ def to_dict(self) -> dict:
872
+ d = {}
873
+ if not self.percent:
874
+ d['percent'] = False
875
+ if self.grouped:
876
+ d['grouped'] = True
877
+ if self.bar_width != 0.8:
878
+ d['bar_width'] = self.bar_width
879
+ if self.show_values:
880
+ d['show_values'] = True
881
+ if self.value_threshold != 5.0:
882
+ d['value_threshold'] = self.value_threshold
883
+ if self.value_decimals != 1:
884
+ d['value_decimals'] = self.value_decimals
885
+ if self.value_color != '#ffffff':
886
+ d['value_color'] = self.value_color
887
+ if not self.value_bold:
888
+ d['value_bold'] = False
889
+ if self.value_size_pt is not None:
890
+ d['value_size_pt'] = self.value_size_pt
891
+ if self.edge_color != '#ffffff':
892
+ d['edge_color'] = self.edge_color
893
+ if self.edge_width_pt != 0.0:
894
+ d['edge_width_pt'] = self.edge_width_pt
895
+ if self.horizontal:
896
+ d['horizontal'] = True
897
+ return d
898
+
899
+ @classmethod
900
+ def from_dict(cls, data, ctx='document.stacked'):
901
+ data = _style_dict(data, ctx)
902
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
903
+ o = cls()
904
+ for key in ('percent', 'grouped', 'show_values', 'value_bold',
905
+ 'horizontal'):
906
+ v = data.get(key)
907
+ if v is not None:
908
+ if not isinstance(v, bool):
909
+ raise _err(f"{ctx}.{key}: expected true/false, "
910
+ f"got {v!r}")
911
+ setattr(o, key, v)
912
+ if o.grouped and o.percent:
913
+ raise _err(f"{ctx}: grouped columns cannot be "
914
+ "percent-stacked")
915
+ v = _style_num(data, 'bar_width', ctx, lo=0, hi=1,
916
+ lo_exclusive=True)
917
+ if v is not None:
918
+ o.bar_width = v
919
+ v = _style_num(data, 'value_threshold', ctx, lo=0, hi=100)
920
+ if v is not None:
921
+ o.value_threshold = v
922
+ dec = data.get('value_decimals')
923
+ if dec is not None:
924
+ if not isinstance(dec, int) or isinstance(dec, bool) \
925
+ or not (0 <= dec <= 4):
926
+ raise _err(f"{ctx}.value_decimals: expected an integer "
927
+ f"in 0..4, got {dec!r}")
928
+ o.value_decimals = dec
929
+ for key in ('value_color', 'edge_color'):
930
+ if data.get(key) is not None:
931
+ setattr(o, key, _check_color(data[key],
932
+ f"{ctx}.{key}"))
933
+ v = _style_num(data, 'value_size_pt', ctx, lo=0, hi=200,
934
+ lo_exclusive=True)
935
+ if v is not None:
936
+ o.value_size_pt = v
937
+ v = _style_num(data, 'edge_width_pt', ctx, lo=0, hi=10)
938
+ if v is not None:
939
+ o.edge_width_pt = v
940
+ return o
941
+
942
+
943
+ @dataclass
944
+ class HistOptions:
945
+ bins: 'int | None' = None # 1..1000; None → auto
946
+ bin_width: 'float | None' = None # data units; exclusive with bins
947
+ density: bool = False # normalise counts to density
948
+ style: str = 'bars' # 'bars' | 'step'
949
+ fill_alpha: float = 0.5
950
+ edge_color: 'str | None' = None # absent → the group's colour
951
+ edge_width_pt: float = 0.0 # 0 → no border
952
+ kde: bool = False # overlay a KDE curve
953
+ bandwidth: object = 'scott' # 'scott' | 'silverman' | float > 0
954
+ show_n: bool = False # sample size in the legend label
955
+ n_format: str = 'n = {n}'
956
+
957
+ _KEYS = ('bins', 'bin_width', 'density', 'style', 'fill_alpha',
958
+ 'edge_color', 'edge_width_pt', 'kde', 'bandwidth',
959
+ 'show_n', 'n_format')
960
+
961
+ def to_dict(self) -> dict:
962
+ d = {}
963
+ if self.bins is not None:
964
+ d['bins'] = self.bins
965
+ if self.bin_width is not None:
966
+ d['bin_width'] = self.bin_width
967
+ if self.density:
968
+ d['density'] = True
969
+ if self.style != 'bars':
970
+ d['style'] = self.style
971
+ if self.fill_alpha != 0.5:
972
+ d['fill_alpha'] = self.fill_alpha
973
+ if self.edge_color is not None:
974
+ d['edge_color'] = self.edge_color
975
+ if self.edge_width_pt != 0.0:
976
+ d['edge_width_pt'] = self.edge_width_pt
977
+ if self.kde:
978
+ d['kde'] = True
979
+ if self.bandwidth != 'scott':
980
+ d['bandwidth'] = self.bandwidth
981
+ if self.show_n:
982
+ d['show_n'] = True
983
+ if self.n_format != 'n = {n}':
984
+ d['n_format'] = self.n_format
985
+ return d
986
+
987
+ @classmethod
988
+ def from_dict(cls, data, ctx='document.histogram'):
989
+ data = _style_dict(data, ctx)
990
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
991
+ o = cls()
992
+ bins = data.get('bins')
993
+ if bins is not None:
994
+ if not isinstance(bins, int) or isinstance(bins, bool) \
995
+ or not (1 <= bins <= 1000):
996
+ raise _err(f"{ctx}.bins: expected an integer in "
997
+ f"1..1000, got {bins!r}")
998
+ o.bins = bins
999
+ o.bin_width = _style_num(data, 'bin_width', ctx, lo=0,
1000
+ lo_exclusive=True)
1001
+ if o.bins is not None and o.bin_width is not None:
1002
+ raise _err(f"{ctx}: bins and bin_width are exclusive")
1003
+ for key in ('density', 'kde', 'show_n'):
1004
+ v = data.get(key)
1005
+ if v is not None:
1006
+ if not isinstance(v, bool):
1007
+ raise _err(f"{ctx}.{key}: expected true/false, "
1008
+ f"got {v!r}")
1009
+ setattr(o, key, v)
1010
+ v = _style_choice(data, 'style', ctx, _HIST_STYLES)
1011
+ if v is not None:
1012
+ o.style = v
1013
+ v = _style_num(data, 'fill_alpha', ctx, lo=0, hi=1)
1014
+ if v is not None:
1015
+ o.fill_alpha = v
1016
+ if data.get('edge_color') is not None:
1017
+ o.edge_color = _check_color(data['edge_color'],
1018
+ f"{ctx}.edge_color")
1019
+ v = _style_num(data, 'edge_width_pt', ctx, lo=0, hi=10)
1020
+ if v is not None:
1021
+ o.edge_width_pt = v
1022
+ bw = data.get('bandwidth')
1023
+ if bw is not None:
1024
+ if isinstance(bw, str):
1025
+ if bw not in _BANDWIDTHS:
1026
+ raise _err(f"{ctx}.bandwidth: unsupported {bw!r}")
1027
+ o.bandwidth = bw
1028
+ elif _is_num(bw) and math.isfinite(bw) and bw > 0:
1029
+ o.bandwidth = float(bw)
1030
+ else:
1031
+ raise _err(f"{ctx}.bandwidth: expected scott/silverman "
1032
+ f"or a positive finite number, got {bw!r}")
1033
+ if data.get('n_format') is not None:
1034
+ o.n_format = _check_n_format(data['n_format'],
1035
+ f"{ctx}.n_format")
1036
+ return o
1037
+
1038
+
1039
+ # ── annotations / brackets ──────────────────────────────────────────
1040
+
1041
+ @dataclass
1042
+ class Annotation:
1043
+ """A text note placed in axes coordinates (NCPlot's ``ur_note``).
1044
+
1045
+ When ``x``/``y`` are both set (axes fractions, -0.5..1.5), the note
1046
+ is a floating centred text at that point and ``anchor`` is ignored;
1047
+ otherwise the corner-anchored behaviour applies.
1048
+ """
1049
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1050
+ text: str = ''
1051
+ anchor: str = 'upper right'
1052
+ style: 'TextStyle | None' = None
1053
+ box: bool = False
1054
+ x: 'float | None' = None
1055
+ y: 'float | None' = None
1056
+
1057
+ _KEYS = ('id', 'text', 'anchor', 'style', 'box', 'x', 'y')
1058
+
1059
+ def to_dict(self) -> dict:
1060
+ d = {'id': self.id, 'text': self.text, 'anchor': self.anchor}
1061
+ if self.style is not None:
1062
+ sd = self.style.to_dict()
1063
+ if sd:
1064
+ d['style'] = sd
1065
+ if self.box:
1066
+ d['box'] = True
1067
+ if self.x is not None:
1068
+ d['x'] = self.x
1069
+ d['y'] = self.y
1070
+ return d
1071
+
1072
+ @classmethod
1073
+ def from_dict(cls, data, ctx):
1074
+ data = _style_dict(data, ctx)
1075
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
1076
+ a = cls()
1077
+ sid = data.get('id')
1078
+ if not isinstance(sid, str) or not sid:
1079
+ raise _err(f"{ctx}.id: expected a non-empty string")
1080
+ a.id = sid
1081
+ text = data.get('text')
1082
+ if not isinstance(text, str) or not (1 <= len(text) <= 500):
1083
+ raise _err(f"{ctx}.text: expected 1..500 characters")
1084
+ a.text = text
1085
+ anchor = data.get('anchor')
1086
+ if anchor not in _ANCHORS:
1087
+ raise _err(f"{ctx}.anchor: unsupported {anchor!r}")
1088
+ a.anchor = anchor
1089
+ if data.get('style') is not None:
1090
+ a.style = TextStyle.from_dict(data['style'], f"{ctx}.style")
1091
+ a.box = _style_bool(data, 'box', ctx)
1092
+ x, y = data.get('x'), data.get('y')
1093
+ if (x is None) != (y is None):
1094
+ raise _err(f"{ctx}: x and y must appear together")
1095
+ if x is not None:
1096
+ for key, v in (('x', x), ('y', y)):
1097
+ if not _is_num(v) or not math.isfinite(v) \
1098
+ or not (-0.5 <= v <= 1.5):
1099
+ raise _err(f"{ctx}.{key}: expected a finite number "
1100
+ f"in [-0.5, 1.5], got {v!r}")
1101
+ a.x, a.y = float(x), float(y)
1102
+ return a
1103
+
1104
+
1105
+ @dataclass
1106
+ class Bracket:
1107
+ """A significance bracket between two positions (NCPlot style).
1108
+
1109
+ ``a``/``b`` are group/bar indices (int ≥ 0) for violin and
1110
+ stacked_column, or x positions (finite floats) for line/ridgeline.
1111
+ """
1112
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1113
+ a: float = 0.0
1114
+ b: float = 0.0
1115
+ text: str = '*'
1116
+ offset: float = 0.05
1117
+ style: 'TextStyle | None' = None
1118
+
1119
+ _KEYS = ('id', 'a', 'b', 'text', 'offset', 'style')
1120
+
1121
+ def to_dict(self) -> dict:
1122
+ d = {'id': self.id, 'a': self.a, 'b': self.b,
1123
+ 'text': self.text}
1124
+ if self.offset != 0.05:
1125
+ d['offset'] = self.offset
1126
+ if self.style is not None:
1127
+ sd = self.style.to_dict()
1128
+ if sd:
1129
+ d['style'] = sd
1130
+ return d
1131
+
1132
+ @classmethod
1133
+ def from_dict(cls, data, ctx):
1134
+ data = _style_dict(data, ctx)
1135
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
1136
+ br = cls()
1137
+ sid = data.get('id')
1138
+ if not isinstance(sid, str) or not sid:
1139
+ raise _err(f"{ctx}.id: expected a non-empty string")
1140
+ br.id = sid
1141
+ for key in ('a', 'b'):
1142
+ v = data.get(key)
1143
+ if not _is_num(v) or not math.isfinite(v) or v < 0:
1144
+ raise _err(f"{ctx}.{key}: expected a finite number "
1145
+ f">= 0, got {v!r}")
1146
+ setattr(br, key, float(v))
1147
+ if br.a == br.b:
1148
+ raise _err(f"{ctx}: a and b must differ")
1149
+ text = data.get('text', '*')
1150
+ if not isinstance(text, str) or len(text) > 50:
1151
+ raise _err(f"{ctx}.text: expected a string of at most 50 "
1152
+ f"characters")
1153
+ br.text = text
1154
+ v = _style_num(data, 'offset', ctx, lo=0, hi=1)
1155
+ if v is not None:
1156
+ br.offset = v
1157
+ if data.get('style') is not None:
1158
+ br.style = TextStyle.from_dict(data['style'],
1159
+ f"{ctx}.style")
1160
+ return br
1161
+
1162
+
1163
+ @dataclass
1164
+ class Band:
1165
+ """A filled region between two y boundaries over shared x values
1166
+ (matplotlib ``fill_between``: mean±SD bands, confidence intervals,
1167
+ area between two curves). Drawn under the series, before them.
1168
+ """
1169
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1170
+ label: str = ''
1171
+ x: list = field(default_factory=lambda: [0., 1.])
1172
+ y1: list = field(default_factory=lambda: [0., 1.])
1173
+ y2: list = field(default_factory=lambda: [1., 2.])
1174
+ color: str = '#1f77b44d'
1175
+
1176
+ _KEYS = ('id', 'label', 'x', 'y1', 'y2', 'color')
1177
+
1178
+ def to_dict(self) -> dict:
1179
+ return {'id': self.id, 'label': self.label,
1180
+ 'x': [float(v) for v in self.x],
1181
+ 'y1': [float(v) for v in self.y1],
1182
+ 'y2': [float(v) for v in self.y2],
1183
+ 'color': self.color}
1184
+
1185
+ @classmethod
1186
+ def from_dict(cls, data, ctx: str = 'bands') -> 'Band':
1187
+ if not isinstance(data, dict):
1188
+ raise _err(f"{ctx}: expected an object, got "
1189
+ f"{type(data).__name__}")
1190
+ unknown = sorted(set(data) - set(cls._KEYS))
1191
+ if unknown:
1192
+ raise _err(f"{ctx}: unknown field(s) {unknown} — file may "
1193
+ "need a newer version")
1194
+ b = cls()
1195
+ sid = data.get('id')
1196
+ if not isinstance(sid, str) or not sid:
1197
+ raise _err(f"{ctx}.id: expected a non-empty string")
1198
+ b.id = sid
1199
+ b.label = _req_str(data, 'label', ctx)
1200
+ arrays = {}
1201
+ for name in ('x', 'y1', 'y2'):
1202
+ arr = data.get(name)
1203
+ if not isinstance(arr, list) or len(arr) < 2:
1204
+ raise _err(f"{ctx}.{name}: expected an array of at "
1205
+ "least 2 numbers")
1206
+ if any(not _is_num(v) or not math.isfinite(v)
1207
+ for v in arr):
1208
+ raise _err(f"{ctx}.{name}: all values must be finite "
1209
+ "numbers")
1210
+ arrays[name] = [float(v) for v in arr]
1211
+ if len(arrays['y1']) != len(arrays['x']) \
1212
+ or len(arrays['y2']) != len(arrays['x']):
1213
+ raise _err(f"{ctx}: x, y1 and y2 must have the same "
1214
+ "length")
1215
+ b.x, b.y1, b.y2 = arrays['x'], arrays['y1'], arrays['y2']
1216
+ b.color = _check_color(data.get('color'), f"{ctx}.color")
1217
+ return b
1218
+
1219
+
1220
+ @dataclass
1221
+ class Span:
1222
+ """A full-height (``axis='x'``) or full-width (``axis='y'``) shaded
1223
+ strip over a data-axis range (matplotlib ``axvspan``/``axhspan``).
1224
+ Drawn under bands and series.
1225
+ """
1226
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1227
+ axis: str = 'x'
1228
+ lo: float = 0.0
1229
+ hi: float = 1.0
1230
+ color: str = '#7f7f7f40'
1231
+ label: str = ''
1232
+
1233
+ _KEYS = ('id', 'axis', 'lo', 'hi', 'color', 'label')
1234
+
1235
+ def to_dict(self) -> dict:
1236
+ return {'id': self.id, 'axis': self.axis,
1237
+ 'lo': float(self.lo), 'hi': float(self.hi),
1238
+ 'color': self.color, 'label': self.label}
1239
+
1240
+ @classmethod
1241
+ def from_dict(cls, data, ctx: str = 'spans') -> 'Span':
1242
+ if not isinstance(data, dict):
1243
+ raise _err(f"{ctx}: expected an object, got "
1244
+ f"{type(data).__name__}")
1245
+ unknown = sorted(set(data) - set(cls._KEYS))
1246
+ if unknown:
1247
+ raise _err(f"{ctx}: unknown field(s) {unknown} — file may "
1248
+ "need a newer version")
1249
+ sp = cls()
1250
+ sid = data.get('id')
1251
+ if not isinstance(sid, str) or not sid:
1252
+ raise _err(f"{ctx}.id: expected a non-empty string")
1253
+ sp.id = sid
1254
+ if data.get('axis') not in ('x', 'y'):
1255
+ raise _err(f"{ctx}.axis: expected 'x' or 'y'")
1256
+ sp.axis = data['axis']
1257
+ for name in ('lo', 'hi'):
1258
+ v = data.get(name)
1259
+ if not _is_num(v) or not math.isfinite(v):
1260
+ raise _err(f"{ctx}.{name}: expected a finite number")
1261
+ setattr(sp, name, float(v))
1262
+ if not sp.lo < sp.hi:
1263
+ raise _err(f"{ctx}: expected lo < hi")
1264
+ sp.color = _check_color(data.get('color'), f"{ctx}.color")
1265
+ sp.label = _req_str(data, 'label', ctx)
1266
+ return sp
1267
+
1268
+
1269
+ # ── kind data ─────────────────────────────────────────────────────────
1270
+
1271
+ @dataclass
1272
+ class ViolinGroup:
1273
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1274
+ label: str = 'Group 1'
1275
+ values: list = field(default_factory=lambda: [0., 1., 2.])
1276
+ color: str = '#0891b2'
1277
+
1278
+ def to_dict(self) -> dict:
1279
+ return {'id': self.id, 'label': self.label,
1280
+ 'values': [float(v) for v in self.values],
1281
+ 'color': self.color}
1282
+
1283
+ @classmethod
1284
+ def from_dict(cls, data, ctx: str) -> 'ViolinGroup':
1285
+ if not isinstance(data, dict):
1286
+ raise _err(f"{ctx}: expected an object, got "
1287
+ f"{type(data).__name__}")
1288
+ allowed = {f.name for f in fields(cls)}
1289
+ unknown = sorted(set(data) - allowed)
1290
+ if unknown:
1291
+ raise _err(f"{ctx}: unknown field(s) {unknown} — file may "
1292
+ "need a newer version")
1293
+ g = cls()
1294
+ sid = data.get('id')
1295
+ if not isinstance(sid, str) or not sid:
1296
+ raise _err(f"{ctx}.id: expected a non-empty string")
1297
+ g.id = sid
1298
+ g.label = _req_str(data, 'label', ctx)
1299
+ values = data.get('values')
1300
+ if not isinstance(values, list) or not values:
1301
+ raise _err(f"{ctx}.values: expected a non-empty array of "
1302
+ "numbers")
1303
+ if any(not _is_num(v) or not math.isfinite(v) for v in values):
1304
+ raise _err(f"{ctx}.values: all values must be finite "
1305
+ "numbers")
1306
+ g.values = [float(v) for v in values]
1307
+ g.color = _check_color(data.get('color'), f"{ctx}.color")
1308
+ return g
1309
+
1310
+
1311
+ @dataclass
1312
+ class StackCategory:
1313
+ """One stack segment series: ``values[i]`` is its height in bar i."""
1314
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1315
+ label: str = 'Category 1'
1316
+ values: list = field(default_factory=lambda: [1.])
1317
+ color: str = '#0891b2'
1318
+ yerr: list | None = None
1319
+ yerr_minus: list | None = None
1320
+ yerr_plus: list | None = None
1321
+
1322
+ def to_dict(self) -> dict:
1323
+ d = {'id': self.id, 'label': self.label,
1324
+ 'values': [float(v) for v in self.values],
1325
+ 'color': self.color}
1326
+ if self.yerr is not None:
1327
+ d['yerr'] = [float(v) for v in self.yerr]
1328
+ if self.yerr_minus is not None:
1329
+ d['yerr_minus'] = [float(v) for v in self.yerr_minus]
1330
+ if self.yerr_plus is not None:
1331
+ d['yerr_plus'] = [float(v) for v in self.yerr_plus]
1332
+ return d
1333
+
1334
+ @classmethod
1335
+ def from_dict(cls, data, ctx: str) -> 'StackCategory':
1336
+ if not isinstance(data, dict):
1337
+ raise _err(f"{ctx}: expected an object, got "
1338
+ f"{type(data).__name__}")
1339
+ allowed = {f.name for f in fields(cls)}
1340
+ unknown = sorted(set(data) - allowed)
1341
+ if unknown:
1342
+ raise _err(f"{ctx}: unknown field(s) {unknown} — file may "
1343
+ "need a newer version")
1344
+ c = cls()
1345
+ sid = data.get('id')
1346
+ if not isinstance(sid, str) or not sid:
1347
+ raise _err(f"{ctx}.id: expected a non-empty string")
1348
+ c.id = sid
1349
+ c.label = _req_str(data, 'label', ctx)
1350
+ values = data.get('values')
1351
+ if not isinstance(values, list) or not values:
1352
+ raise _err(f"{ctx}.values: expected a non-empty array of "
1353
+ "numbers")
1354
+ if any(not _is_num(v) or not math.isfinite(v) or v < 0
1355
+ for v in values):
1356
+ raise _err(f"{ctx}.values: all values must be finite "
1357
+ "numbers >= 0")
1358
+ c.values = [float(v) for v in values]
1359
+ c.color = _check_color(data.get('color'), f"{ctx}.color")
1360
+ c.yerr = _check_yerr(data.get('yerr'), len(c.values),
1361
+ f"{ctx}.yerr")
1362
+ c.yerr_minus = _check_yerr(data.get('yerr_minus'),
1363
+ len(c.values), f"{ctx}.yerr_minus")
1364
+ c.yerr_plus = _check_yerr(data.get('yerr_plus'),
1365
+ len(c.values), f"{ctx}.yerr_plus")
1366
+ _check_yerr_pair(c.yerr, c.yerr_minus, c.yerr_plus, ctx)
1367
+ return c
1368
+
1369
+
1370
+ @dataclass
1371
+ class PlotStyle:
1372
+ """Optional per-element styling; empty means "everything default"."""
1373
+ title: 'TitleStyle | None' = None
1374
+ xlabel: 'TextStyle | None' = None
1375
+ ylabel: 'TextStyle | None' = None
1376
+ xaxis: 'AxisStyle | None' = None
1377
+ yaxis: 'AxisStyle | None' = None
1378
+ grid: 'GridStyle | None' = None
1379
+ legend: 'LegendStyle | None' = None
1380
+ frame: 'FrameStyle | None' = None
1381
+
1382
+ _KEYS = ('title', 'xlabel', 'ylabel', 'xaxis', 'yaxis', 'grid',
1383
+ 'legend', 'frame')
1384
+ _TYPES = {'title': TitleStyle, 'xlabel': TextStyle,
1385
+ 'ylabel': TextStyle, 'xaxis': AxisStyle, 'yaxis': AxisStyle,
1386
+ 'grid': GridStyle, 'legend': LegendStyle,
1387
+ 'frame': FrameStyle}
1388
+
1389
+ def to_dict(self) -> dict:
1390
+ d = {}
1391
+ for name in self._KEYS:
1392
+ sub = getattr(self, name)
1393
+ if sub is not None:
1394
+ sd = sub.to_dict()
1395
+ if sd:
1396
+ d[name] = sd
1397
+ return d
1398
+
1399
+ @classmethod
1400
+ def from_dict(cls, data, ctx='document.style'):
1401
+ data = _style_dict(data, ctx)
1402
+ _unknown_style_keys(data, set(cls._KEYS), ctx)
1403
+ s = cls()
1404
+ for name, typ in cls._TYPES.items():
1405
+ v = data.get(name)
1406
+ if v is not None:
1407
+ setattr(s, name, typ.from_dict(v, f"{ctx}.{name}"))
1408
+ return s
1409
+
1410
+
1411
+ @dataclass
1412
+ class LineSeries:
1413
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
1414
+ label: str = 'Series 1'
1415
+ x: list = field(default_factory=lambda: [0., 1., 2., 3.])
1416
+ y: list = field(default_factory=lambda: [0., 1., .5, 1.5])
1417
+ color: str = '#0891b2'
1418
+ linewidth_pt: float = 1.5
1419
+ linestyle: str = '-'
1420
+ marker: str = ''
1421
+ markersize_pt: float = 6.
1422
+ yerr: list | None = None
1423
+ yerr_minus: list | None = None
1424
+ yerr_plus: list | None = None
1425
+ # 'bars' draws capless error bars; 'band' a shaded y±err band.
1426
+ error_style: str = 'bars'
1427
+ error_alpha: float = 0.3
1428
+
1429
+ def to_dict(self) -> dict:
1430
+ d = {
1431
+ 'id': self.id, 'label': self.label,
1432
+ 'x': list(self.x), 'y': list(self.y),
1433
+ 'color': self.color, 'linewidth_pt': self.linewidth_pt,
1434
+ 'linestyle': self.linestyle, 'marker': self.marker,
1435
+ 'markersize_pt': self.markersize_pt,
1436
+ }
1437
+ if self.yerr is not None:
1438
+ d['yerr'] = [float(v) for v in self.yerr]
1439
+ if self.yerr_minus is not None:
1440
+ d['yerr_minus'] = [float(v) for v in self.yerr_minus]
1441
+ if self.yerr_plus is not None:
1442
+ d['yerr_plus'] = [float(v) for v in self.yerr_plus]
1443
+ if self.error_style == 'band':
1444
+ d['error_style'] = self.error_style
1445
+ if self.error_alpha != 0.3:
1446
+ d['error_alpha'] = self.error_alpha
1447
+ return d
1448
+
1449
+ @classmethod
1450
+ def from_dict(cls, data, ctx: str = 'series') -> 'LineSeries':
1451
+ if not isinstance(data, dict):
1452
+ raise _err(f"{ctx}: expected an object, got {type(data).__name__}")
1453
+ allowed = {f.name for f in fields(cls)}
1454
+ unknown = sorted(set(data) - allowed)
1455
+ if unknown:
1456
+ raise _err(f"{ctx}: unknown field(s) {unknown} — file may need a newer version")
1457
+ s = cls()
1458
+ sid = data.get('id')
1459
+ if not isinstance(sid, str) or not sid:
1460
+ raise _err(f"{ctx}.id: expected a non-empty string")
1461
+ s.id = sid
1462
+ s.label = _req_str(data, 'label', ctx)
1463
+ x, y = data.get('x'), data.get('y')
1464
+ for name, arr in (('x', x), ('y', y)):
1465
+ if not isinstance(arr, list) or not arr:
1466
+ raise _err(f"{ctx}.{name}: expected a non-empty array of numbers")
1467
+ if any(not _is_num(v) or not math.isfinite(v) for v in arr):
1468
+ raise _err(f"{ctx}.{name}: all values must be finite numbers")
1469
+ if len(x) != len(y):
1470
+ raise _err(f"{ctx}: x and y must have the same length "
1471
+ f"({len(x)} != {len(y)})")
1472
+ s.x = [float(v) for v in x]
1473
+ s.y = [float(v) for v in y]
1474
+ s.color = _check_color(data.get('color'), f"{ctx}.color")
1475
+ s.linewidth_pt = _req_number(data, 'linewidth_pt', ctx,
1476
+ lo=0, hi=200, lo_exclusive=True)
1477
+ ls = data.get('linestyle')
1478
+ if ls not in LINESTYLES:
1479
+ raise _err(f"{ctx}.linestyle: unsupported {ls!r}; one of {LINESTYLES}")
1480
+ s.linestyle = ls
1481
+ mk = data.get('marker')
1482
+ if mk not in MARKERS:
1483
+ raise _err(f"{ctx}.marker: unsupported {mk!r}; one of {MARKERS}")
1484
+ s.marker = mk
1485
+ s.markersize_pt = _req_number(data, 'markersize_pt', ctx,
1486
+ lo=0, hi=200)
1487
+ s.yerr = _check_yerr(data.get('yerr'), len(s.y),
1488
+ f"{ctx}.yerr")
1489
+ s.yerr_minus = _check_yerr(data.get('yerr_minus'), len(s.y),
1490
+ f"{ctx}.yerr_minus")
1491
+ s.yerr_plus = _check_yerr(data.get('yerr_plus'), len(s.y),
1492
+ f"{ctx}.yerr_plus")
1493
+ _check_yerr_pair(s.yerr, s.yerr_minus, s.yerr_plus, ctx)
1494
+ es = data.get('error_style', 'bars')
1495
+ if es not in ('bars', 'band'):
1496
+ raise _err(f"{ctx}.error_style: unsupported {es!r}; "
1497
+ "one of ('bars', 'band')")
1498
+ s.error_style = es
1499
+ ea = data.get('error_alpha', 0.3)
1500
+ if not _is_num(ea) or not math.isfinite(ea) \
1501
+ or not (0.0 < float(ea) <= 1.0):
1502
+ raise _err(f"{ctx}.error_alpha: expected 0 < value <= 1, "
1503
+ f"got {ea!r}")
1504
+ s.error_alpha = float(ea)
1505
+ return s
1506
+
1507
+
1508
+ @dataclass
1509
+ class PlotDocument:
1510
+ schema_version: int = SCHEMA_VERSION
1511
+ kind: str = 'line'
1512
+ width_mm: float = 90.
1513
+ height_mm: float = 65.
1514
+ # normalized left, bottom, width, height (matplotlib add_axes rect)
1515
+ axes_rect: list = field(default_factory=lambda: [.18, .18, .76, .72])
1516
+ title: str = ''
1517
+ xlabel: str = 'X'
1518
+ ylabel: str = 'Y'
1519
+ font_family: str = 'DejaVu Sans'
1520
+ font_size_pt: float = 10.
1521
+ title_size_pt: float = 12.
1522
+ xlim: list | None = None
1523
+ ylim: list | None = None
1524
+ # Optional categorical x axis: [[position, label], ...]; None = numeric.
1525
+ x_tick_labels: list | None = None
1526
+ legend: bool = True
1527
+ legend_location: str = 'best'
1528
+ grid: bool = False
1529
+ series: list = field(default_factory=lambda: [LineSeries()])
1530
+ # 'violin' data; 'stacked_column' data; per-kind option objects.
1531
+ groups: list = field(default_factory=list)
1532
+ categories: list = field(default_factory=list)
1533
+ violin: 'ViolinOptions | None' = None
1534
+ ridgeline: 'RidgeOptions | None' = None
1535
+ stacked: 'StackOptions | None' = None
1536
+ histogram: 'HistOptions | None' = None
1537
+ annotations: list = field(default_factory=list)
1538
+ brackets: list = field(default_factory=list)
1539
+ bands: list = field(default_factory=list)
1540
+ spans: list = field(default_factory=list)
1541
+ style: 'PlotStyle' = field(default_factory=lambda: PlotStyle())
1542
+ # Optional-feature ids this document needs a reader to understand
1543
+ # (see CAPABILITIES); emitted only when non-empty.
1544
+ requires: list = field(default_factory=list)
1545
+
1546
+ # ── validation ──────────────────────────────────────────────────
1547
+ def validate(self) -> 'PlotDocument':
1548
+ ctx = 'document'
1549
+ if not isinstance(self.schema_version, int) \
1550
+ or isinstance(self.schema_version, bool):
1551
+ raise _err(f"{ctx}.schema_version: expected an integer, got "
1552
+ f"{self.schema_version!r}")
1553
+ if self.schema_version != SCHEMA_VERSION:
1554
+ raise _err(f"{ctx}.schema_version: unsupported {self.schema_version!r} "
1555
+ f"(this version supports {SCHEMA_VERSION})")
1556
+ if self.kind not in KINDS:
1557
+ raise _err(f"{ctx}.kind: unsupported {self.kind!r} "
1558
+ f"(one of {KINDS})")
1559
+ for name in ('width_mm', 'height_mm'):
1560
+ v = getattr(self, name)
1561
+ if not _is_num(v) or not math.isfinite(v) \
1562
+ or v <= 0 or v > MAX_DIMENSION_MM:
1563
+ raise _err(f"{ctx}.{name}: expected 0 < value <= "
1564
+ f"{MAX_DIMENSION_MM} mm, got {v!r}")
1565
+ rect = self.axes_rect
1566
+ if (not isinstance(rect, (list, tuple)) or len(rect) != 4
1567
+ or any(not _is_num(v) or not math.isfinite(v) for v in rect)):
1568
+ raise _err(f"{ctx}.axes_rect: expected [left, bottom, width, height] "
1569
+ f"of finite numbers in 0..1")
1570
+ l, b, w, h = (float(v) for v in rect)
1571
+ if not (0.0 <= l < 1.0 and 0.0 <= b < 1.0 and 0.0 < w <= 1.0
1572
+ and 0.0 < h <= 1.0 and l + w <= 1.0 and b + h <= 1.0):
1573
+ raise _err(f"{ctx}.axes_rect: {list(rect)} is outside the figure bounds")
1574
+ for name in ('title', 'xlabel', 'ylabel', 'font_family'):
1575
+ if not isinstance(getattr(self, name), str):
1576
+ raise _err(f"{ctx}.{name}: expected a string")
1577
+ if not self.font_family:
1578
+ raise _err(f"{ctx}.font_family: expected a non-empty string")
1579
+ for name in ('font_size_pt', 'title_size_pt'):
1580
+ v = getattr(self, name)
1581
+ if not _is_num(v) or not math.isfinite(v) or v <= 0 or v > 200:
1582
+ raise _err(f"{ctx}.{name}: expected 0 < value <= 200, got {v!r}")
1583
+ self.xlim = _check_limit(self.xlim, f"{ctx}.xlim")
1584
+ self.ylim = _check_limit(self.ylim, f"{ctx}.ylim")
1585
+ self.x_tick_labels = _check_tick_labels(
1586
+ self.x_tick_labels, f"{ctx}.x_tick_labels")
1587
+ if not isinstance(self.legend, bool) or not isinstance(self.grid, bool):
1588
+ raise _err(f"{ctx}.legend/grid: expected true/false")
1589
+ if self.legend_location not in LEGEND_LOCATIONS:
1590
+ raise _err(f"{ctx}.legend_location: unsupported "
1591
+ f"{self.legend_location!r}")
1592
+ owners = {'violin': 'violin', 'stacked': 'stacked_column',
1593
+ 'ridgeline': 'ridgeline', 'histogram': 'histogram'}
1594
+ if self.kind in ('line', 'ridgeline'):
1595
+ if not isinstance(self.series, list) or not self.series:
1596
+ raise _err(f"{ctx}.series: at least one series is "
1597
+ "required")
1598
+ for coll, coll_name in ((self.groups, 'groups'),
1599
+ (self.categories, 'categories')):
1600
+ if coll:
1601
+ raise _err(f"{ctx}.{coll_name}: must be empty for "
1602
+ f"kind {self.kind!r}")
1603
+ blocked = ('violin', 'stacked', 'histogram') \
1604
+ if self.kind == 'ridgeline' \
1605
+ else ('violin', 'stacked', 'ridgeline', 'histogram')
1606
+ for name in blocked:
1607
+ if getattr(self, name) is not None:
1608
+ raise _err(f"{ctx}.{name}: options only apply to "
1609
+ f"kind {owners[name]!r}")
1610
+ else:
1611
+ if self.series:
1612
+ raise _err(f"{ctx}.series: must be empty for kind "
1613
+ f"{self.kind!r}")
1614
+ if self.ridgeline is not None:
1615
+ raise _err(f"{ctx}.ridgeline: options only apply to "
1616
+ "kind 'ridgeline'")
1617
+ if self.kind == 'violin':
1618
+ if not self.groups:
1619
+ raise _err(f"{ctx}.groups: at least one group is "
1620
+ "required")
1621
+ if self.stacked is not None:
1622
+ raise _err(f"{ctx}.stacked: options only apply to "
1623
+ "kind 'stacked_column'")
1624
+ if self.histogram is not None:
1625
+ raise _err(f"{ctx}.histogram: options only apply to "
1626
+ "kind 'histogram'")
1627
+ if self.kind == 'stacked_column':
1628
+ if not self.categories:
1629
+ raise _err(f"{ctx}.categories: at least one category "
1630
+ "is required")
1631
+ if self.stacked is not None and self.stacked.grouped \
1632
+ and self.stacked.percent:
1633
+ raise _err(f"{ctx}.stacked: grouped columns cannot be "
1634
+ "percent-stacked")
1635
+ if self.violin is not None:
1636
+ raise _err(f"{ctx}.violin: options only apply to "
1637
+ "kind 'violin'")
1638
+ if self.histogram is not None:
1639
+ raise _err(f"{ctx}.histogram: options only apply to "
1640
+ "kind 'histogram'")
1641
+ if self.kind == 'histogram':
1642
+ if not self.groups:
1643
+ raise _err(f"{ctx}.groups: at least one group is "
1644
+ "required")
1645
+ if self.categories:
1646
+ raise _err(f"{ctx}.categories: must be empty for kind "
1647
+ "'histogram'")
1648
+ for name in ('violin', 'stacked', 'ridgeline'):
1649
+ if getattr(self, name) is not None:
1650
+ raise _err(f"{ctx}.{name}: options only apply to "
1651
+ f"kind '{owners[name]}'")
1652
+ if self.x_tick_labels is not None:
1653
+ raise _err(f"{ctx}.x_tick_labels: histogram x axis is "
1654
+ "numeric")
1655
+ if self.brackets:
1656
+ raise _err(f"{ctx}.brackets: brackets do not apply to "
1657
+ "kind 'histogram'")
1658
+ if len(self.series) > MAX_SERIES:
1659
+ raise _err(f"{ctx}.series: at most {MAX_SERIES} series")
1660
+ if len(self.groups) > MAX_SERIES:
1661
+ raise _err(f"{ctx}.groups: at most {MAX_SERIES} groups")
1662
+ if len(self.categories) > MAX_SERIES:
1663
+ raise _err(f"{ctx}.categories: at most {MAX_SERIES} "
1664
+ "categories")
1665
+ ids = set()
1666
+ total = 0
1667
+ for i, s in enumerate(self.series):
1668
+ if not isinstance(s, LineSeries):
1669
+ raise _err(f"{ctx}.series[{i}]: not a LineSeries")
1670
+ LineSeries.from_dict(s.to_dict(), ctx=f"series[{i}]")
1671
+ if s.id in ids:
1672
+ raise _err(f"{ctx}.series[{i}]: duplicate id {s.id!r}")
1673
+ ids.add(s.id)
1674
+ total += len(s.x)
1675
+ for i, g in enumerate(self.groups):
1676
+ if not isinstance(g, ViolinGroup):
1677
+ raise _err(f"{ctx}.groups[{i}]: not a ViolinGroup")
1678
+ ViolinGroup.from_dict(g.to_dict(), ctx=f"groups[{i}]")
1679
+ if g.id in ids:
1680
+ raise _err(f"{ctx}.groups[{i}]: duplicate id {g.id!r}")
1681
+ ids.add(g.id)
1682
+ total += len(g.values)
1683
+ n_bars = None
1684
+ for i, c in enumerate(self.categories):
1685
+ if not isinstance(c, StackCategory):
1686
+ raise _err(f"{ctx}.categories[{i}]: not a "
1687
+ "StackCategory")
1688
+ StackCategory.from_dict(c.to_dict(),
1689
+ ctx=f"categories[{i}]")
1690
+ if c.id in ids:
1691
+ raise _err(f"{ctx}.categories[{i}]: duplicate id "
1692
+ f"{c.id!r}")
1693
+ ids.add(c.id)
1694
+ total += len(c.values)
1695
+ if n_bars is None:
1696
+ n_bars = len(c.values)
1697
+ elif len(c.values) != n_bars:
1698
+ raise _err(f"{ctx}.categories[{i}].values: expected "
1699
+ f"{n_bars} values (all categories must "
1700
+ "have the same length)")
1701
+ if self.kind == 'stacked_column' and n_bars is not None \
1702
+ and self.x_tick_labels is not None:
1703
+ positions = sorted(p for p, _l in self.x_tick_labels)
1704
+ if positions != [float(i) for i in range(n_bars)]:
1705
+ raise _err(f"{ctx}.x_tick_labels: expected exactly "
1706
+ f"{n_bars} entries at positions 0.."
1707
+ f"{n_bars - 1}")
1708
+ if not isinstance(self.bands, list) \
1709
+ or len(self.bands) > MAX_BANDS:
1710
+ raise _err(f"{ctx}.bands: expected a list of at most "
1711
+ f"{MAX_BANDS} entries")
1712
+ if self.kind != 'line' and self.bands:
1713
+ raise _err(f"{ctx}.bands: bands only apply to kind 'line'")
1714
+ band_ids = set()
1715
+ for i, b in enumerate(self.bands):
1716
+ if not isinstance(b, Band):
1717
+ raise _err(f"{ctx}.bands[{i}]: not a Band")
1718
+ Band.from_dict(b.to_dict(), ctx=f"bands[{i}]")
1719
+ if b.id in band_ids:
1720
+ raise _err(f"{ctx}.bands[{i}]: duplicate id {b.id!r}")
1721
+ band_ids.add(b.id)
1722
+ total += len(b.x)
1723
+ if not isinstance(self.spans, list) \
1724
+ or len(self.spans) > MAX_SPANS:
1725
+ raise _err(f"{ctx}.spans: expected a list of at most "
1726
+ f"{MAX_SPANS} entries")
1727
+ if self.kind != 'line' and self.spans:
1728
+ raise _err(f"{ctx}.spans: spans only apply to kind 'line'")
1729
+ span_ids = set()
1730
+ for i, sp in enumerate(self.spans):
1731
+ if not isinstance(sp, Span):
1732
+ raise _err(f"{ctx}.spans[{i}]: not a Span")
1733
+ Span.from_dict(sp.to_dict(), ctx=f"spans[{i}]")
1734
+ if sp.id in span_ids:
1735
+ raise _err(f"{ctx}.spans[{i}]: duplicate id {sp.id!r}")
1736
+ span_ids.add(sp.id)
1737
+ if total > MAX_TOTAL_POINTS:
1738
+ raise _err(f"{ctx}: too many data points ({total} > {MAX_TOTAL_POINTS})")
1739
+ for name in ('violin', 'ridgeline', 'stacked', 'histogram'):
1740
+ opt = getattr(self, name)
1741
+ if opt is not None:
1742
+ typ = {'violin': ViolinOptions,
1743
+ 'ridgeline': RidgeOptions,
1744
+ 'stacked': StackOptions,
1745
+ 'histogram': HistOptions}[name]
1746
+ if not isinstance(opt, typ):
1747
+ raise _err(f"{ctx}.{name}: expected {typ.__name__}"
1748
+ f" or None")
1749
+ typ.from_dict(opt.to_dict(), f"{ctx}.{name}")
1750
+ for coll, coll_name, typ, limit in (
1751
+ (self.annotations, 'annotations', Annotation,
1752
+ MAX_ANNOTATIONS),
1753
+ (self.brackets, 'brackets', Bracket, MAX_BRACKETS)):
1754
+ if not isinstance(coll, list) or len(coll) > limit:
1755
+ raise _err(f"{ctx}.{coll_name}: expected a list of at "
1756
+ f"most {limit} entries")
1757
+ aids = set()
1758
+ for i, a in enumerate(coll):
1759
+ if not isinstance(a, typ):
1760
+ raise _err(f"{ctx}.{coll_name}[{i}]: not a "
1761
+ f"{typ.__name__}")
1762
+ typ.from_dict(a.to_dict(), ctx=f"{coll_name}[{i}]")
1763
+ if a.id in aids:
1764
+ raise _err(f"{ctx}.{coll_name}[{i}]: duplicate id "
1765
+ f"{a.id!r}")
1766
+ aids.add(a.id)
1767
+ for i, br in enumerate(self.brackets):
1768
+ index_kind = self.kind in ('violin', 'stacked_column')
1769
+ for key in ('a', 'b'):
1770
+ v = getattr(br, key)
1771
+ if index_kind and not float(v).is_integer():
1772
+ raise _err(f"{ctx}.brackets[{i}].{key}: expected "
1773
+ f"a group/bar index, got {v}")
1774
+ if index_kind:
1775
+ count = len(self.groups) if self.kind == 'violin' \
1776
+ else n_bars
1777
+ if count is not None and v >= count:
1778
+ raise _err(f"{ctx}.brackets[{i}].{key}: index "
1779
+ f"{int(v)} >= {count} items")
1780
+ if not isinstance(self.style, PlotStyle):
1781
+ raise _err(f"{ctx}.style: not a PlotStyle")
1782
+ # Round-trip validates every emitted key/value strictly.
1783
+ PlotStyle.from_dict(self.style.to_dict())
1784
+ return self
1785
+
1786
+ # ── serialization ───────────────────────────────────────────────
1787
+ def to_dict(self) -> dict:
1788
+ d = {
1789
+ 'format': FORMAT_NAME,
1790
+ 'schema_version': self.schema_version,
1791
+ 'kind': self.kind,
1792
+ 'width_mm': self.width_mm,
1793
+ 'height_mm': self.height_mm,
1794
+ 'axes_rect': list(self.axes_rect),
1795
+ 'title': self.title,
1796
+ 'xlabel': self.xlabel,
1797
+ 'ylabel': self.ylabel,
1798
+ 'font_family': self.font_family,
1799
+ 'font_size_pt': self.font_size_pt,
1800
+ 'title_size_pt': self.title_size_pt,
1801
+ 'xlim': list(self.xlim) if self.xlim is not None else None,
1802
+ 'ylim': list(self.ylim) if self.ylim is not None else None,
1803
+ 'legend': self.legend,
1804
+ 'legend_location': self.legend_location,
1805
+ 'grid': self.grid,
1806
+ 'series': [s.to_dict() for s in self.series],
1807
+ }
1808
+ # Optional field: emit only when set so files without it stay
1809
+ # byte-identical and parseable by older builds (unknown fields
1810
+ # are rejected by from_dict).
1811
+ if self.x_tick_labels is not None:
1812
+ d['x_tick_labels'] = [[p, l] for p, l in self.x_tick_labels]
1813
+ if self.groups:
1814
+ d['groups'] = [g.to_dict() for g in self.groups]
1815
+ if self.categories:
1816
+ d['categories'] = [c.to_dict() for c in self.categories]
1817
+ for name in ('violin', 'ridgeline', 'stacked', 'histogram'):
1818
+ opt = getattr(self, name)
1819
+ if opt is not None:
1820
+ od = opt.to_dict()
1821
+ if od:
1822
+ d[name] = od
1823
+ else:
1824
+ # An all-default options object still pins the kind's
1825
+ # rendering defaults when present.
1826
+ d[name] = {}
1827
+ if self.annotations:
1828
+ d['annotations'] = [a.to_dict() for a in self.annotations]
1829
+ if self.brackets:
1830
+ d['brackets'] = [b.to_dict() for b in self.brackets]
1831
+ if self.bands:
1832
+ d['bands'] = [b.to_dict() for b in self.bands]
1833
+ if self.spans:
1834
+ d['spans'] = [s.to_dict() for s in self.spans]
1835
+ style = self.style.to_dict() if self.style is not None else {}
1836
+ if style:
1837
+ d['style'] = style
1838
+ req = required_capabilities(self)
1839
+ if req:
1840
+ d['requires'] = req
1841
+ return d
1842
+
1843
+ @classmethod
1844
+ def from_dict(cls, data) -> 'PlotDocument':
1845
+ if not isinstance(data, dict):
1846
+ raise _err(f"plot document: expected a JSON object, got "
1847
+ f"{type(data).__name__}")
1848
+ if data.get('format') != FORMAT_NAME:
1849
+ raise _err(f"plot document: missing or wrong 'format' "
1850
+ f"(expected {FORMAT_NAME!r}, got {data.get('format')!r})")
1851
+ d = cls()
1852
+ sv = data.get('schema_version')
1853
+ if not isinstance(sv, int) or isinstance(sv, bool):
1854
+ raise _err(f"document.schema_version: expected an integer, got {sv!r}")
1855
+ d.schema_version = sv
1856
+ if sv > SCHEMA_VERSION:
1857
+ from .messages import text as tr
1858
+ raise PlotVersionError(
1859
+ tr('err_plot_newer_schema', found=sv,
1860
+ supported=SCHEMA_VERSION),
1861
+ schema_version=sv)
1862
+ d.requires = _check_requires(data.get('requires'),
1863
+ 'document.requires')
1864
+ missing = sorted(set(d.requires) - CAPABILITIES)
1865
+ if missing:
1866
+ from .messages import text as tr
1867
+ raise PlotVersionError(
1868
+ tr('err_plot_newer_features',
1869
+ features=', '.join(missing)),
1870
+ schema_version=sv, missing=missing)
1871
+ allowed = {f.name for f in fields(cls)} | {'format'}
1872
+ unknown = sorted(set(data) - allowed)
1873
+ if unknown:
1874
+ raise _err(f"plot document: unknown field(s) {unknown} — "
1875
+ f"file may need a newer version of the editor")
1876
+ kind = data.get('kind')
1877
+ if kind not in KINDS:
1878
+ raise _err(f"document.kind: unsupported {kind!r}")
1879
+ d.kind = kind
1880
+ d.width_mm = _req_number(data, 'width_mm', 'document',
1881
+ lo=0, hi=MAX_DIMENSION_MM, lo_exclusive=True)
1882
+ d.height_mm = _req_number(data, 'height_mm', 'document',
1883
+ lo=0, hi=MAX_DIMENSION_MM, lo_exclusive=True)
1884
+ rect = data.get('axes_rect')
1885
+ if not isinstance(rect, list) or len(rect) != 4 \
1886
+ or any(not _is_num(v) or not math.isfinite(v) for v in rect):
1887
+ raise _err("document.axes_rect: expected [left, bottom, width, height]")
1888
+ d.axes_rect = [float(v) for v in rect]
1889
+ d.title = _req_str(data, 'title', 'document')
1890
+ d.xlabel = _req_str(data, 'xlabel', 'document')
1891
+ d.ylabel = _req_str(data, 'ylabel', 'document')
1892
+ d.font_family = _req_str(data, 'font_family', 'document', allow_empty=False)
1893
+ d.font_size_pt = _req_number(data, 'font_size_pt', 'document',
1894
+ lo=0, hi=200, lo_exclusive=True)
1895
+ d.title_size_pt = _req_number(data, 'title_size_pt', 'document',
1896
+ lo=0, hi=200, lo_exclusive=True)
1897
+ d.xlim = _check_limit(data.get('xlim'), 'document.xlim')
1898
+ d.ylim = _check_limit(data.get('ylim'), 'document.ylim')
1899
+ d.x_tick_labels = _check_tick_labels(
1900
+ data.get('x_tick_labels'), 'document.x_tick_labels')
1901
+ d.legend = _req_bool(data, 'legend', 'document')
1902
+ loc = data.get('legend_location')
1903
+ if loc not in LEGEND_LOCATIONS:
1904
+ raise _err(f"document.legend_location: unsupported {loc!r}")
1905
+ d.legend_location = loc
1906
+ d.grid = _req_bool(data, 'grid', 'document')
1907
+ series = data.get('series')
1908
+ if not isinstance(series, list) or (not series
1909
+ and d.kind in ('line',
1910
+ 'ridgeline')):
1911
+ raise _err("document.series: expected a non-empty array")
1912
+ d.series = [LineSeries.from_dict(s, ctx=f"series[{i}]")
1913
+ for i, s in enumerate(series)]
1914
+ for name, typ in (('groups', ViolinGroup),
1915
+ ('categories', StackCategory)):
1916
+ coll = data.get(name)
1917
+ if coll is not None:
1918
+ if not isinstance(coll, list):
1919
+ raise _err(f"document.{name}: expected an array")
1920
+ setattr(d, name, [typ.from_dict(
1921
+ v, ctx=f"{name}[{i}]") for i, v in enumerate(coll)])
1922
+ for name, typ in (('violin', ViolinOptions),
1923
+ ('ridgeline', RidgeOptions),
1924
+ ('stacked', StackOptions),
1925
+ ('histogram', HistOptions)):
1926
+ opt = data.get(name)
1927
+ if opt is not None:
1928
+ setattr(d, name,
1929
+ typ.from_dict(opt, f'document.{name}'))
1930
+ for name, typ in (('annotations', Annotation),
1931
+ ('brackets', Bracket),
1932
+ ('bands', Band),
1933
+ ('spans', Span)):
1934
+ coll = data.get(name)
1935
+ if coll is not None:
1936
+ if not isinstance(coll, list):
1937
+ raise _err(f"document.{name}: expected an array")
1938
+ setattr(d, name, [typ.from_dict(
1939
+ v, ctx=f"{name}[{i}]") for i, v in enumerate(coll)])
1940
+ style = data.get('style')
1941
+ if style is not None:
1942
+ d.style = PlotStyle.from_dict(style)
1943
+ return d.validate()
1944
+
1945
+ def clone(self) -> 'PlotDocument':
1946
+ return copy.deepcopy(self)
1947
+
1948
+
1949
+ # ── SVG embedding / extraction ────────────────────────────────────────────
1950
+
1951
+ def embed_metadata(svg_bytes: bytes, document: PlotDocument) -> bytes:
1952
+ """Insert or replace the plot-document metadata node in *svg_bytes*."""
1953
+ payload = json.dumps(document.to_dict(), ensure_ascii=False,
1954
+ allow_nan=False, separators=(',', ':'))
1955
+ try:
1956
+ root = ET.fromstring(svg_bytes)
1957
+ except ET.ParseError as e:
1958
+ raise _err(f"cannot embed metadata in invalid SVG: {e}")
1959
+ ns = ''
1960
+ if root.tag.startswith('{'):
1961
+ ns = root.tag[:root.tag.index('}') + 1]
1962
+ # remove existing node(s)
1963
+ for meta in list(root.findall(f'{ns}metadata')) + list(root.findall('metadata')):
1964
+ if meta.get('id') == METADATA_ID:
1965
+ root.remove(meta)
1966
+ meta = ET.Element(f'{ns}metadata', {'id': METADATA_ID})
1967
+ meta.text = payload
1968
+ root.insert(0, meta)
1969
+ return ET.tostring(root, encoding='unicode').encode('utf-8')
1970
+
1971
+
1972
+ _ENTITY_RE = re.compile(rb'<!ENTITY', re.IGNORECASE)
1973
+
1974
+
1975
+ def _reject_entities(svg: bytes):
1976
+ """Refuse SVG input that declares entities (XXE hardening)."""
1977
+ if _ENTITY_RE.search(svg):
1978
+ from .messages import text as tr
1979
+ raise _err(tr('err_svg_entities'))
1980
+
1981
+
1982
+ def document_from_svg(svg: bytes) -> PlotDocument:
1983
+ """Parse the embedded document; raises PlotDocumentError if absent/bad."""
1984
+ if len(svg) > MAX_FILE_BYTES:
1985
+ raise _err(f"SVG too large (> {MAX_FILE_BYTES // (1024 * 1024)} MiB)")
1986
+ _reject_entities(svg)
1987
+ try:
1988
+ root = ET.fromstring(svg)
1989
+ except ET.ParseError as e:
1990
+ raise _err(f"not a valid SVG file: {e}")
1991
+ text = _find_metadata_text(root)
1992
+ if text is None:
1993
+ raise _err("no embedded plot document — this is a plain SVG "
1994
+ "(editable plots are saved as *.ilmplot.svg)")
1995
+ try:
1996
+ data = json.loads(text, parse_constant=_reject_json_constant)
1997
+ except json.JSONDecodeError as e:
1998
+ raise _err(f"embedded plot document is not valid JSON: {e}")
1999
+ return PlotDocument.from_dict(data)
2000
+
2001
+
2002
+ def _reject_json_constant(value: str):
2003
+ raise _err(f"embedded plot document contains non-finite value {value!r}")
2004
+
2005
+
2006
+ def _find_metadata_text(root) -> str | None:
2007
+ for elem in root.iter():
2008
+ local = elem.tag.rsplit('}', 1)[-1]
2009
+ if local == 'metadata' and elem.get('id') == METADATA_ID:
2010
+ return elem.text
2011
+ return None
2012
+
2013
+
2014
+ def load_document(path: str) -> PlotDocument:
2015
+ with open(path, 'rb') as fh:
2016
+ head = fh.read(MAX_FILE_BYTES + 1)
2017
+ if len(head) > MAX_FILE_BYTES:
2018
+ raise _err(f"file too large (> {MAX_FILE_BYTES // (1024 * 1024)} MiB)")
2019
+ return document_from_svg(head)
2020
+
2021
+
2022
+ # ── cheap presence detection (cached) ─────────────────────────────────────
2023
+
2024
+ _METADATA_MARKER = METADATA_ID.encode('utf-8')
2025
+ _detect_cache: dict = {}
2026
+ _DETECT_CACHE_MAX = 256
2027
+
2028
+
2029
+ def has_plot_metadata(path: str) -> bool:
2030
+ """True when *path* is an SVG that carries an ilm-plot metadata node.
2031
+
2032
+ Returns True even when the embedded payload is malformed or from a
2033
+ future schema version, so the editor can open and explain the problem.
2034
+ """
2035
+ try:
2036
+ st = os.stat(path)
2037
+ except OSError:
2038
+ return False
2039
+ key = os.path.abspath(path)
2040
+ sig = (st.st_size, st.st_mtime_ns)
2041
+ hit = _detect_cache.get(key)
2042
+ if hit is not None and hit[0] == sig:
2043
+ return hit[1]
2044
+ result = False
2045
+ if st.st_size <= MAX_FILE_BYTES:
2046
+ try:
2047
+ with open(path, 'rb') as fh:
2048
+ # bounded scan: matplotlib emits metadata near the end;
2049
+ # scan the whole (bounded) file to be safe.
2050
+ blob = fh.read()
2051
+ result = _METADATA_MARKER in blob \
2052
+ and not _ENTITY_RE.search(blob)
2053
+ except OSError:
2054
+ result = False
2055
+ if len(_detect_cache) >= _DETECT_CACHE_MAX:
2056
+ _detect_cache.clear()
2057
+ _detect_cache[key] = (sig, result)
2058
+ return result
2059
+
2060
+
2061
+ def document_from_metadata_text(text: str) -> PlotDocument:
2062
+ """Parse raw JSON payload text (test/diagnostic helper)."""
2063
+ return PlotDocument.from_dict(
2064
+ json.loads(text, parse_constant=_reject_json_constant))