quario 0.5.0 → 0.7.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 CHANGED
@@ -7,6 +7,515 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **Styled runs.** Bold a word, colour a phrase, or format one value inside a
15
+ sentence, and have it survive into the PDF and the spreadsheet — not only the
16
+ HTML, which was the only place `<b>` in a template ever meant anything. A run
17
+ carries the inline declarations only: no padding, borders or spacing, which
18
+ belong to a whole line or block.
19
+
20
+ A cell's tokens carry a run's resolved style on the tokens it covers, and
21
+ `styledRuns(tokens)` groups them back out — the same rule every built-in
22
+ target reads them through, so a custom target cannot drift from them.
23
+ `RUN_STYLE_NAMES` is the inline half of the vocabulary as a list, beside
24
+ `STYLE_NAMES`, for a tool that offers an author what a run may wear.
25
+
26
+ - **`plan()` now reports declarations that nothing will read.** A definition can
27
+ be perfectly valid and still say something the engine quietly drops, and
28
+ until now the only way to find out was to notice the output was wrong.
29
+ `plan()` returns a `warnings` list beside `problems` — same shape, same
30
+ document order, one entry per declaration — and it currently catches three:
31
+
32
+ - a `format` on a cell whose value can never present a single interpolation —
33
+ `"Due {{ due }}"` under `format: "date"` — which the engine leaves unread;
34
+ - a `currency` on a cell whose `format` is not `"currency"`, which the engine
35
+ reads only under that kind and otherwise ignores, so the cell renders in
36
+ the instance's currency and the code you wrote does nothing;
37
+ - a table where every column declares a `width` and the widths total under
38
+ 100, which leaves the trailing share of the table unused — usually a column
39
+ edited down without its neighbours being adjusted.
40
+
41
+ `problems` is unchanged and still fatal: a definition with problems compiles
42
+ to nothing, while one carrying only warnings compiles and renders exactly as
43
+ before. Hosts that ignore the new field are unaffected, and `validate()` is
44
+ untouched.
45
+
46
+ Warnings are advisory and deliberately incomplete — they catch what can be
47
+ seen in the definition alone, without data, so a quiet `warnings` list is not
48
+ a promise that every declaration will be read.
49
+
50
+ ### Changed
51
+
52
+ - **A `format` declaration now needs one value to speak about, and a cell can
53
+ give it one.** Previously a `format` on a cell reached every interpolation in
54
+ it and skipped the literal text between them, which meant
55
+ `"{{ amount }} of {{ quantity }}"` under `format: "currency"` rendered the
56
+ quantity as money too — and the page and the spreadsheet disagreed about it,
57
+ the page formatting inside a sentence where the sheet did not.
58
+
59
+ A cell `value` may now be a list of **styled runs** —
60
+ `{ "value": "…", "style": { … } }` — each with its own declarations, and
61
+ `format` applies to a run holding a single interpolation.
62
+
63
+ **What changes in an existing report:** a cell that mixes text and an
64
+ interpolation under a `format` stops presenting that value and renders it
65
+ plainly, in every target. `"Due {{ due }}"` with `format: "date"` was
66
+ `Due 14 Aug 2026` on the page and `Due 2026-08-14` in the sheet; it is now
67
+ the plain form in both. To restore it, split the value and leave the
68
+ declaration where it is:
69
+
70
+ ```jsonc
71
+ {
72
+ "value": [{ "value": "Due " }, { "value": "{{ due }}" }],
73
+ "style": { "format": "date" }
74
+ }
75
+ ```
76
+
77
+ A cell's `format` reaches its runs, so nothing moves and nothing is declared
78
+ twice. `plan()` reports the cells this affects wherever it can see them
79
+ without data.
80
+
81
+ ### Fixed
82
+
83
+ - **`round`, `floor` and `ceil` reuse their formatters instead of rebuilding
84
+ one per call.** The three reach the decimal you wrote through `Intl`, and
85
+ each call built a fresh formatter to do it — so a detail column of
86
+ `{{ round(@.qty * @.price, 2) }}` paid for one per row. A hundred-thousand
87
+ row report with two such columns drained in 3.5 s; it now drains in 0.2 s.
88
+ Every answer is the value it was before: the rounding mode and the digit
89
+ count are both part of what a reused formatter is found by, so no call can
90
+ be handed one built for another.
91
+
92
+ - **`round`, `floor` and `ceil` name themselves when they refuse an `n`.** All
93
+ three threw `n must be a number from 0 to 100`, so a cell calling more than
94
+ one was told how it failed and never which call; the message now opens with
95
+ the function, as a reducer's already did. The located path still names the
96
+ cell — one cell holds as many calls as you write, which is the whole reason
97
+ the name is worth having.
98
+
99
+ **An `n` that cannot be read as a number at all now gets the same answer.**
100
+ A `BigInt` or a `Symbol` reaching `n` from render data used to surface the
101
+ runtime's own `Cannot convert a BigInt value to a number`, and an object
102
+ carrying a throwing `valueOf` surfaced whatever it threw — each located at
103
+ the cell but naming neither the function nor anything an author could act
104
+ on. All of them are now the same refusal.
105
+
106
+ - **`isDiagnostic` is documented for what it actually answers.** The README
107
+ said it was true for "one of the stack's located errors: quario's own, or one
108
+ thrown directly by xprsn, sjabloon, or padvinder", and its example read a
109
+ `false` as "one of your own functions failed". Only an engine's own error is
110
+ a diagnostic: quario's verdicts on a document — an unknown reducer, a
111
+ misused inline reducer, image bytes it will not vouch for — are located just
112
+ the same and are not diagnostics, so a host following that example rethrew
113
+ report faults as its own. Nothing about the guard changes; it never behaved
114
+ the way the page described.
115
+
116
+ - **A misused inline reducer says what it is, instead of how it folds.**
117
+ `{{ min(1, 2) }}` — the two-argument scalar `min` quario does not have —
118
+ failed at the cell with `(rows || []).map is not a function`, the fold's own
119
+ internals, naming neither the reducer nor the mistake. It now reads
120
+ `min is a reducer over an array, not a scalar function; got a number`, and a
121
+ second argument that is not a lambda says so in the same words rather than
122
+ reaching a host as `of is not a function`. Register a function of your own to
123
+ shadow a built-in reducer when a report needs the scalar.
124
+
125
+ **Two reducers used to answer instead of failing**, each reading a `length`
126
+ off a value that is not a collection: `count('ab')` presented `2` — a
127
+ string's own length, folded as if it were a count of rows — while `count(1)`
128
+ and `avg(1, 2)` presented nothing at all. All three are now the same located
129
+ error. A reducer over an **absent** array is unchanged and still not a
130
+ mistake: `sum(@.lines)` on a row carrying no lines is `0`, exactly as an
131
+ empty array gives.
132
+
133
+ ## [0.6.0] - 2026-09-07
134
+
135
+ ### Added
136
+
137
+ - **`imageError(path, said, cause)`**, the mint a render target raises an image
138
+ failure through. The engine vouches for an image's magic numbers and no
139
+ further, so every failure past them belongs to whichever target was sizing or
140
+ embedding the file — and each was wording it its own way. This names the item
141
+ that asked for the bytes and keeps the failing class and message behind it,
142
+ so one report reads the same however it is rendered. It is not a diagnostic:
143
+ `isDiagnostic` does not answer for one.
144
+
145
+ - **Formatted cells render dramatically faster.** The engine built a fresh
146
+ `Intl` formatter for every presented cell and now keeps them, keyed on
147
+ everything a formatter is built from — the kind, the locale, the digit count,
148
+ the currency code, and for a date its form and timezone. A report of a
149
+ hundred thousand formatted cells in mixed currencies went from 3.2 s to
150
+ 0.2 s rendering to HTML. Nothing about what a cell presents changes.
151
+
152
+ The store is bounded and empties past its bound rather than growing, so a
153
+ report presenting an unusual number of distinct formats costs what it always
154
+ did and never a formatter belonging to another cell.
155
+
156
+ - **`format` takes a digit count, and a date takes a form.** The declaration is
157
+ still a closed kind, and now the kind may carry the one modifier it
158
+ understands: `number`, `percent` and `currency` take `digits`, and `date`
159
+ takes `form`.
160
+
161
+ ```jsonc
162
+ { "header": "Rate", "value": "{{ @.rate }}",
163
+ "style": { "format": { "kind": "percent", "digits": 3 } } }
164
+ { "header": "Total", "value": "{{ @.amt }}",
165
+ "style": { "format": { "kind": "currency", "digits": 0 }, "currency": "EUR" } }
166
+ { "header": "Due", "value": "{{ @.due }}",
167
+ "style": { "format": { "kind": "date", "form": "long" } } }
168
+ ```
169
+
170
+ The bare kind stays the shorthand — `"number"` means
171
+ `{ "kind": "number" }` — so no existing declaration has to change.
172
+
173
+ `digits` is a whole number from 0 to 20 and overrides the count the kind
174
+ would otherwise present, **a currency's minor units included**: the example
175
+ above shows `€1,235` on the page and builds `"EUR"#,##0` in the worksheet.
176
+ The minor units remain the default, so a JPY cell still shows no decimals
177
+ beside a EUR one that shows two.
178
+
179
+ On `percent`, `digits` counts the places of the **presented percentage**, not
180
+ of the stored fraction: `digits: 2` on `0.12345` gives `12.35%`. Making the
181
+ stored value agree still takes `round(x, 4)` — the two count different
182
+ things, a factor of 100 apart.
183
+
184
+ `form` is one of `short`, `medium`, `long`, `full`. It is honoured exactly in
185
+ HTML and PDF, and **approximated in the worksheet**: a sheet date is a real
186
+ typed date its reader re-presents, so the shape is yours while the language a
187
+ month or weekday name is spelled in follows whoever opens the file. Pinning
188
+ that would stop the sheet reading naturally for its reader.
189
+
190
+ A modifier a kind does not understand is a definition error naming the cell —
191
+ `{ "kind": "date", "digits": 2 }` fails, as does a `digits` outside 0 to 20.
192
+ `format` layers whole: a cell that names it restates the whole declaration, so
193
+ naming a kind again resets the modifier. Only the whole declaration may be an
194
+ `=` expression; a computed count is written
195
+ `"={'kind':'number','digits':@.dp}"`. Custom `#,##0.00` patterns still belong
196
+ in `funcs`, and a fraction-digit count no longer needs one.
197
+
198
+ - **`sort` is stable**: rows whose keys compare equal keep the order they
199
+ arrived in. The engine has always sorted this way; the promise is new.
200
+
201
+ - `currencyOf(style, options)` joins the package's exports beside `format()`.
202
+ It answers which currency code a cell wears — its own when it declares one,
203
+ else the instance default — for a target that needs the code itself rather
204
+ than presented text. A cell that declared a code the engine could not accept
205
+ carries `style.currency` as `null`, so `style.currency ?? yourDefault` is the
206
+ wrong spelling and this helper is the right one.
207
+
208
+ - **A cell may name its own currency.** `currency` is a new style declaration
209
+ beside `format` — a three-letter ISO 4217 code saying what a money cell is
210
+ denominated in.
211
+
212
+ ```jsonc
213
+ { "header": "Amount", "value": "{{ @.amt }}",
214
+ "style": { "format": "currency", "currency": "=@.ccy" } }
215
+ ```
216
+
217
+ Until now the code lived only on the instance, so a report whose rows arrive
218
+ in different currencies — a revenue listing, a statement, an outstanding-
219
+ invoices table — had to format through `funcs`, and that costs the value: a
220
+ formatter returns a string, so the cell leaves the typed seam and the
221
+ worksheet gets text with no number format. A declared code keeps the number.
222
+
223
+ The cell's own code beats the instance's, which stays the default for
224
+ documents that only ever hold one denomination. Locale and timezone do not
225
+ follow it into the document: a locale describes the reader, while a currency
226
+ code describes the value. The declaration takes an expression like any other,
227
+ so a total under a group keyed by currency reads `"=byCcy.key"`. It shares
228
+ `format`'s sites exactly — text items, column cells, headers and totals, and
229
+ a column's own `style.currency` declares an amount column once — and, like
230
+ `format`, it is refused on an image, on `row.style` and on the report
231
+ default. It is unread without `format: "currency"`.
232
+
233
+ A literal that is not three uppercase letters is a definition error. An
234
+ expression that resolves to something else stays lenient, and the cell then
235
+ renders as plain display text: it does **not** fall back to the instance's
236
+ currency, because labelling a figure in a denomination nobody named is worse
237
+ than not formatting it.
238
+
239
+ - **`round`, `floor`, `ceil` and `abs` are callable from any expression**,
240
+ without registering anything. `round(x, n)` gives `x` to `n` decimal places;
241
+ `n` defaults to 0 and must be a number from 0 to 100; a fraction in it
242
+ truncates. This is the lever a report needs when arithmetic across exact values lands on an inexact one:
243
+ a receipt whose `{{ $.subtotal + $.vatLow + $.vatHigh }}` computes
244
+ `83.75999999999999` writes `83.76` once the sum is wrapped in `round(…, 2)`.
245
+ It matters in the two targets that keep the number rather than presenting it
246
+ — the workbook stores the value, and a CSV field shows it as text with no
247
+ number format to hide behind. A registered function of the same name still
248
+ wins, so nothing an existing report does changes.
249
+
250
+ They round the **decimal you wrote**, not the binary value it is stored as:
251
+ `round(2.675, 2)` is `2.68` and `round(0.615, 2)` is `0.62`. Note that a
252
+ `percent` cell presents its value multiplied by 100, so matching a
253
+ two-decimal percent display takes `round(x, 4)`. A value that is not a finite
254
+ number passes through untouched rather than becoming zero; an `n` outside the
255
+ range is an error naming the cell it came from.
256
+
257
+ - `split-start` events carry `path`, the split definition's schema path, as
258
+ `item` and `image` events already do. A consumer can now name the definition
259
+ behind a split without tracking position.
260
+
261
+ - `fractionDigits(kind, options)` is exported beside `format()`: how many
262
+ fraction digits a kind presents, or nothing for a kind that carries no
263
+ count. It is the table the official targets read, and it is public so a
264
+ consumer writing its own target presents the same digits they do.
265
+
266
+ ### Changed
267
+
268
+ - **A `date` cell with no `form` now presents `medium`, not the runtime's own
269
+ default.** A `date` that names no form used to render whatever the platform's
270
+ default date format was — under `en-US`, `8/14/2026` — which matched none of
271
+ the four forms you can now ask for. It renders `Aug 14, 2026` instead, and
272
+ `14 Aug 2026` under `en-IE`. In the worksheet the number format moves from
273
+ `yyyy-mm-dd` to `dd mmm yyyy`.
274
+
275
+ If you want the old shape back, say so: `{ "kind": "date", "form": "short" }`
276
+ is the nearest of the four. This is the one change here that moves output
277
+ under an existing report.
278
+
279
+ - **`style.format` crosses the event stream resolved, not as authored.** A
280
+ custom target reading `style.format` now sees `{ kind, digits }` or
281
+ `{ kind, form }` where it saw a bare string: `"number"` arrives as
282
+ `{ kind: "number", digits: 2 }` and `"date"` as
283
+ `{ kind: "date", form: "medium" }`. A `currency` whose code cannot be read
284
+ arrives as `{ kind: "currency" }` with no count at all, which is the signal
285
+ to write no number format. Read `style.format.kind` where you read
286
+ `style.format`, and take the digit count off the declaration rather than
287
+ computing one.
288
+
289
+ - **`fractionDigits` takes the whole style.** Its first argument was the kind
290
+ and is now the style, matching `format()` and `currencyOf()`.
291
+ `fractionDigits("number")` becomes
292
+ `fractionDigits({ format: "number" })`. Targets no longer need it at all —
293
+ the count rides on the declaration the stream carries.
294
+
295
+ - **`typed()`'s second argument is the resolved declaration.** Pass
296
+ `style.format` as it arrives on the stream; it reads the kind from the
297
+ object. The shorthand string is still accepted, and omitting the argument is
298
+ unchanged.
299
+
300
+ - **A rejected box value is now dropped from the resolved style rather than
301
+ carried on it.** A `border*` or `padding*` declaration written as an `=`
302
+ expression used to cross the render stream with whatever the expression
303
+ answered, even a value the vocabulary rejects; every other name was already
304
+ dropped. It is now dropped too, so one rule covers the whole vocabulary and
305
+ `currency` is its only exception.
306
+
307
+ Nothing a report renders changes. A border side is still won whole by the
308
+ node that names any of its three parts — that is now settled from the
309
+ declarations rather than from what they resolve to, so a rejected part still
310
+ cannot hand the node its row's other two. A side left incomplete still
311
+ contributes nothing rather than becoming a solid black stroke, and a rejected
312
+ padding still falls to the target's own inset.
313
+
314
+ What changes is what a consumer reading the stream itself sees. Where a cell
315
+ had `style.borderTopColor` holding an unusable value, the key is now absent:
316
+
317
+ ```js
318
+ // before
319
+ cell.style; // { borderTopWidth: 1, borderTopColor: "2-6-A-2-2" }
320
+ // after
321
+ cell.style; // { borderTopWidth: 1 }
322
+ ```
323
+
324
+ Every target in this release already checked such values before reading them
325
+ and is unaffected. A consumer that inferred border-side ownership from which
326
+ keys were present should read the declarations instead; one that validates
327
+ what it reads needs no change.
328
+
329
+ - **`format()` now takes the cell's resolved `style` instead of its `format`
330
+ kind.** The signature is `format(value, style, options)`, where it was
331
+ `format(value, kind, options)`. This is what lets the helper read a cell's own
332
+ `currency` code alongside the kind. Callers pass one property less:
333
+
334
+ ```js
335
+ // before
336
+ format(token.value, style?.format, intl);
337
+ // after
338
+ format(token.value, style, intl);
339
+ ```
340
+
341
+ A caller holding only a kind wraps it as `{ format: kind }`.
342
+
343
+ - **A table's `total` is now a block holding its rows**, so what belongs to the
344
+ totals as a whole can be said once. Before, `total` was the array of rows
345
+ itself:
346
+
347
+ ```jsonc
348
+ "total": [{ "cells": [...] }, { "cells": [...] }]
349
+ ```
350
+
351
+ Now it is an object whose `rows` is that same array, plus a `style` and a
352
+ `visible` of its own:
353
+
354
+ ```jsonc
355
+ "total": {
356
+ "style": { "paddingTop": 0, "paddingBottom": 0 },
357
+ "rows": [{ "cells": [...] }, { "cells": [...] }]
358
+ }
359
+ ```
360
+
361
+ The rows themselves are unchanged — still `{ "cells", "style?", "visible?" }`,
362
+ still covering every column exactly once. A `total` that is still an array is
363
+ a definition error reading `detail.total: expected an object`, and a `total`
364
+ with no `rows` reads `detail.total.rows: expected an array`. Total cell paths
365
+ gain the one segment: `detail.total.rows[r].cells[i]`, which is what a
366
+ `total-row` cell now carries as its `path` and what a located problem names.
367
+
368
+ The block's `style` is the layer below each row's — box under box, every
369
+ other declaration under the row's own — and takes a total row's vocabulary,
370
+ the whole style set less flow spacing and `format`. Where the block, a row
371
+ and a cell all speak, the innermost one that names any of a border side's
372
+ three keys takes that side whole, exactly as a cell already did over a row;
373
+ padding layers per name. Exact `false` on the block's `visible` omits the
374
+ totals entirely: the rows are never evaluated, none of them votes on a
375
+ width-less column, and the output is what leaving `total` out gives.
376
+
377
+ This is why the change earns a break: a table's header row has `detail.header`
378
+ and its data rows have `detail.row`, but the totals — the one kind whose rows
379
+ a document writes out one by one — had no level above them, so "these sit at
380
+ the band's line pitch, not the table's row pitch" had to be spelled on every
381
+ row. `columns` is unaffected and needs no such block: a declaration reaching
382
+ every column reaches every data cell, which is what `detail.row`'s style
383
+ already says from the other side.
384
+
385
+ - **`table-start` now carries the header row**, instead of a `header` cell on
386
+ each column that a custom target had to collect for itself. The event's
387
+ `header` is `{ cells, style? }` — the same shape a `row` or `total-row`
388
+ event has — where `cells` holds one cell per column no neighbour's `span`
389
+ covers, so it is shorter than `columns` whenever a header spans. Each header
390
+ cell carries its column's `path`, as a row cell does; a spanning header takes
391
+ the first column it covers. What is left on `columns` is the geometry: one
392
+ entry per column, holding a `width` percentage when the document authored
393
+ one. Nothing an existing report produces changes in any of the shipped
394
+ targets — this is the seam between the engine and a target, not the document.
395
+
396
+ ```js
397
+ // before
398
+ const headers = event.columns
399
+ .filter((column) => column.header)
400
+ .map((column) => Object.assign({}, column.header, { path: column.path }));
401
+ const headerStyle = event.style;
402
+
403
+ // after
404
+ const headers = event.header.cells;
405
+ const headerStyle = event.header.style;
406
+ ```
407
+
408
+ `columns[i].header`, `columns[i].path` and `table-start.style` are gone. A
409
+ target that reads only `columns[i].width` is unaffected.
410
+
411
+ - **`format` now presents a fixed number of fraction digits, and every target
412
+ presents the same number.** Until now the digits were whatever each edge
413
+ happened to default to: `format: "number"` showed up to three decimals in
414
+ HTML and PDF and exactly two in a worksheet, and `format: "percent"` showed
415
+ none in HTML and PDF against the worksheet's two — so one cell in one
416
+ document read `12%` in the PDF and `12.35%` in the workbook beside it.
417
+
418
+ `number` and `percent` are now **two places, always**, in every target that
419
+ presents. `currency` is its currency's own minor units, so a JPY amount has
420
+ none and a BHD amount has three, where every currency previously got two.
421
+ Author-visible, with no document change:
422
+
423
+ | value | before | now |
424
+ | ------------------ | ----------- | ---------- |
425
+ | `number` `1000` | `1,000` | `1,000.00` |
426
+ | `number` `0.12345` | `0.123` | `0.12` |
427
+ | `percent` `0.21` | `21%` | `21.00%` |
428
+ | `currency` JPY | `¥1,234.57` | `¥1,235` |
429
+
430
+ Only the digit count converges. Grouping separators, a currency symbol's
431
+ position and a date's shape still follow each target's own conventions, and
432
+ in a worksheet they follow whoever opens the file. `date` is unchanged.
433
+ A report that needs different digits formats through `funcs`, as before.
434
+
435
+ - A `currency` code that is not a readable currency now presents nothing in
436
+ every target, rather than the worksheet inventing a number format from it
437
+ while the other targets fell back to the unformatted value.
438
+
439
+ ### Fixed
440
+
441
+ - **A split slot is typed with the declarations the traversal accepts.**
442
+ `SplitSlot` reused the band items' style types, so TypeScript refused
443
+ `valign` on an image slot — legal there, and only there — and admitted
444
+ `spaceBefore`/`spaceAfter` on a slot, which the engine rejects. Two new
445
+ exported types, `SlotStyleDeclarations` and `SlotImageStyleDeclarations`,
446
+ now say what a slot may declare.
447
+
448
+ - **A page band closure is typed as what it returns.** `PageBandRenderers`
449
+ declared `header`/`footer` as returning item and image events only, while a
450
+ split in a page band has always come back as its bracket — `split-start`,
451
+ one event per slot, `split-end`. The closures now return `PageBandEvent[]`,
452
+ a new exported union naming all four, so a TypeScript host reading a page
453
+ band no longer types `role` onto a `split-end` that carries none.
454
+
455
+ - **A computed style value the schema does not accept now leaves the layer
456
+ below in place**, which is what the schema has always said it does —
457
+ "contributes nothing, same as omit". The name is dropped from the resolved
458
+ style rather than passed on, so no target has to decide what an unreadable
459
+ value means.
460
+
461
+ Before, the value reached each target and they disagreed. A detail item with
462
+ `"bold": "=@.flag"` over a `1` rendered bold in a PDF and a worksheet but not
463
+ in HTML. On a report header, where those two targets carry a heavier default
464
+ of their own, a `0` rendered _not_ bold in both while HTML left the header
465
+ bold. The same held for every other declaration: a report header whose
466
+ `"size": "=@.pt"` resolved to `"big"` rendered at 14pt in HTML, at the body
467
+ size in a PDF, and at no size at all in a worksheet. Now all three keep the
468
+ layer below in every case, and only a real `false` turns a flag off.
469
+
470
+ This covers `family`, `size`, the five flags, `color`, `background`, `align`,
471
+ `valign`, `format`, `spaceBefore` and `spaceAfter`, and only where the value
472
+ comes from an `=` expression — a literal has always been checked when the
473
+ report was compiled. It reaches inside a split too: a slot whose `format`
474
+ expression resolves to something other than the four kinds now keeps the
475
+ split's own kind instead of overwriting it.
476
+
477
+ Two declarations are deliberately unchanged. A border or padding name is kept
478
+ rather than dropped, because a node that names any part of a border side
479
+ takes that side whole from its row — dropping the name would hand the node
480
+ the row's other two and draw an edge nobody declared. And a `currency` code
481
+ that is not a code still crosses as `null` rather than disappearing, so a
482
+ cell that named a denomination is never presented under another one.
483
+
484
+ - A `format` kind that an `=` expression resolved to the name of a built-in
485
+ object member — `"toString"`, `"constructor"` — now contributes nothing and
486
+ the cell falls back to display text, as the documented rule says any
487
+ unrecognised kind does. Before, such a cell could render `[object Undefined]`.
488
+ A literal was never affected: it is checked against the four kinds.
489
+
490
+ - **A pinned report header now refuses a lead on every band that can sit under
491
+ its box, and only where the document settles which one that is.** When
492
+ `header` declares a `height`, an authored `spaceBefore` on the first item of
493
+ the band below the box would push that band off the pin, so it is refused.
494
+ Which band is below the box was read too narrowly, and which item leads it
495
+ too eagerly.
496
+
497
+ Reports that used to validate and now do not: a lead on the `empty` band's
498
+ first item, and a lead on a group header below the first one. Both were
499
+ already refused at render, but only on the render that reached them — an
500
+ `empty` band's lead detonated the first time a filter emptied the row set,
501
+ on a report that had validated clean for months. Both are definition errors
502
+ now, on every render.
503
+
504
+ Reports that used to be refused and now validate: a lead on an item that may
505
+ not appear at all — one whose `visible` is an expression, or an image whose
506
+ `source` yields nothing. Such an item was blamed as though it always
507
+ occupied, so a document that renders correctly could not be compiled. The
508
+ check now stays silent wherever only the data decides which item comes first,
509
+ and the render refuses it at the moment the answer exists.
510
+
511
+ Two smaller repairs come with it. A `spaceBefore` that is not a paintable
512
+ number — negative, `NaN`, infinite — no longer produces two problems on one
513
+ key, one telling you to make it `0` and one telling you it is not a valid
514
+ measurement; only the second is reported, because it is the one that is
515
+ wrong. And a `split` that leads a band now names itself in the error rather
516
+ than reporting against the report header, which is a band this rule never
517
+ covers.
518
+
10
519
  ## [0.5.0] - 2026-09-05
11
520
 
12
521
  ### Added