@zumer/snapdom-plugins 3.0.0 → 4.0.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +22 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,985 @@
1
+ /**
2
+ * SVD schema validator — strict by design.
3
+ *
4
+ * It rejects; it never repairs. A repaired document is a document whose bug
5
+ * ships, and the whole point of SVD is that every loss is declared. The
6
+ * orchestrator runs `validate` on its own output and throws, so a false
7
+ * *negative* here is a silently broken export while a false *positive* is a
8
+ * loud crash during development — the asymmetry is deliberate.
9
+ *
10
+ * What it checks, in three layers:
11
+ *
12
+ * 1. Per node, locally (`validateNode`): shape, enums, ranges, and the text
13
+ * offsets, which are self-contained because they index `text.characters`.
14
+ * 2. Across the document (`validate`): every id in `children` resolves, the
15
+ * graph is a tree rooted at `root` — no cycles, no second parents, no
16
+ * unreachable islands — and every `assets` / `styles` / node reference
17
+ * lands on something that exists.
18
+ * 3. The rule that outranks the rest of the schema (index.js invariant 9):
19
+ * **every node whose `fidelity.grade` is not `E` is named by an entry in
20
+ * `diagnostics`.** A degradation nobody declared is the failure mode this
21
+ * format exists to prevent.
22
+ *
23
+ * Fields the contract fixes are required. Fields it leaves open are
24
+ * type-checked when present and never invented: this module must not decide,
25
+ * by rejecting, what a collector is allowed to emit.
26
+ *
27
+ * No DOM, no dependencies — the harness fuzzes it from Node.
28
+ */
29
+
30
+ // index.js re-exports validate/validateNode, so this import closes a cycle.
31
+ // It is safe *only* because nothing here dereferences these bindings at module
32
+ // scope: by the time any exported function runs, index.js has finished
33
+ // evaluating. Do not hoist them into a top-level `new Set(...)`.
34
+ import { SVD_VERSION, NODE_TYPES, FIDELITY } from './index.js'
35
+
36
+ const PAINT_TYPES = ['solid', 'linear', 'radial', 'angular', 'image']
37
+ const GRADIENT_TYPES = ['linear', 'radial', 'angular']
38
+ const GRADIENT_SHAPES = ['circle', 'ellipse']
39
+ const EFFECT_TYPES = ['dropShadow', 'innerShadow', 'layerBlur', 'backgroundBlur', 'colorMatrix']
40
+ const SHADOW_TYPES = ['dropShadow', 'innerShadow']
41
+ /** `feColorMatrix type="matrix"` is four rows of five, row-major. */
42
+ const COLOR_MATRIX_LEN = 20
43
+ const STROKE_ALIGNS = ['inside', 'center', 'outside']
44
+ const CLIP_MODES = ['rect', 'path']
45
+ const BOXES = ['border', 'padding', 'content', 'text']
46
+ const MASK_TYPES = ['alpha', 'luminance']
47
+ const DIRECTIONS = ['ltr', 'rtl']
48
+ const SEVERITIES = ['info', 'warn', 'error']
49
+
50
+ /** CSS `mix-blend-mode`, verbatim: a typo here is a silent visual change. */
51
+ const BLEND_MODES = [
52
+ 'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
53
+ 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference',
54
+ 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'plus-lighter',
55
+ ]
56
+
57
+ /** The root's `abs` is relative to itself, so its origin is 0 up to float noise. */
58
+ const ORIGIN_EPS = 1e-6
59
+
60
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
61
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
62
+ const isId = (v) => typeof v === 'string' && v.length > 0
63
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
64
+ const quote = (v) => JSON.stringify(String(v))
65
+
66
+ /** Short, unambiguous rendering of a bad value — errors are read, not parsed. */
67
+ function show (v) {
68
+ if (v === undefined) return 'undefined'
69
+ if (v === null) return 'null'
70
+ if (typeof v === 'string') return JSON.stringify(v.length > 48 ? `${v.slice(0, 48)}…` : v)
71
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
72
+ if (Array.isArray(v)) return `array(${v.length})`
73
+ if (typeof v === 'function') return 'function'
74
+ return 'object'
75
+ }
76
+
77
+ const oneOf = (list) => list.map(quote).join(' | ')
78
+
79
+ // ——— field checkers ———
80
+ //
81
+ // Each takes the value, the field path, and `at(path, message)`. Paths are
82
+ // absolute within the node so an error can be pasted into a debugger.
83
+
84
+ /** Invariant 4: colors are [r,g,b,a] floats 0..1 in sRGB. */
85
+ function checkColor (v, path, at) {
86
+ if (!Array.isArray(v) || v.length !== 4) {
87
+ at(path, `must be an [r,g,b,a] array of 4 floats in 0..1 (got ${show(v)})`)
88
+ return
89
+ }
90
+ for (let i = 0; i < 4; i++) {
91
+ const c = v[i]
92
+ if (!isNum(c)) at(`${path}[${i}]`, `must be a finite number (got ${show(c)})`)
93
+ else if (c < 0 || c > 1) at(`${path}[${i}]`, `must be in 0..1 (got ${c})`)
94
+ }
95
+ }
96
+
97
+ function checkRect (v, path, at) {
98
+ if (!isObject(v)) {
99
+ at(path, `must be an object {x, y, w, h} in CSS px (got ${show(v)})`)
100
+ return
101
+ }
102
+ for (const k of ['x', 'y', 'w', 'h']) {
103
+ if (!isNum(v[k])) at(`${path}.${k}`, `must be a finite number (got ${show(v[k])})`)
104
+ }
105
+ if (isNum(v.w) && v.w < 0) at(`${path}.w`, `must be >= 0 (got ${v.w})`)
106
+ if (isNum(v.h) && v.h < 0) at(`${path}.h`, `must be >= 0 (got ${v.h})`)
107
+ }
108
+
109
+ function checkMatrix (v, path, at) {
110
+ if (!Array.isArray(v) || v.length !== 6 || !v.every(isNum)) {
111
+ at(path, `must be a 2x3 matrix [a,b,c,d,e,f] of finite numbers (got ${show(v)})`)
112
+ }
113
+ }
114
+
115
+ /** tl, tr, br, bl — each an [rx, ry] pair, overlap factor already applied. */
116
+ function checkRadii (v, path, at) {
117
+ if (!Array.isArray(v) || v.length !== 4) {
118
+ at(path, `must be 4 corner pairs [tl, tr, br, bl] (got ${show(v)})`)
119
+ return
120
+ }
121
+ v.forEach((corner, i) => {
122
+ if (!Array.isArray(corner) || corner.length !== 2 ||
123
+ !corner.every((n) => isNum(n) && n >= 0)) {
124
+ at(`${path}[${i}]`, `must be [rx, ry], both finite and >= 0 (got ${show(corner)})`)
125
+ }
126
+ })
127
+ }
128
+
129
+ /** A point in the gradient's unit square, `[x, y]`. */
130
+ function checkUnitPoint (v, path, at) {
131
+ if (!Array.isArray(v) || v.length !== 2 || !v.every(isNum)) {
132
+ at(path, `must be [x, y] in the unit square (got ${show(v)})`)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * `unit` keys, fixed per gradient type by the contract.
138
+ *
139
+ * They are checked because a consumer that probes for a key nobody emits does
140
+ * not crash: it takes its default and paints something plausible and wrong.
141
+ * That is exactly how every linear gradient once shipped flattened to
142
+ * top-to-bottom — three consumers each invented a probe list (`from||start||p0`)
143
+ * and the one that guessed `from` alone silently lost the gradient direction on
144
+ * every layer. A key that is missing has to fail HERE, on the machine that
145
+ * produced the document, not on the screen of whoever opens it.
146
+ *
147
+ * linear {p0, p1} gradient line endpoints
148
+ * radial {center, radius, shape} shape is 'circle' | 'ellipse'
149
+ * angular {center, startAngle} degrees CW from 12 o'clock
150
+ *
151
+ * Extra keys are allowed — `linear.angle` is carried for readability and costs
152
+ * nothing. Only the absence of a normative key is an error.
153
+ */
154
+ function checkUnit (p, path, at) {
155
+ const u = p.unit
156
+ if (!isObject(u)) {
157
+ at(path, `must be the gradient geometry in the unit square (got ${show(u)})`)
158
+ return
159
+ }
160
+ if (p.type === 'linear') {
161
+ checkUnitPoint(u.p0, `${path}.p0`, at)
162
+ checkUnitPoint(u.p1, `${path}.p1`, at)
163
+ return
164
+ }
165
+ if (p.type === 'radial') {
166
+ checkUnitPoint(u.center, `${path}.center`, at)
167
+ // The contract spells the radius `[rx, ry]`; `css/gradient.js` still emits
168
+ // the scalar it normalizes anisotropy out of (the ellipse is absorbed into
169
+ // `transform`, so one number is enough). Both are accepted, and the day the
170
+ // collector emits the pair this stops being a divergence worth a comment.
171
+ const r = u.radius
172
+ const okScalar = isNum(r) && r >= 0
173
+ const okPair = Array.isArray(r) && r.length === 2 && r.every((n) => isNum(n) && n >= 0)
174
+ if (!okScalar && !okPair) {
175
+ at(`${path}.radius`, `must be [rx, ry] in unit space, or one number when the ` +
176
+ `ellipse is absorbed into transform, all >= 0 (got ${show(r)})`)
177
+ }
178
+ if (!GRADIENT_SHAPES.includes(u.shape)) {
179
+ at(`${path}.shape`, `must be one of ${oneOf(GRADIENT_SHAPES)} (got ${show(u.shape)})`)
180
+ }
181
+ return
182
+ }
183
+ checkUnitPoint(u.center, `${path}.center`, at)
184
+ // The one asymmetry: a conic's angles are real screen angles, so the rotation
185
+ // lives here and never in the matrix. A consumer that reads it as `angle` and
186
+ // finds nothing paints the ramp starting at 3 o'clock.
187
+ if (!isNum(u.startAngle)) {
188
+ at(`${path}.startAngle`, `must be the start angle in degrees, clockwise from 12 o'clock ` +
189
+ `(got ${show(u.startAngle)})`)
190
+ }
191
+ }
192
+
193
+ function checkPaint (p, path, at) {
194
+ if (!isObject(p)) {
195
+ at(path, `must be a paint object (got ${show(p)})`)
196
+ return
197
+ }
198
+ if (!PAINT_TYPES.includes(p.type)) {
199
+ at(`${path}.type`, `must be one of ${oneOf(PAINT_TYPES)} (got ${show(p.type)})`)
200
+ return
201
+ }
202
+ if (p.type === 'solid') {
203
+ checkColor(p.color, `${path}.color`, at)
204
+ } else if (p.type === 'image') {
205
+ if (!isId(p.asset)) at(`${path}.asset`, `must be an asset id (got ${show(p.asset)})`)
206
+ } else if (GRADIENT_TYPES.includes(p.type)) {
207
+ // Invariant 6: unit-square geometry plus a 2x3 unit -> box matrix, never
208
+ // percentages. The keys of `unit` are normative per type — see `checkUnit`.
209
+ checkUnit(p, `${path}.unit`, at)
210
+ checkMatrix(p.transform, `${path}.transform`, at)
211
+ if (!Array.isArray(p.stops) || p.stops.length < 2) {
212
+ at(`${path}.stops`, `must be an array of at least 2 stops (got ${show(p.stops)})`)
213
+ } else {
214
+ let prev = -Infinity
215
+ p.stops.forEach((s, i) => {
216
+ const sp = `${path}.stops[${i}]`
217
+ if (!isObject(s)) {
218
+ at(sp, `must be an object {t, color, srcCss} (got ${show(s)})`)
219
+ return
220
+ }
221
+ // The stop position is `t`, the parameter along the gradient line, and
222
+ // it is the same name `css/gradient.js` emits. It used to be renamed to
223
+ // `p` on the way in; that spelling is gone from the format.
224
+ if (!isNum(s.t) || s.t < 0 || s.t > 1) {
225
+ at(`${sp}.t`, `must be the stop position, a number in 0..1 (got ${show(s.t)})`)
226
+ } else {
227
+ if (s.t < prev) at(`${sp}.t`, `must not go backwards: ${s.t} follows ${prev}`)
228
+ prev = s.t
229
+ }
230
+ checkColor(s.color, `${sp}.color`, at)
231
+ })
232
+ }
233
+ }
234
+ if (p.clipBox !== undefined && !BOXES.includes(p.clipBox)) {
235
+ at(`${path}.clipBox`, `must be one of ${oneOf(BOXES)} (got ${show(p.clipBox)})`)
236
+ }
237
+ if (p.notes !== undefined && !isStringArray(p.notes)) {
238
+ at(`${path}.notes`, `must be an array of strings (got ${show(p.notes)})`)
239
+ }
240
+ }
241
+
242
+ function checkStroke (s, path, at) {
243
+ if (!isObject(s)) {
244
+ at(path, `must be a stroke object (got ${show(s)})`)
245
+ return
246
+ }
247
+ checkPaint(s.paint, `${path}.paint`, at)
248
+ const w = s.weight
249
+ if (isNum(w)) {
250
+ if (w < 0) at(`${path}.weight`, `must be >= 0 (got ${w})`)
251
+ } else if (Array.isArray(w)) {
252
+ if (w.length !== 4 || !w.every((n) => isNum(n) && n >= 0)) {
253
+ at(`${path}.weight`, `per-side weights must be 4 finite numbers >= 0 (got ${show(w)})`)
254
+ }
255
+ } else {
256
+ at(`${path}.weight`, `must be a number or 4 per-side numbers (got ${show(w)})`)
257
+ }
258
+ if (s.align !== undefined && !STROKE_ALIGNS.includes(s.align)) {
259
+ at(`${path}.align`, `must be one of ${oneOf(STROKE_ALIGNS)} (got ${show(s.align)})`)
260
+ }
261
+ if (s.dash !== undefined && s.dash !== null &&
262
+ !(Array.isArray(s.dash) && s.dash.length > 0 && s.dash.every((n) => isNum(n) && n >= 0))) {
263
+ at(`${path}.dash`, `must be null or an array of finite numbers >= 0 (got ${show(s.dash)})`)
264
+ }
265
+ }
266
+
267
+ /**
268
+ * A colour matrix: a run of the CSS filter functions that Filter Effects §8
269
+ * *defines* as one — `saturate`, `grayscale`, `sepia`, `hue-rotate`, `invert`,
270
+ * `brightness`, `contrast`, `opacity` — composed into their product. Because
271
+ * each is a matrix by definition, the composition is exact and this effect
272
+ * approximates nothing; the only thing left to declare is whether the backend
273
+ * has the primitive.
274
+ *
275
+ * `values` is the 4x5 of `feColorMatrix type="matrix"`, row-major, with the
276
+ * fifth row `[0,0,0,0,1]` implied. It operates on NON-PREMULTIPLIED sRGB in
277
+ * 0..1 — §8 writes those matrices for sRGB, so an SVG backend must set
278
+ * `color-interpolation-filters="sRGB"` on the primitive or it composites the
279
+ * chain in linearRGB, which is a different colour and would be a silent one.
280
+ *
281
+ * `srcCss` is the chain the matrix came from, verbatim: it round-trips, and it
282
+ * is the text a diagnostic quotes when a backend cannot keep the effect. A
283
+ * matrix with no source is 20 numbers nobody can check.
284
+ *
285
+ * `backdrop` is required rather than defaulted because the two properties
286
+ * filter different pixels — `backdrop-filter` recolours what shows THROUGH the
287
+ * node, `filter` recolours the node itself — and a consumer that reads a
288
+ * missing field as `false` paints the node instead of its backdrop.
289
+ */
290
+ function checkColorMatrix (e, path, at) {
291
+ const v = e.values
292
+ if (!Array.isArray(v) || v.length !== COLOR_MATRIX_LEN) {
293
+ at(`${path}.values`, `must be ${COLOR_MATRIX_LEN} floats — the 4x5 of ` +
294
+ `feColorMatrix type="matrix", row-major (got ${show(v)})`)
295
+ } else {
296
+ v.forEach((n, i) => {
297
+ if (!isNum(n)) at(`${path}.values[${i}]`, `must be a finite number (got ${show(n)})`)
298
+ })
299
+ }
300
+ if (typeof e.srcCss !== 'string' || e.srcCss.length === 0) {
301
+ at(`${path}.srcCss`, 'must be the CSS filter chain this matrix composes, verbatim ' +
302
+ `(got ${show(e.srcCss)})`)
303
+ }
304
+ if (typeof e.backdrop !== 'boolean') {
305
+ at(`${path}.backdrop`, 'must be true when the matrix comes from `backdrop-filter` and false ' +
306
+ `when it comes from \`filter\`; the two recolour different pixels (got ${show(e.backdrop)})`)
307
+ }
308
+ }
309
+
310
+ function checkEffect (e, path, at) {
311
+ if (!isObject(e)) {
312
+ at(path, `must be an effect object (got ${show(e)})`)
313
+ return
314
+ }
315
+ if (!EFFECT_TYPES.includes(e.type)) {
316
+ at(`${path}.type`, `must be one of ${oneOf(EFFECT_TYPES)} (got ${show(e.type)})`)
317
+ return
318
+ }
319
+ // The one effect that is not a blur and does not carry one: a colour matrix
320
+ // moves colours where they stand.
321
+ if (e.type === 'colorMatrix') {
322
+ checkColorMatrix(e, path, at)
323
+ return
324
+ }
325
+ // Invariant 5: `blur` is the CSS radius. The emitter halves it for SVG sigma;
326
+ // nobody else may reinterpret it.
327
+ if (!isNum(e.blur) || e.blur < 0) {
328
+ at(`${path}.blur`, `must be the CSS blur radius, a finite number >= 0 (got ${show(e.blur)})`)
329
+ }
330
+ if (!SHADOW_TYPES.includes(e.type)) return
331
+ checkColor(e.color, `${path}.color`, at)
332
+ if (!Array.isArray(e.offset) || e.offset.length !== 2 || !e.offset.every(isNum)) {
333
+ at(`${path}.offset`, `must be [x, y] in CSS px (got ${show(e.offset)})`)
334
+ }
335
+ if (e.spread !== undefined && !isNum(e.spread)) {
336
+ at(`${path}.spread`, `must be a finite number (got ${show(e.spread)})`)
337
+ }
338
+ if (e.behind !== undefined && typeof e.behind !== 'boolean') {
339
+ at(`${path}.behind`, `must be a boolean (got ${show(e.behind)})`)
340
+ }
341
+ }
342
+
343
+ const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string')
344
+
345
+ /**
346
+ * `start`/`end` are offsets into `characters`, `end` exclusive. `len` is null
347
+ * when `characters` is unusable, so the range half is skipped rather than
348
+ * reported against a length nobody can trust.
349
+ */
350
+ function checkSpan (span, path, len, at) {
351
+ const { start, end } = span
352
+ const okStart = Number.isInteger(start) && start >= 0
353
+ const okEnd = Number.isInteger(end) && end >= 0
354
+ if (!okStart) at(`${path}.start`, `must be a non-negative integer offset into characters (got ${show(start)})`)
355
+ if (!okEnd) at(`${path}.end`, `must be a non-negative integer offset into characters (got ${show(end)})`)
356
+ if (okStart && okEnd && end < start) at(`${path}.end`, `must be >= start (start ${start}, got ${end})`)
357
+ if (len === null) return
358
+ if (okStart && start > len) at(`${path}.start`, `is ${start}, past the end of characters (length ${len})`)
359
+ if (okEnd && end > len) at(`${path}.end`, `is ${end}, past the end of characters (length ${len})`)
360
+ }
361
+
362
+ /**
363
+ * Text is where a downstream backend has no recourse: it cannot re-measure, so
364
+ * anything missing here becomes a reflow nobody can see coming.
365
+ */
366
+ function checkText (t, path, at) {
367
+ if (!isObject(t)) {
368
+ at(path, `must be an object on type:"text" nodes (got ${show(t)})`)
369
+ return
370
+ }
371
+
372
+ let len = null
373
+ const chars = t.characters
374
+ if (typeof chars !== 'string') {
375
+ at(`${path}.characters`, `must be a string (got ${show(chars)})`)
376
+ } else if (chars.length === 0) {
377
+ at(`${path}.characters`, 'must not be empty; a text node with no characters should have been pruned')
378
+ } else {
379
+ len = chars.length
380
+ const nl = chars.indexOf('\n')
381
+ if (nl !== -1) {
382
+ at(`${path}.characters`,
383
+ `must join visual lines with U+2028 (LSEP), never U+000A — found a newline at index ${nl}; ` +
384
+ 'a newline makes a paragraph and triggers paragraphSpacing downstream')
385
+ }
386
+ }
387
+
388
+ if (typeof t.sourceText !== 'undefined' && typeof t.sourceText !== 'string') {
389
+ at(`${path}.sourceText`, `must be the pre-text-transform string (got ${show(t.sourceText)})`)
390
+ }
391
+ if (!isNum(t.pitch) || t.pitch <= 0) {
392
+ at(`${path}.pitch`,
393
+ `must be the measured baseline-to-baseline distance, a number > 0 (got ${show(t.pitch)})`)
394
+ }
395
+ if (t.direction !== undefined && !DIRECTIONS.includes(t.direction)) {
396
+ at(`${path}.direction`, `must be one of ${oneOf(DIRECTIONS)} (got ${show(t.direction)})`)
397
+ }
398
+ if (t.align !== undefined && typeof t.align !== 'string') {
399
+ at(`${path}.align`, `must be a string (got ${show(t.align)})`)
400
+ }
401
+ for (const k of ['letterSpacing', 'wordSpacing', 'paragraphSpacing', 'trailingSpacingComp', 'widthSlackPx']) {
402
+ if (t[k] !== undefined && !isNum(t[k])) at(`${path}.${k}`, `must be a finite number (got ${show(t[k])})`)
403
+ }
404
+
405
+ if (!Array.isArray(t.lines) || t.lines.length === 0) {
406
+ at(`${path}.lines`, `must be a non-empty array of measured line boxes (got ${show(t.lines)})`)
407
+ } else {
408
+ let prevEnd = null
409
+ t.lines.forEach((line, i) => {
410
+ const lp = `${path}.lines[${i}]`
411
+ if (!isObject(line)) {
412
+ at(lp, `must be an object {start, end, x, w, baseline} (got ${show(line)})`)
413
+ return
414
+ }
415
+ if (line.i !== undefined && line.i !== i) {
416
+ at(`${lp}.i`, `must be the line's own index ${i} (got ${show(line.i)})`)
417
+ }
418
+ checkSpan(line, lp, len, at)
419
+ if (!isNum(line.x)) at(`${lp}.x`, `must be a finite number (got ${show(line.x)})`)
420
+ if (!isNum(line.w) || line.w < 0) at(`${lp}.w`, `must be the measured width, >= 0 (got ${show(line.w)})`)
421
+ if (!isNum(line.baseline)) {
422
+ at(`${lp}.baseline`, `must be the measured baseline in CSS px (got ${show(line.baseline)})`)
423
+ }
424
+ for (const k of ['asc', 'desc']) {
425
+ if (line[k] !== undefined && !isNum(line[k])) at(`${lp}.${k}`, `must be a finite number (got ${show(line[k])})`)
426
+ }
427
+ // The glyph the browser paints at a hyphenation break. It is NOT in
428
+ // `characters` on purpose — every offset in `runs`, `lines`, `frags` and
429
+ // `spans` indexes that string and `sourceText` matches it one for one — so it
430
+ // rides on the line that painted it, and its advance is already inside
431
+ // `line.w`. A backend paints the line as `characters.slice(start, end) +
432
+ // hyphen`. One character, because a break glyph is one glyph.
433
+ if (line.hyphen !== undefined) {
434
+ if (typeof line.hyphen !== 'string' || [...line.hyphen].length !== 1) {
435
+ at(`${lp}.hyphen`, 'must be the single character the browser painted at this line\'s break ' +
436
+ `(got ${show(line.hyphen)})`)
437
+ }
438
+ }
439
+ // A line broken by something that is not a character (an inline image, a
440
+ // float) tessellates into fragments. `line.w` is then the SUM of the
441
+ // painted advances and only `frags` says where they sit, so a consumer
442
+ // that stretches the whole line to `line.w` squeezes the glyphs. The
443
+ // fragments must cover the line's span, in visual order, left to right.
444
+ if (line.frags !== undefined) {
445
+ if (!Array.isArray(line.frags) || line.frags.length === 0) {
446
+ at(`${lp}.frags`, `must be a non-empty array of {x, w, start, end} (got ${show(line.frags)})`)
447
+ } else {
448
+ let prevFragEnd = null
449
+ line.frags.forEach((frag, k) => {
450
+ const fp = `${lp}.frags[${k}]`
451
+ if (!isObject(frag)) {
452
+ at(fp, `must be an object {x, w, start, end} (got ${show(frag)})`)
453
+ return
454
+ }
455
+ checkSpan(frag, fp, len, at)
456
+ if (!isNum(frag.x)) at(`${fp}.x`, `must be a finite number (got ${show(frag.x)})`)
457
+ if (!isNum(frag.w) || frag.w < 0) at(`${fp}.w`, `must be the measured width, >= 0 (got ${show(frag.w)})`)
458
+ if (prevFragEnd !== null && Number.isInteger(frag.start) && frag.start < prevFragEnd) {
459
+ at(`${fp}.start`, `is ${frag.start}, before the previous fragment ended (${prevFragEnd}); ` +
460
+ 'fragments tessellate the line in visual order')
461
+ }
462
+ if (Number.isInteger(frag.end)) prevFragEnd = frag.end
463
+ })
464
+ const first = line.frags[0]
465
+ const last = line.frags[line.frags.length - 1]
466
+ if (isObject(first) && Number.isInteger(line.start) && first.start !== line.start) {
467
+ at(`${lp}.frags[0].start`,
468
+ `is ${show(first.start)}; the fragments must start where the line does (${line.start})`)
469
+ }
470
+ if (isObject(last) && Number.isInteger(line.end) && last.end !== line.end) {
471
+ at(`${lp}.frags[${line.frags.length - 1}].end`,
472
+ `is ${show(last.end)}; the fragments must end where the line does (${line.end})`)
473
+ }
474
+ }
475
+ }
476
+ // Measured word anchors, written only for justified blocks (2026-08-13):
477
+ // justification puts each line's slack into the WORD GAPS, so no advance
478
+ // arithmetic can place a word — only a measurement can. Unlike `frags`,
479
+ // words do NOT tessellate: the gaps between them are the space characters,
480
+ // which belong to no word. Each word is where its first glyph's advance
481
+ // box starts, in the same units as `line.x`.
482
+ if (line.words !== undefined) {
483
+ if (!Array.isArray(line.words) || line.words.length < 2) {
484
+ at(`${lp}.words`, `must be an array of at least two {x, w, start, end} (a line with one word has nothing to anchor) (got ${show(line.words)})`)
485
+ } else {
486
+ let prevWordEnd = null
487
+ line.words.forEach((word, k) => {
488
+ const wp = `${lp}.words[${k}]`
489
+ if (!isObject(word)) {
490
+ at(wp, `must be an object {x, w, start, end} (got ${show(word)})`)
491
+ return
492
+ }
493
+ checkSpan(word, wp, len, at)
494
+ if (!isNum(word.x)) at(`${wp}.x`, `must be a finite number (got ${show(word.x)})`)
495
+ if (!isNum(word.w) || word.w < 0) at(`${wp}.w`, `must be the measured width, >= 0 (got ${show(word.w)})`)
496
+ if (prevWordEnd !== null && Number.isInteger(word.start) && word.start < prevWordEnd) {
497
+ at(`${wp}.start`, `is ${word.start}, before the previous word ended (${prevWordEnd}); ` +
498
+ 'words are in visual order and never overlap')
499
+ }
500
+ if (Number.isInteger(word.end)) prevWordEnd = word.end
501
+ })
502
+ }
503
+ }
504
+ // Lines are in visual order, so their spans only move forward. The LSEP
505
+ // between them is why this is >= and not ===.
506
+ if (prevEnd !== null && Number.isInteger(line.start) && line.start < prevEnd) {
507
+ at(`${lp}.start`, `is ${line.start}, before the previous line ended (${prevEnd}); lines must be in visual order`)
508
+ }
509
+ if (Number.isInteger(line.end)) prevEnd = line.end
510
+ })
511
+ }
512
+
513
+ if (!Array.isArray(t.runs) || t.runs.length === 0) {
514
+ at(`${path}.runs`, `must be a non-empty array of styled runs (got ${show(t.runs)})`)
515
+ } else {
516
+ t.runs.forEach((run, i) => {
517
+ const rp = `${path}.runs[${i}]`
518
+ if (!isObject(run)) {
519
+ at(rp, `must be an object {start, end, style} (got ${show(run)})`)
520
+ return
521
+ }
522
+ checkSpan(run, rp, len, at)
523
+ if (!isId(run.style)) at(`${rp}.style`, `must be a styles.text id (got ${show(run.style)})`)
524
+ if (run.href !== undefined && run.href !== null && typeof run.href !== 'string') {
525
+ at(`${rp}.href`, `must be a string (got ${show(run.href)})`)
526
+ }
527
+ // The measured baseline offset of a raised/lowered run (sup/sub,
528
+ // `vertical-align`), positive downwards. Optional, but never a guess.
529
+ if (run.dy !== undefined && !isNum(run.dy)) {
530
+ at(`${rp}.dy`, `must be the measured baseline offset in CSS px, positive down (got ${show(run.dy)})`)
531
+ }
532
+ })
533
+ }
534
+
535
+ if (t.emit !== undefined && t.emit !== null) {
536
+ if (!isObject(t.emit)) at(`${path}.emit`, `must be an object (got ${show(t.emit)})`)
537
+ else if (t.emit.outlineAsset !== undefined && t.emit.outlineAsset !== null &&
538
+ !isId(t.emit.outlineAsset)) {
539
+ at(`${path}.emit.outlineAsset`, `must be null or an asset id (got ${show(t.emit.outlineAsset)})`)
540
+ }
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Everything about one node that does not need the rest of the document.
546
+ * Cross-references (`children`, `assets`, `styles`, `diagnostics`) are
547
+ * `validate`'s job — this function has no way to resolve them.
548
+ *
549
+ * @param {object} node
550
+ * @param {string} id
551
+ * @returns {string[]} errors, empty if valid
552
+ */
553
+ export function validateNode (node, id) {
554
+ const errors = []
555
+ if (!isId(id)) errors.push(`node id must be a non-empty string (got ${show(id)})`)
556
+ const label = `node ${quote(id)}`
557
+ const at = (path, message) => errors.push(`${label}: ${path} ${message}`)
558
+
559
+ if (!isObject(node)) {
560
+ errors.push(`${label}: must be an object (got ${show(node)})`)
561
+ return errors
562
+ }
563
+
564
+ if (!NODE_TYPES.includes(node.type)) {
565
+ at('type', `must be one of ${oneOf(NODE_TYPES)} (got ${show(node.type)})`)
566
+ }
567
+ if (node.name !== undefined && typeof node.name !== 'string') {
568
+ at('name', `must be a string (got ${show(node.name)})`)
569
+ }
570
+
571
+ // Invariant 2: both boxes, always. `abs` is what the harness asserts against
572
+ // and the only one that survives reparenting.
573
+ checkRect(node.frame, 'frame', at)
574
+ checkRect(node.abs, 'abs', at)
575
+
576
+ if (!isObject(node.paint)) {
577
+ at('paint', `must be an object {z, stackingContext} (got ${show(node.paint)})`)
578
+ } else {
579
+ // Invariant 3: paint order is an integer index the backend sorts by, never
580
+ // re-derives. A float means someone inserted by bisection.
581
+ if (!Number.isInteger(node.paint.z)) {
582
+ at('paint.z', `must be an integer back-to-front index (got ${show(node.paint.z)})`)
583
+ }
584
+ if (node.paint.stackingContext !== undefined && typeof node.paint.stackingContext !== 'boolean') {
585
+ at('paint.stackingContext', `must be a boolean (got ${show(node.paint.stackingContext)})`)
586
+ }
587
+ if (node.paint.reason !== undefined && typeof node.paint.reason !== 'string') {
588
+ at('paint.reason', `must be a string (got ${show(node.paint.reason)})`)
589
+ }
590
+ }
591
+
592
+ if (!isNum(node.opacity) || node.opacity < 0 || node.opacity > 1) {
593
+ at('opacity', `must be a number in 0..1 (got ${show(node.opacity)})`)
594
+ }
595
+ if (node.blend !== undefined && !BLEND_MODES.includes(node.blend)) {
596
+ at('blend', `must be a CSS mix-blend-mode keyword (got ${show(node.blend)})`)
597
+ }
598
+ if (node.isolate !== undefined && typeof node.isolate !== 'boolean') {
599
+ at('isolate', `must be a boolean (got ${show(node.isolate)})`)
600
+ }
601
+
602
+ if (node.transform !== undefined && node.transform !== null) {
603
+ if (!isObject(node.transform)) {
604
+ at('transform', `must be null or {m, decomposed} (got ${show(node.transform)})`)
605
+ } else {
606
+ checkMatrix(node.transform.m, 'transform.m', at)
607
+ const d = node.transform.decomposed
608
+ if (d !== undefined && d !== null) {
609
+ if (!isObject(d)) {
610
+ at('transform.decomposed', `must be an object (got ${show(d)})`)
611
+ } else {
612
+ for (const k of ['tx', 'ty', 'rot', 'sx', 'sy', 'skewX']) {
613
+ if (!isNum(d[k])) at(`transform.decomposed.${k}`, `must be a finite number (got ${show(d[k])})`)
614
+ }
615
+ if (isNum(d.rot) && (d.rot < -180 || d.rot > 180)) {
616
+ at('transform.decomposed.rot', `must be degrees clamped to -180..180 (got ${d.rot})`)
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ if (node.clip !== undefined && node.clip !== null) {
624
+ if (!isObject(node.clip)) {
625
+ at('clip', `must be null or an object {mode, box, radii} (got ${show(node.clip)})`)
626
+ } else {
627
+ if (!CLIP_MODES.includes(node.clip.mode)) {
628
+ at('clip.mode', `must be one of ${oneOf(CLIP_MODES)} (got ${show(node.clip.mode)})`)
629
+ }
630
+ if (node.clip.box !== undefined && !BOXES.includes(node.clip.box)) {
631
+ at('clip.box', `must be one of ${oneOf(BOXES)} (got ${show(node.clip.box)})`)
632
+ }
633
+ if (node.clip.radii !== undefined && node.clip.radii !== null) {
634
+ checkRadii(node.clip.radii, 'clip.radii', at)
635
+ }
636
+ }
637
+ }
638
+ if (node.radii !== undefined && node.radii !== null) checkRadii(node.radii, 'radii', at)
639
+
640
+ if (node.mask !== undefined && node.mask !== null) {
641
+ if (!isObject(node.mask)) {
642
+ at('mask', `must be null or an object {node, type} (got ${show(node.mask)})`)
643
+ } else {
644
+ if (!isId(node.mask.node)) at('mask.node', `must be a node id (got ${show(node.mask.node)})`)
645
+ if (node.mask.type !== undefined && !MASK_TYPES.includes(node.mask.type)) {
646
+ at('mask.type', `must be one of ${oneOf(MASK_TYPES)} (got ${show(node.mask.type)})`)
647
+ }
648
+ }
649
+ }
650
+
651
+ for (const [key, check] of [['fills', checkPaint], ['strokes', checkStroke], ['effects', checkEffect]]) {
652
+ const list = node[key]
653
+ if (list === undefined) continue
654
+ if (!Array.isArray(list)) {
655
+ at(key, `must be an array (got ${show(list)})`)
656
+ continue
657
+ }
658
+ list.forEach((entry, i) => check(entry, `${key}[${i}]`, at))
659
+ }
660
+
661
+ if (!isObject(node.fidelity)) {
662
+ at('fidelity', `must be an object {grade, notes} (got ${show(node.fidelity)})`)
663
+ } else {
664
+ if (!FIDELITY.includes(node.fidelity.grade)) {
665
+ at('fidelity.grade', `must be one of ${oneOf(FIDELITY)} (got ${show(node.fidelity.grade)})`)
666
+ }
667
+ if (node.fidelity.notes !== undefined && !isStringArray(node.fidelity.notes)) {
668
+ at('fidelity.notes', `must be an array of strings (got ${show(node.fidelity.notes)})`)
669
+ }
670
+ if (node.fidelity.reason !== undefined && typeof node.fidelity.reason !== 'string') {
671
+ at('fidelity.reason', `must be a string (got ${show(node.fidelity.reason)})`)
672
+ }
673
+ }
674
+
675
+ if (node.type === 'text') {
676
+ checkText(node.text, 'text', at)
677
+ } else if (node.text !== undefined && node.text !== null) {
678
+ at('text', `is legal only on type:"text" nodes (this one is ${show(node.type)})`)
679
+ }
680
+
681
+ if (node.source !== undefined && node.source !== null) {
682
+ if (!isObject(node.source)) at('source', `must be an object (got ${show(node.source)})`)
683
+ else if (node.source.tag !== undefined && typeof node.source.tag !== 'string') {
684
+ at('source.tag', `must be a string (got ${show(node.source.tag)})`)
685
+ }
686
+ }
687
+
688
+ if (node.children !== undefined) {
689
+ if (!Array.isArray(node.children)) {
690
+ at('children', `must be an array of node ids (got ${show(node.children)})`)
691
+ } else {
692
+ node.children.forEach((child, i) => {
693
+ if (!isId(child)) at(`children[${i}]`, `must be a node id string (got ${show(child)})`)
694
+ })
695
+ }
696
+ }
697
+
698
+ return errors
699
+ }
700
+
701
+ /**
702
+ * `root` is written `{"$ref": "n_0"}` in the format skeleton but `buildPaintTree`
703
+ * hands back a bare `rootId`, so both spellings are accepted here — only
704
+ * *resolving* is normative. Returns null when it is neither.
705
+ */
706
+ function readRootId (root) {
707
+ if (isId(root)) return root
708
+ if (isObject(root) && isId(root.$ref)) return root.$ref
709
+ return null
710
+ }
711
+
712
+ /**
713
+ * Node ids named by a diagnostic. `node` is the primary field; `from`/`to`
714
+ * (promotions) and `overlaps` also name real nodes, and an array-valued `nodes`
715
+ * is a list — the scalar one is a *count* (`"nodes": 18`) and is skipped.
716
+ */
717
+ function diagnosticNodeIds (diagnostics) {
718
+ const ids = new Set()
719
+ for (const d of diagnostics) {
720
+ if (!isObject(d)) continue
721
+ for (const key of ['node', 'from', 'to']) if (isId(d[key])) ids.add(d[key])
722
+ for (const key of ['nodes', 'overlaps']) {
723
+ if (Array.isArray(d[key])) for (const v of d[key]) if (isId(v)) ids.add(v)
724
+ }
725
+ }
726
+ return ids
727
+ }
728
+
729
+ /**
730
+ * Cycles among nodes the root cannot reach. A node has at most one parent by
731
+ * the time this runs, so walking parent links from each unreachable node finds
732
+ * every cycle in linear time — and "cycle" is a far more actionable diagnosis
733
+ * than the "unreachable" every member would otherwise get.
734
+ */
735
+ function findCycles (ids, parent, reachable) {
736
+ const cycles = []
737
+ const settled = new Set()
738
+ for (const start of ids) {
739
+ if (reachable.has(start) || settled.has(start)) continue
740
+ const path = []
741
+ const seen = new Map()
742
+ let cur = start
743
+ while (cur !== undefined && !reachable.has(cur) && !settled.has(cur)) {
744
+ if (seen.has(cur)) {
745
+ cycles.push(path.slice(seen.get(cur)).reverse())
746
+ break
747
+ }
748
+ seen.set(cur, path.length)
749
+ path.push(cur)
750
+ cur = parent.get(cur)
751
+ }
752
+ for (const id of path) settled.add(id)
753
+ }
754
+ return cycles
755
+ }
756
+
757
+ /**
758
+ * Validate a whole SVD document.
759
+ *
760
+ * @param {object} doc
761
+ * @returns {{ok: boolean, errors: string[]}}
762
+ */
763
+ export function validate (doc) {
764
+ const errors = []
765
+ const push = (message) => errors.push(message)
766
+
767
+ if (!isObject(doc)) {
768
+ return { ok: false, errors: [`document must be an object (got ${show(doc)})`] }
769
+ }
770
+
771
+ if (doc.schema !== SVD_VERSION) {
772
+ push(`schema must be ${quote(SVD_VERSION)} (got ${show(doc.schema)})`)
773
+ }
774
+ if (doc.generator !== undefined && !isObject(doc.generator)) {
775
+ push(`generator must be an object (got ${show(doc.generator)})`)
776
+ }
777
+ if (doc.capture !== undefined && !isObject(doc.capture)) {
778
+ push(`capture must be an object (got ${show(doc.capture)})`)
779
+ }
780
+ if (doc.report !== undefined && !isObject(doc.report)) {
781
+ push(`report must be an object (got ${show(doc.report)})`)
782
+ }
783
+
784
+ const assets = isObject(doc.assets) ? doc.assets : null
785
+ if (assets === null) push(`assets must be an object keyed by asset id (got ${show(doc.assets)})`)
786
+ else {
787
+ for (const id of Object.keys(assets)) {
788
+ const asset = assets[id]
789
+ if (!isObject(asset)) {
790
+ push(`assets[${quote(id)}] must be an object (got ${show(asset)})`)
791
+ continue
792
+ }
793
+ if (!isId(asset.kind)) push(`assets[${quote(id)}].kind must be a non-empty string (got ${show(asset.kind)})`)
794
+ for (const k of ['w', 'h']) {
795
+ if (asset[k] !== undefined && (!isNum(asset[k]) || asset[k] < 0)) {
796
+ push(`assets[${quote(id)}].${k} must be a finite number >= 0 (got ${show(asset[k])})`)
797
+ }
798
+ }
799
+ }
800
+ }
801
+
802
+ const styles = isObject(doc.styles) ? doc.styles : null
803
+ if (styles === null) push(`styles must be an object (got ${show(doc.styles)})`)
804
+ const textStyles = styles !== null && isObject(styles.text) ? styles.text : null
805
+ if (styles !== null && textStyles === null) {
806
+ push(`styles.text must be an object keyed by text style id (got ${show(styles.text)})`)
807
+ }
808
+ if (textStyles !== null) {
809
+ for (const id of Object.keys(textStyles)) {
810
+ const style = textStyles[id]
811
+ const sp = `styles.text[${quote(id)}]`
812
+ if (!isObject(style)) {
813
+ push(`${sp} must be an object (got ${show(style)})`)
814
+ continue
815
+ }
816
+ const at = (path, message) => push(`${sp}.${path} ${message}`)
817
+ if (!isNum(style.size) || style.size <= 0) at('size', `must be a number > 0 (got ${show(style.size)})`)
818
+ if (style.weight !== undefined && !isNum(style.weight)) at('weight', `must be a number (got ${show(style.weight)})`)
819
+ if (style.italic !== undefined && typeof style.italic !== 'boolean') {
820
+ at('italic', `must be a boolean (got ${show(style.italic)})`)
821
+ }
822
+ for (const k of ['letterSpacing', 'wordSpacing']) {
823
+ if (style[k] !== undefined && !isNum(style[k])) at(k, `must be a finite number (got ${show(style[k])})`)
824
+ }
825
+ if (style.fills !== undefined) {
826
+ if (!Array.isArray(style.fills)) at('fills', `must be an array (got ${show(style.fills)})`)
827
+ else style.fills.forEach((p, i) => checkPaint(p, `fills[${i}]`, at))
828
+ }
829
+ if (isId(style.font) && assets !== null && !has(assets, style.font)) {
830
+ push(`${sp}.font references ${quote(style.font)}, which is not in assets`)
831
+ }
832
+ }
833
+ }
834
+
835
+ const diagnostics = Array.isArray(doc.diagnostics) ? doc.diagnostics : null
836
+ if (diagnostics === null) push(`diagnostics must be an array (got ${show(doc.diagnostics)})`)
837
+
838
+ const nodes = isObject(doc.nodes) ? doc.nodes : null
839
+ if (nodes === null) {
840
+ push(`nodes must be an object keyed by node id (got ${show(doc.nodes)})`)
841
+ return { ok: false, errors }
842
+ }
843
+
844
+ const ids = Object.keys(nodes)
845
+ if (ids.length === 0) push('nodes is empty; a document always has at least its root node')
846
+
847
+ const rootId = readRootId(doc.root)
848
+ if (rootId === null) {
849
+ push(`root must be a node id or {$ref: id} (got ${show(doc.root)})`)
850
+ } else if (!has(nodes, rootId)) {
851
+ push(`root references ${quote(rootId)}, which is not in nodes`)
852
+ }
853
+
854
+ // Per-node checks, plus the parent map the tree checks below are built on.
855
+ const parent = new Map()
856
+ for (const id of ids) {
857
+ const node = nodes[id]
858
+ for (const e of validateNode(node, id)) push(e)
859
+ if (!isObject(node) || !Array.isArray(node.children)) continue
860
+ node.children.forEach((childId, i) => {
861
+ if (!isId(childId)) return // already reported by validateNode
862
+ if (!has(nodes, childId)) {
863
+ push(`node ${quote(id)}: children[${i}] references ${quote(childId)}, which is not in nodes`)
864
+ return
865
+ }
866
+ if (childId === id) {
867
+ push(`node ${quote(id)}: children[${i}] references itself`)
868
+ return
869
+ }
870
+ const prev = parent.get(childId)
871
+ if (prev === id) {
872
+ push(`node ${quote(childId)} appears twice in the children of ${quote(id)}`)
873
+ } else if (prev !== undefined) {
874
+ push(`node ${quote(childId)} is a child of both ${quote(prev)} and ${quote(id)}; ` +
875
+ 'the tree is DOM containment, so a node has exactly one parent and one frame')
876
+ } else {
877
+ parent.set(childId, id)
878
+ }
879
+ })
880
+ }
881
+
882
+ if (rootId !== null && parent.has(rootId)) {
883
+ push(`root ${quote(rootId)} is also a child of ${quote(parent.get(rootId))}; the root has no parent`)
884
+ }
885
+
886
+ // Reachability doubles as the cycle guard: the visited set is what keeps a
887
+ // cyclic children graph from spinning here forever.
888
+ const reachable = new Set()
889
+ if (rootId !== null && has(nodes, rootId)) {
890
+ const stack = [rootId]
891
+ while (stack.length) {
892
+ const id = stack.pop()
893
+ if (reachable.has(id)) continue
894
+ reachable.add(id)
895
+ const node = nodes[id]
896
+ if (!isObject(node) || !Array.isArray(node.children)) continue
897
+ for (const child of node.children) if (isId(child) && has(nodes, child)) stack.push(child)
898
+ }
899
+ }
900
+
901
+ const inCycle = new Set()
902
+ for (const cycle of findCycles(ids, parent, reachable)) {
903
+ for (const id of cycle) inCycle.add(id)
904
+ push(`cycle in children: ${cycle.map(quote).join(' -> ')} -> ${quote(cycle[0])}`)
905
+ }
906
+ for (const id of ids) {
907
+ if (reachable.has(id) || inCycle.has(id)) continue
908
+ push(`node ${quote(id)} is unreachable from root; every node hangs off the root or does not exist`)
909
+ }
910
+
911
+ // The root's own `abs` is measured against itself (invariant 2).
912
+ if (rootId !== null && isObject(nodes[rootId]) && isObject(nodes[rootId].abs)) {
913
+ const abs = nodes[rootId].abs
914
+ for (const k of ['x', 'y']) {
915
+ if (isNum(abs[k]) && Math.abs(abs[k]) > ORIGIN_EPS) {
916
+ push(`node ${quote(rootId)}: abs.${k} must be 0 on the root — abs is relative to the ` +
917
+ `capture root's own border box (got ${abs[k]})`)
918
+ }
919
+ }
920
+ }
921
+
922
+ // Reference resolution: assets, text styles, mask targets.
923
+ for (const id of ids) {
924
+ const node = nodes[id]
925
+ if (!isObject(node)) continue
926
+ const label = `node ${quote(id)}`
927
+ if (Array.isArray(node.fills)) {
928
+ node.fills.forEach((fill, i) => {
929
+ if (!isObject(fill) || !isId(fill.asset)) return
930
+ if (assets !== null && !has(assets, fill.asset)) {
931
+ push(`${label}: fills[${i}].asset references ${quote(fill.asset)}, which is not in assets`)
932
+ }
933
+ })
934
+ }
935
+ if (isObject(node.mask) && isId(node.mask.node) && !has(nodes, node.mask.node)) {
936
+ push(`${label}: mask.node references ${quote(node.mask.node)}, which is not in nodes`)
937
+ }
938
+ if (node.type !== 'text' || !isObject(node.text)) continue
939
+ if (Array.isArray(node.text.runs)) {
940
+ node.text.runs.forEach((run, i) => {
941
+ if (!isObject(run) || !isId(run.style)) return
942
+ if (textStyles !== null && !has(textStyles, run.style)) {
943
+ push(`${label}: text.runs[${i}].style references ${quote(run.style)}, which is not in styles.text`)
944
+ }
945
+ })
946
+ }
947
+ const outline = isObject(node.text.emit) ? node.text.emit.outlineAsset : undefined
948
+ if (isId(outline) && assets !== null && !has(assets, outline)) {
949
+ push(`${label}: text.emit.outlineAsset references ${quote(outline)}, which is not in assets`)
950
+ }
951
+ }
952
+
953
+ if (diagnostics === null) return { ok: errors.length === 0, errors }
954
+
955
+ diagnostics.forEach((d, i) => {
956
+ if (!isObject(d)) {
957
+ push(`diagnostics[${i}] must be an object (got ${show(d)})`)
958
+ return
959
+ }
960
+ if (!isId(d.code)) push(`diagnostics[${i}].code must be a non-empty string (got ${show(d.code)})`)
961
+ if (d.severity !== undefined && !SEVERITIES.includes(d.severity)) {
962
+ push(`diagnostics[${i}].severity must be one of ${oneOf(SEVERITIES)} (got ${show(d.severity)})`)
963
+ }
964
+ if (d.node !== undefined && !isId(d.node)) {
965
+ push(`diagnostics[${i}].node must be a node id (got ${show(d.node)})`)
966
+ } else if (isId(d.node) && !has(nodes, d.node)) {
967
+ push(`diagnostics[${i}] (${quote(d.code)}) references node ${quote(d.node)}, which is not in nodes`)
968
+ }
969
+ })
970
+
971
+ // The rule that outranks the rest of the schema: nothing degrades silently.
972
+ const declared = diagnosticNodeIds(diagnostics)
973
+ for (const id of ids) {
974
+ const node = nodes[id]
975
+ if (!isObject(node) || !isObject(node.fidelity)) continue
976
+ const grade = node.fidelity.grade
977
+ if (!FIDELITY.includes(grade) || grade === 'E') continue
978
+ if (declared.has(id)) continue
979
+ push(`node ${quote(id)}: fidelity.grade is ${quote(grade)} but no diagnostics entry names it. ` +
980
+ 'Every node graded other than "E" lost something, and a loss nobody declared is a bug — ' +
981
+ `add a diagnostics entry with node: ${quote(id)}`)
982
+ }
983
+
984
+ return { ok: errors.length === 0, errors }
985
+ }