@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0
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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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
|
+
}
|