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/__init__.py +47 -0
- ilmplot/bridge.py +1469 -0
- ilmplot/document.py +2064 -0
- ilmplot/mathtext.py +69 -0
- ilmplot/messages.py +40 -0
- ilmplot/render.py +2574 -0
- ilmplot/stats.py +103 -0
- ilmplot-0.1.0.dist-info/METADATA +127 -0
- ilmplot-0.1.0.dist-info/RECORD +12 -0
- ilmplot-0.1.0.dist-info/WHEEL +5 -0
- ilmplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- ilmplot-0.1.0.dist-info/top_level.txt +1 -0
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))
|