quario 0.7.0 → 0.9.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
@@ -1,15 +1,105 @@
1
- # Changelog
2
-
3
- All notable changes to quario are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.7.0] - 2026-09-07
11
-
12
- ### Added
1
+ # quario
2
+
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Every target factory now refuses an option it does not understand.** An unknown key, a key with
8
+ a value of the wrong type, or an `options` that is not an object throws a `TypeError` at the
9
+ factory call, naming the option path — `options.meta.title`, `options.page.size`. `@quario/docx`
10
+ already behaved this way. `@quario/pdf`, `@quario/html`, `@quario/xlsx` and `@quario/layout` read
11
+ the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
12
+ signal at any point. `csv()` takes no options, and it refuses an argument rather than discarding
13
+ one.
14
+
15
+ The check itself is one thing, so the engine now exports it: `hostOptions` closes a key set and
16
+ `hostMeta` validates the `{ title, author, subject }` contract the document targets share. A
17
+ fourth document property is one edit rather than three.
18
+
19
+ **What this changes for you.** One options object spread across several targets stops working if
20
+ any target does not know one of its keys — `const o = { page, fonts, meta }` passed to `pdf(o)`,
21
+ `html(o)` and `xlsx(o)` is three different key sets. An object holding only what its consumers
22
+ share is unaffected, so `{ page, fonts }` into both `pdf()` and `layout()` still works.
23
+ TypeScript does not warn about this: excess-property checking fires on an object literal and not
24
+ on a variable, so a bag held in a `const` compiles clean and throws when you call the factory.
25
+
26
+ Nullish is absence everywhere, at both levels, so `{ meta: config.meta ?? null }` and
27
+ `{ meta: { title: config.title } }` over a config that carries neither are both fine.
28
+ `html({ paths })` now takes `true` or `false` rather than anything truthy, so `paths: "no"` throws
29
+ instead of turning path stamping on. A `fonts` mapping given as an array is refused by
30
+ `@quario/html` and `@quario/layout` rather than read as families named `0`, `1`, `2`.
31
+
32
+ Every host-option failure is now a `TypeError` rather than a plain `Error`. A `catch` that tests
33
+ `instanceof Error` is unaffected. One that compares the constructor is not.
34
+
35
+ `@quario/docx` is relaxed in three places, each of them now accepting what it refused: `meta: null`
36
+ and `page: null` are absence rather than errors, a property written as `meta: { title: undefined }`
37
+ is a property you did not write rather than one of the wrong type, and an inherited enumerable key
38
+ is no longer reported as an option you wrote.
39
+
40
+ `@quario/pdf`, `@quario/xlsx` and `@quario/docx` now copy `meta` at the factory call. Mutating the
41
+ object you passed no longer changes what a configured target writes.
42
+
43
+ ### Patch Changes
44
+
45
+ - The engine verifies a license key against a new signing key.
46
+
47
+ ## 0.8.0
48
+
49
+ ### Minor Changes
50
+
51
+ - **BREAKING: an image whose header does not state its size now fails on every
52
+ target.** A PNG or JPEG truncated before its dimensions used to render on the
53
+ HTML and CSV targets — the browser coped, and CSV has no picture to place —
54
+ while failing on the PDF and XLSX targets, which have to know how big a
55
+ picture is before they can put it anywhere. One file, two verdicts, depending
56
+ on where the report was sent.
57
+
58
+ It is now a single render error, raised where every other verdict on image
59
+ bytes is raised and naming the item's `source` the same way — for example
60
+ `header[0].source [=$.input.logo]: could not read the image's size from its bytes`.
61
+ A report that renders as a PDF today is unaffected: those bytes already
62
+ failed there. What changes is a report that renders as HTML today on bytes no other target
63
+ would accept: it now fails, and the file it names is one no target could ever
64
+ have drawn correctly.
65
+
66
+ Nothing further has changed about what the engine promises: a file whose size
67
+ reads but whose pixel data is corrupt is still nobody's guarantee, and each
68
+ target does whatever its own machinery does with it.
69
+
70
+ - **The image event carries the picture's size.** `width` and `height`, in
71
+ pixels, read from the same header the format is sniffed from. A target — the
72
+ built-in ones, or your own — places a picture from two fields instead of
73
+ parsing a PNG's IHDR and a JPEG's frame header for itself.
74
+ - **A page-number token says which page value it is.** A value token whose
75
+ interpolation is exactly `{{ page.number }}` or `{{ page.total }}` now carries
76
+ `field` on the event stream, holding that same string. The `value` is
77
+ unchanged — the number the page band was called with — so nothing renders
78
+ differently; `field` tells a target whose own document format numbers pages
79
+ that it may write its own live field there instead of the number this render
80
+ saw. Anything computed from them (`{{ page.number + 1 }}`, `{{ pad(page.total)
81
+ }}`) is an ordinary value and carries no `field`, so write the bare token
82
+ wherever a live number matters.
83
+
84
+ ### Patch Changes
85
+
86
+ - **A PNG wider or taller than 65535 pixels is no longer misplaced.** Its
87
+ dimensions were read from the low half of each of IHDR's four-byte fields, so
88
+ a 70000-pixel panorama measured 4464 pixels and was laid out at that size,
89
+ confidently and silently.
90
+ - **Every published README says where the documentation is.** Each package now
91
+ carries a Documentation section pointing at the reference, at the report schema
92
+ that normatively specifies what a report may declare, and at the package's own
93
+ API. The paragraphs that used to end on an unstated contract — the event
94
+ stream's field semantics, the style vocabulary, page columns, the Content
95
+ Security Policy a fragment with images needs, the formula mangling, and each
96
+ target's own contract — link the page that states it. Every link is an absolute
97
+ URL, so it resolves from the npm package page as readily as from an installed
98
+ copy.
99
+
100
+ ## 0.7.0
101
+
102
+ ### Minor Changes
13
103
 
14
104
  - **Styled runs.** Bold a word, colour a phrase, or format one value inside a
15
105
  sentence, and have it survive into the PDF and the spreadsheet — not only the
@@ -47,8 +137,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
47
137
  seen in the definition alone, without data, so a quiet `warnings` list is not
48
138
  a promise that every declaration will be read.
49
139
 
50
- ### Changed
51
-
52
140
  - **A `format` declaration now needs one value to speak about, and a cell can
53
141
  give it one.** Previously a `format` on a cell reached every interpolation in
54
142
  it and skipped the literal text between them, which meant
@@ -78,8 +166,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
78
166
  twice. `plan()` reports the cells this affects wherever it can see them
79
167
  without data.
80
168
 
81
- ### Fixed
82
-
83
169
  - **`round`, `floor` and `ceil` reuse their formatters instead of rebuilding
84
170
  one per call.** The three reach the decimal you wrote through `Intl`, and
85
171
  each call built a fresh formatter to do it — so a detail column of
@@ -88,7 +174,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
174
  Every answer is the value it was before: the rounding mode and the digit
89
175
  count are both part of what a reused formatter is found by, so no call can
90
176
  be handed one built for another.
91
-
92
177
  - **`round`, `floor` and `ceil` name themselves when they refuse an `n`.** All
93
178
  three threw `n must be a number from 0 to 100`, so a cell calling more than
94
179
  one was told how it failed and never which call; the message now opens with
@@ -112,7 +197,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
197
  the same and are not diagnostics, so a host following that example rethrew
113
198
  report faults as its own. Nothing about the guard changes; it never behaved
114
199
  the way the page described.
115
-
116
200
  - **A misused inline reducer says what it is, instead of how it folds.**
117
201
  `{{ min(1, 2) }}` — the two-argument scalar `min` quario does not have —
118
202
  failed at the cell with `(rows || []).map is not a function`, the fold's own
@@ -130,9 +214,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
130
214
  mistake: `sum(@.lines)` on a row carrying no lines is `0`, exactly as an
131
215
  empty array gives.
132
216
 
133
- ## [0.6.0] - 2026-09-07
217
+ ## 0.6.0
134
218
 
135
- ### Added
219
+ ### Minor Changes
136
220
 
137
221
  - **`imageError(path, said, cause)`**, the mint a render target raises an image
138
222
  failure through. The engine vouches for an image's magic numbers and no
@@ -141,7 +225,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
225
  that asked for the bytes and keeps the failing class and message behind it,
142
226
  so one report reads the same however it is rendered. It is not a diagnostic:
143
227
  `isDiagnostic` does not answer for one.
144
-
145
228
  - **Formatted cells render dramatically faster.** The engine built a fresh
146
229
  `Intl` formatter for every presented cell and now keeps them, keyed on
147
230
  everything a formatter is built from — the kind, the locale, the digit count,
@@ -197,14 +280,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
197
280
 
198
281
  - **`sort` is stable**: rows whose keys compare equal keep the order they
199
282
  arrived in. The engine has always sorted this way; the promise is new.
200
-
201
283
  - `currencyOf(style, options)` joins the package's exports beside `format()`.
202
284
  It answers which currency code a cell wears — its own when it declares one,
203
285
  else the instance default — for a target that needs the code itself rather
204
286
  than presented text. A cell that declared a code the engine could not accept
205
287
  carries `style.currency` as `null`, so `style.currency ?? yourDefault` is the
206
288
  wrong spelling and this helper is the right one.
207
-
208
289
  - **A cell may name its own currency.** `currency` is a new style declaration
209
290
  beside `format` — a three-letter ISO 4217 code saying what a money cell is
210
291
  denominated in.
@@ -257,14 +338,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
257
338
  - `split-start` events carry `path`, the split definition's schema path, as
258
339
  `item` and `image` events already do. A consumer can now name the definition
259
340
  behind a split without tracking position.
260
-
261
341
  - `fractionDigits(kind, options)` is exported beside `format()`: how many
262
342
  fraction digits a kind presents, or nothing for a kind that carries no
263
343
  count. It is the table the official targets read, and it is public so a
264
344
  consumer writing its own target presents the same digits they do.
265
-
266
- ### Changed
267
-
268
345
  - **A `date` cell with no `form` now presents `medium`, not the runtime's own
269
346
  default.** A `date` that names no form used to render whatever the platform's
270
347
  default date format was — under `en-US`, `8/14/2026` — which matched none of
@@ -285,18 +362,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
285
362
  to write no number format. Read `style.format.kind` where you read
286
363
  `style.format`, and take the digit count off the declaration rather than
287
364
  computing one.
288
-
289
365
  - **`fractionDigits` takes the whole style.** Its first argument was the kind
290
366
  and is now the style, matching `format()` and `currencyOf()`.
291
367
  `fractionDigits("number")` becomes
292
368
  `fractionDigits({ format: "number" })`. Targets no longer need it at all —
293
369
  the count rides on the declaration the stream carries.
294
-
295
370
  - **`typed()`'s second argument is the resolved declaration.** Pass
296
371
  `style.format` as it arrives on the stream; it reads the kind from the
297
372
  object. The shorthand string is still accepted, and omitting the argument is
298
373
  unchanged.
299
-
300
374
  - **A rejected box value is now dropped from the resolved style rather than
301
375
  carried on it.** A `border*` or `padding*` declaration written as an `=`
302
376
  expression used to cross the render stream with whatever the expression
@@ -435,23 +509,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
435
509
  - A `currency` code that is not a readable currency now presents nothing in
436
510
  every target, rather than the worksheet inventing a number format from it
437
511
  while the other targets fell back to the unformatted value.
438
-
439
- ### Fixed
440
-
441
512
  - **A split slot is typed with the declarations the traversal accepts.**
442
513
  `SplitSlot` reused the band items' style types, so TypeScript refused
443
514
  `valign` on an image slot — legal there, and only there — and admitted
444
515
  `spaceBefore`/`spaceAfter` on a slot, which the engine rejects. Two new
445
516
  exported types, `SlotStyleDeclarations` and `SlotImageStyleDeclarations`,
446
517
  now say what a slot may declare.
447
-
448
518
  - **A page band closure is typed as what it returns.** `PageBandRenderers`
449
519
  declared `header`/`footer` as returning item and image events only, while a
450
520
  split in a page band has always come back as its bracket — `split-start`,
451
521
  one event per slot, `split-end`. The closures now return `PageBandEvent[]`,
452
522
  a new exported union naming all four, so a TypeScript host reading a page
453
523
  band no longer types `role` onto a `split-end` that carries none.
454
-
455
524
  - **A computed style value the schema does not accept now leaves the layer
456
525
  below in place**, which is what the schema has always said it does —
457
526
  "contributes nothing, same as omit". The name is dropped from the resolved
@@ -486,7 +555,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
486
555
  the cell falls back to display text, as the documented rule says any
487
556
  unrecognised kind does. Before, such a cell could render `[object Undefined]`.
488
557
  A literal was never affected: it is checked against the four kinds.
489
-
490
558
  - **A pinned report header now refuses a lead on every band that can sit under
491
559
  its box, and only where the document settles which one that is.** When
492
560
  `header` declares a `height`, an authored `spaceBefore` on the first item of
@@ -516,9 +584,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
516
584
  than reporting against the report header, which is a band this rule never
517
585
  covers.
518
586
 
519
- ## [0.5.0] - 2026-09-05
587
+ ## 0.5.0
520
588
 
521
- ### Added
589
+ ### Minor Changes
522
590
 
523
591
  - **`STYLE_NAMES`**, every name in the style vocabulary as a read-only list,
524
592
  in the order the specification's table lists them. A tool that offers the
@@ -540,9 +608,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
540
608
  on a band image, whose boxes have no such slack. A row's or a split's layers
541
609
  under its cells' or slots' own. Undeclared is not a declaration: each target
542
610
  keeps its own default.
543
-
544
- ### Changed
545
-
546
611
  - **A table row's `style` now resolves onto that row's cells**, the box
547
612
  included, instead of meaning something different on every output. Before,
548
613
  padding and border on `detail.header`, `detail.row` or a total row's `style`
@@ -565,9 +630,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
565
630
  carries only what layers by ordinary means, and a row whose whole block was
566
631
  box carries no `style` at all.
567
632
 
568
- ## [0.4.0] - 2026-09-03
633
+ ## 0.4.0
569
634
 
570
- ### Changed
635
+ ### Minor Changes
571
636
 
572
637
  - **`format: "date"` now reads a date string, not only a `Date`.** A JSON
573
638
  document has no date type, so the kind could not be reached from parsed
@@ -591,9 +656,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
591
656
  - **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
592
657
  opts into the same revival, for consumers that write typed cells. The
593
658
  one-argument call is unchanged.
594
-
595
- ### Fixed
596
-
597
659
  - **The TypeScript declarations accept the box.** The per-side `padding*` and
598
660
  `border*` names, and the table's `detail.header`, were validated by the
599
661
  engine from 0.3.0 but were missing from the shipped declarations, so a
@@ -608,9 +670,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
608
670
  about rendering changes: a report that compiled through a cast produces the
609
671
  same output without one.
610
672
 
611
- ## [0.3.0] - 2026-09-02
673
+ ## 0.3.0
612
674
 
613
- ### Added
675
+ ### Minor Changes
614
676
 
615
677
  - **`format` is a closed style name.** `number` / `currency` / `percent` /
616
678
  `date` on text items, column cells, headers, and totals. Not images, not
@@ -618,19 +680,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
618
680
  live on `quario({ locale, currency, timeZone })`. The public `format()`
619
681
  helper is how targets present a kind; a kind on the wrong type contributes
620
682
  nothing.
621
-
622
683
  - **The report header may pin a `height` from the page top.** Dual-shaped
623
684
  like `detail`: an item array, or `{ height, items }`. `page.margin` is a
624
685
  document field (one number, all four sides), required with `height` and
625
686
  legal without. Authored `spaceBefore` on the first occupying item of the
626
687
  next band is refused.
627
-
628
688
  - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
629
689
  before or after a band item, in points, including band images and splits as
630
690
  band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
631
691
  and split slots refuse the names. `spaceBefore` drops at a fresh body page
632
692
  or strip top; page-band items keep it. Leading and inset stay cut.
633
-
634
693
  - **Per-side padding and border on the closed style vocabulary.**
635
694
  `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
636
695
  `border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
@@ -640,18 +699,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
640
699
  than a solid black stroke. The names are legal wherever `background` is,
641
700
  including images, plus `row.style`. The report default still takes only
642
701
  `family` and `size`. Column `%` widths are border-box.
643
-
644
702
  - **`detail.header` is the header-row box.** `{ style }` only, a different
645
703
  path from `columns[i].header`. It crosses the seam as `table-start.style`.
646
-
647
- ### Changed
648
-
649
704
  - **A table total is N rows.** Before, `total` was one row: a flat cell
650
705
  array or `{ style, cells }`. After, it is absent or a non-empty array of
651
706
  `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
652
707
  `total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
653
708
  The stream yields one `total-row` per emitted row.
654
-
655
709
  - **A visible text item occupies a line at its own `size`.** Empty display
656
710
  used to take the report default's leading in PDF and collapse in HTML;
657
711
  `"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
@@ -660,9 +714,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
660
714
  table cells stay contentless for height. This is not a spacing primitive;
661
715
  `visible: false` is still how an item leaves the layout.
662
716
 
663
- ## [0.2.0] - 2026-09-01
717
+ ## 0.2.0
664
718
 
665
- ### Added
719
+ ### Minor Changes
666
720
 
667
721
  - **A report default: one `style` block for the whole document.** A top-level
668
722
  `style` beside `header`/`detail`/`footer` states the typeface a report is set
@@ -672,21 +726,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
672
726
  a text declaration on an image item is. Values are literals or `=`
673
727
  expressions like any other style block's, resolved once per render in report
674
728
  scope.
675
-
676
729
  - **`report-start` carries the resolved report default as `style`.** A
677
730
  report-level fact, never merged into an item's own `style`: an event's
678
731
  `style` stays what the author wrote on that node, and a consumer composes the
679
732
  default itself, once — under its own band-role defaults and under every
680
733
  event's own style. Absent when the report declares none, so a consumer
681
734
  written before this field renders in its own baseline exactly as it did.
682
-
683
735
  - **An `uppercase` style declaration.** A boolean beside `bold` and `italic`,
684
736
  literal or an `=` expression, for the capitalised column labels business
685
737
  forms are usually set with. It is capitals, not small caps — real small caps
686
738
  need a font feature the PDF target's built-in faces cannot supply, so the
687
739
  declaration promises only what every target can draw. Image items keep
688
740
  refusing it, as they refuse every text declaration.
689
-
690
741
  - **A `split` item places values across the line instead of down the band.**
691
742
  The invoice header's "seller left, customer right", which a band could not
692
743
  say before. `{ "type": "split", "slots": [...] }` takes two or more slots,
@@ -697,13 +748,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
697
748
  a slot that renders nothing keeps its width, so a line's geometry never
698
749
  moves with the data. Splits may appear in every item array except table and
699
750
  total cells.
700
-
701
751
  - **`split-start` / `split-end` bracket a split's slots on the event stream.**
702
752
  `split-start` carries the slot geometry, then one ordinary `item` or `image`
703
753
  event per slot in order, then `split-end`. Existing consumers need no
704
754
  change: the walk driver's missing-handler rule means a target that ignores
705
755
  the bracket still receives the slot items and renders them stacked.
706
-
707
756
  - **`quario().plan(schema, funcs?)` hands the whole traversal over at once.**
708
757
  Returns `{ report, problems, anchors }` from one descent: the compiled
709
758
  report (`null` while the document has problems), every problem as
@@ -727,12 +776,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
727
776
  display rule behind the token join, re-exported beside `text()` so a stream
728
777
  consumer that stringifies token values itself renders exactly what the
729
778
  official targets render — Dates included.
730
-
731
779
  - **`maxDepth: Infinity` opts a query budget out.** The data query's traversal
732
780
  budgets accept an explicit `Infinity` per key for "this budget, unbounded".
733
781
  The 500-deep default is unchanged — it is now padvinder's own, applied for
734
782
  every consumer rather than added by quario at the seam.
735
-
736
783
  - **Located data-query errors carry padvinder's code and span.** A `data`
737
784
  query that does not parse now surfaces with padvinder's `code` and
738
785
  `start`/`end` offsets into the query you wrote — filter faults included —
@@ -742,23 +789,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
742
789
  `PADVINDER_UNKNOWN_FUNCTION`, or `PADVINDER_SYNTAX` for a path character or
743
790
  filter body that is open-endedly not a query. Traversal budgets exceeded at
744
791
  render time keep `limit`/`actual` and carry no span.
745
-
746
- ### Changed
747
-
748
792
  - **Cells render straight over the engine scope chain.** A text cell no longer
749
793
  allocates a wrapper scope and an anchor pair per cell per row — quario's
750
794
  chain already binds `$` at the render base and `@` on the detail row, and
751
795
  sjabloon now renders over it as-is. A 4-column stream over a million rows
752
796
  went from 1.7s to 0.5s. No report changes what it renders. Requires
753
797
  sjabloon 0.11.
754
-
755
798
  - **A compiled report's `functions` carry signatures.** Each entry is now
756
799
  `{ name, arity, doc? }` instead of a bare name — `arity` from the function's
757
800
  declared parameter count (or its own numeric `arity` where rest parameters
758
801
  mislead `length`), `doc` from an own `doc` string when it carries one — in
759
802
  the same call-first-seen order. `names` is unchanged. Requires xprsn 0.11
760
803
  and sjabloon 0.11.
761
-
762
804
  - **A bare `Date` renders as ISO 8601 UTC, the same on every machine.**
763
805
  Display text for a `Date` value was `String(date)`, which bakes the
764
806
  rendering host's timezone and locale into the output — so one report
@@ -767,7 +809,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
767
809
  rule; an invalid `Date` keeps its deterministic `Invalid Date` text.
768
810
  Reports that want a formatted date keep using a registered function,
769
811
  exactly as before.
770
-
771
812
  - **A bad literal pattern in the data query is a definition error.** A typo'd
772
813
  I-Regexp written as a string literal in `match()`/`search()` used to
773
814
  produce a plausible empty report with no signal; `report()` and
@@ -779,7 +820,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
779
820
  fault in an expression: the engine that decided the fault names it. A
780
821
  pattern that arrives from render data keeps RFC 9535 semantics and still
781
822
  matches nothing at render time. Requires padvinder 0.8.
782
-
783
823
  - **Each engine relocates its own diagnostic.** A located error is now a copy
784
824
  made by the engine that raised it (xprsn, sjabloon, or padvinder), so it
785
825
  carries every field that engine puts on a diagnostic — nothing is lost in
@@ -787,9 +827,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
787
827
  passes that engine's own `isDiagnostic`. Host errors are wrapped as plain
788
828
  errors with no diagnostic metadata, exactly as before. Requires xprsn 0.10,
789
829
  sjabloon 0.9, and padvinder 0.5. No report changes what it renders.
790
-
791
- ### Fixed
792
-
793
830
  - **A host error cannot pose as the report's diagnostic.** `report()` rethrows
794
831
  the first definition problem's located engine error; it chose that error by
795
832
  probing for a `code` property, so a host error class that stamps `code` on
@@ -798,9 +835,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
798
835
  `isDiagnostic`; everything else falls back to a plain `SyntaxError` naming
799
836
  the first problem, as before.
800
837
 
801
- ## [0.1.0] - 2026-08-27
838
+ ## 0.1.0
802
839
 
803
- ### Added
840
+ ### Minor Changes
804
841
 
805
842
  - **A compile-once report engine.** You hand it a JSON report definition and
806
843
  data; it plans once and streams render events that every target consumes.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Compiles a report and returns the compiled report. `stream(data)` is the raw eve
63
63
  (e.g. `html()` from `@quario/html`) or your own.
64
64
 
65
65
  A render is async: quario awaits key verification before the target sees its first event. A
66
- malformed target throws synchronously from `render`, before the first event; definition problems
66
+ malformed target throws synchronously from `render`, before the first event. Definition problems
67
67
  throw earlier, at `report()`.
68
68
 
69
69
  The data pre-pass (select, filter, sort, aggregate) runs when you call the renderer, because a
@@ -80,7 +80,7 @@ report.paths; // padvinder's deeply frozen dependency topology for the `data` qu
80
80
 
81
81
  Events arrive in render order: `report-start`, report `header` items, then either the `empty`
82
82
  items or the group/detail walk, then `footer` items, `report-end`. Group instances bracket their
83
- content with `group-start`/`group-end`; a table detail yields `table-start`, one `row` per visible
83
+ content with `group-start`/`group-end`. A table detail yields `table-start`, one `row` per visible
84
84
  row, one `total-row` per emitted total row, and `table-end`.
85
85
 
86
86
  | Event | Carries |
@@ -88,7 +88,7 @@ row, one `total-row` per emitted total row, and `table-end`.
88
88
  | `report-start` | `params`, resolved report `aggregates`, optional `page` band closures, `columns`, `style` (the report default), `marking`, `margin`, `headerHeight`, and the instance's `locale` / `currency` / `timeZone` when set |
89
89
  | `item` | `role`, `path`, `tokens`, optional `style`, `run` |
90
90
  | `image` | `role`, `path`, `bytes`, `format` (`png` \| `jpeg`), `fit`, optional `alt`, `style`, `run` |
91
- | `split-start` | `role`, `slots` (each an optional `width`), optional `style`; one `item` or `image` per slot follows, then `split-end` |
91
+ | `split-start` | `role`, `slots` (each an optional `width`), optional `style`. One `item` or `image` per slot follows, then `split-end` |
92
92
  | `split-end` | - |
93
93
  | `group-start` | `name`, `depth`, `key`, `aggregates`, `path`, optional `break`, `reset`, `columns` |
94
94
  | `group-end` | `name`, `depth` |
@@ -98,19 +98,28 @@ row, one `total-row` per emitted total row, and `table-end`.
98
98
  | `table-end` | - |
99
99
  | `report-end` | - |
100
100
 
101
+ The full field semantics are in the [event stream reference](https://getquario.com/docs/reference/events/).
102
+
101
103
  **Cells carry tokens.** A cell is `{ tokens, style? }`. Each token is either `{ literal }`
102
104
  (static template text, verbatim) or `{ value }` (one interpolation's _pre-format_ value, the
103
105
  expression result before any stringification). This is the typed seam: a cell whose template is `{{ @.amount }}` holds one value token with the
104
106
  number itself, so a spreadsheet consumer writes a real numeric cell. `Total: {{ @.amount }}` mixes
105
107
  a literal and a value, so the cell is text only.
106
108
 
109
+ **A page-number token names itself.** A value token whose interpolation is exactly
110
+ `{{ page.number }}` or `{{ page.total }}` also carries `field`, holding that same string. The
111
+ value is the number the render used for this page. `field` says which page value the token stands
112
+ for, so a target whose own document format numbers pages can write its own live field there
113
+ instead. Anything computed from them carries no `field`.
114
+
107
115
  **Escaping is the consumer's job.** A target that embeds values in markup must escape them at its
108
116
  own edge.
109
117
 
110
- **`report-start.marking`** carries the evaluation wording when the render is unlicensed, or while
111
- verification is still settling. Licensed streams omit it. Targets place the marking; they do not
118
+ **`report-start.marking`** carries the evaluation wording when no license covers the render, or while
119
+ verification is still settling. Licensed streams omit it. Targets place the marking. They do not
112
120
  author its wording. **`columns`** on `report-start` / `group-start` is the declared
113
- page column count when present. `@quario/pdf` and `@quario/html` lay page columns out;
121
+ [page column](https://getquario.com/docs/diving-deeper/bands/#flowing-in-columns) count when
122
+ present. `@quario/pdf` and `@quario/html` lay page columns out.
114
123
  xlsx never will.
115
124
 
116
125
  ### `text(tokens)`
@@ -124,7 +133,7 @@ Exactly one value token holding a finite number, a boolean, or a valid `Date` ke
124
133
  pre-stringify value. Anything else, including a lone null, reports `undefined` and joins to
125
134
  display text. Passing the cell's resolved `format` kind opts into the seam's one coercion: under
126
135
  `"date"`, an RFC 3339 string revives to the `Date` it names. Spreadsheet consumers use this for
127
- real numeric cells;
136
+ real numeric cells.
128
137
  [`@quario/csv`](https://www.npmjs.com/package/@quario/csv) is the short form.
129
138
 
130
139
  ### `styledRuns(tokens)`
@@ -138,12 +147,12 @@ target cannot drift from them.
138
147
  ### `walk(events, handlers)` / `breathe()`
139
148
 
140
149
  `walk` is the delivery driver every official target uses. Pass one render's event iterable and
141
- per-event handlers keyed by type; a missing handler ignores that event. It pulls on demand, hands
142
- the loop back between batches, and delivers the opening event before pulling a second, so a
150
+ per-event handlers keyed by type. A missing handler ignores that event. It pulls on demand,
151
+ returns the loop between batches, and delivers the opening event before pulling a second, so a
143
152
  target can settle `report-start` (page bands, marking) there instead of draining the stream
144
153
  itself.
145
154
 
146
- `breathe()` is that hand-back alone. Await it between batches of a loop you own; `walk` already
155
+ `breathe()` is that return alone. Await it between batches of a loop you own. `walk` already
147
156
  calls it for you.
148
157
 
149
158
  ### Presentation helpers
@@ -154,11 +163,11 @@ cell the same way:
154
163
  - `display(value)` — the scalar rule `text()` joins with: a `Date` as ISO 8601 UTC, nullish as
155
164
  the empty string, everything else `String(value)`.
156
165
  - `format(value, style?, options?)` — presents a token under the cell's resolved `format`
157
- declaration (its kind and modifier, and for `currency` the cell's own code); `undefined` when the kind does not apply, so the caller falls back to
158
- `display()`.
166
+ declaration (its kind and modifier, and for `currency` the cell's own code). It answers
167
+ `undefined` when the kind does not apply, so the caller falls back to `display()`.
159
168
  - `fractionDigits(style?, options?)` — the digit count a resolved `format` declaration presents:
160
169
  the kind's own (two for `number` and `percent`, a currency's minor units for `currency`) unless
161
- the declaration's `digits` overrides it; `undefined` where there is no count.
170
+ the declaration's `digits` overrides it. It answers `undefined` where there is no count.
162
171
  - `currencyOf(style?, options?)` — which code a money cell wears: its own, else the instance's.
163
172
  - `isReportBand(role)` — whether a role names one of the report's own bands rather than a
164
173
  group's.
@@ -167,8 +176,8 @@ cell the same way:
167
176
 
168
177
  ### `validate(schema, functions?)`
169
178
 
170
- Checks a definition without rendering it. Returns every problem as a path-prefixed string; an
171
- empty array means valid.
179
+ Validates a definition without rendering it. It returns every problem as a path-prefixed string.
180
+ An empty array means valid.
172
181
 
173
182
  ```js
174
183
  validate({ data: "$.o[*]", sort: [{ by: "=@.a", dir: "up" }] });
@@ -193,7 +202,7 @@ is not a problem at a lower severity, which is why it has no `diagnostic` — no
193
202
  engine decided. Neither warning below locates into an authored source, so none carries a `source`
194
203
  today. **A warning is never fatal**: a document carrying only warnings compiles and
195
204
  renders, so `report` is `null` on `problems` alone. Two declarations warn today — a `currency` on
196
- a cell whose `format` is not `"currency"`, and a table where every column is sized and the widths
205
+ a cell whose `format` is not `"currency"`, and a table where the author sized every column and the widths
197
206
  total under 100 — and the list is advisory and deliberately incomplete, so a quiet one is not a
198
207
  promise that every declaration will be read. `validate()` returns problems only.
199
208
 
@@ -208,10 +217,10 @@ else console.error(problems[0].path, problems[0].message);
208
217
 
209
218
  True when a caught value is a **located diagnostic**: an error xprsn, sjabloon or padvinder
210
219
  minted — thrown by that engine, or re-thrown by quario with the engine original behind it.
211
- Authentication checks identity. An error that only matches the shape does not pass.
220
+ Authentication tests identity. An error that only matches the shape does not pass.
212
221
 
213
- Being located is not what the guard reads: quario's own verdicts on a document and a registered
214
- function's own throw are located too, and neither is a diagnostic. Errors a report throws
222
+ Location is not what the guard reads: quario's own verdicts on a document and a registered
223
+ function's own throw carry a location too, and neither is a diagnostic. Errors a report throws
215
224
  name the path they failed at — and the offending source, where there is one — while keeping their
216
225
  original type (`SyntaxError`, `TypeError`, `RangeError`). What a diagnostic adds on top is
217
226
  metadata an engine vouches for: `code`, `start`/`end` offsets, and, for a query budget in place
@@ -237,7 +246,7 @@ try {
237
246
  | `run.<name>` | Running accumulator values on the current detail row |
238
247
 
239
248
  Each anchor is a distinct object. Absent reads are `null`, so `x == null` holds for a missing
240
- field; reading _through_ a null base still throws, so use `?.`.
249
+ field. Reading _through_ a null base still throws, so use `?.`.
241
250
 
242
251
  ## Options
243
252
 
@@ -245,18 +254,19 @@ field; reading _through_ a null base still throws, so use `?.`.
245
254
  quario({ query: { maxNodes: 10_000, maxDepth: 64, maxResults: 1_000 } }).report(schema, functions);
246
255
  ```
247
256
 
248
- `query` bounds the JSONPath data selection. Hosts set budgets through this API argument;
249
- definitions do not carry it. Failures are located at `data`, keep their `RangeError` type, and
257
+ `query` bounds the JSONPath data selection. Hosts set budgets through this API argument.
258
+ Definitions do not carry it. Failures point at `data`, keep their `RangeError` type, and
250
259
  carry `code`, `limit`, and `actual`. Every render starts with fresh counters.
251
260
 
252
261
  ## Writing a render target
253
262
 
254
263
  Read the stream through the public API. Do not reach for engine internals. A complete, tested
255
264
  Markdown target lives in the repository at `example/markdown.js` in about 70 lines. The built-in
256
- targets are written against the same public API.
265
+ targets use the same public API.
257
266
 
258
267
  Two rules a target owes its users: escape or neutralize every `value` token at your own edge, and
259
- map the style vocabulary to your own formatting model rather than expecting CSS.
268
+ map the [style vocabulary](https://getquario.com/docs/reference/style-declarations/) to your own
269
+ formatting model rather than expecting CSS.
260
270
 
261
271
  The stream is additive. The walk driver skips any event you register no handler for, so a target
262
272
  that ignores a newer event (for example, `example/markdown.js` has none for `image`) keeps rendering
@@ -272,12 +282,19 @@ package, so it runs under a script policy that omits `unsafe-eval`. The test sui
272
282
  under Node's `--disallow-code-generation-from-strings` flag, a source scan, and a Playwright
273
283
  harness that loads the published files under a strict CSP.
274
284
 
285
+ ## Documentation
286
+
287
+ [The quario documentation](https://getquario.com/docs/) is the reference.
288
+ The [report schema](https://getquario.com/docs/reference/report-schema/) is the normative
289
+ specification of what a report may declare, and
290
+ the [engine reference](https://getquario.com/docs/reference/quario/) is this package's own API.
291
+
275
292
  ## License
276
293
 
277
- Commercial software with readable source. Free, unlimited, watermarked evaluation; per-developer
278
- licenses at [getquario.com](https://getquario.com). See the bundled LICENSE.
294
+ Commercial software with readable source. Evaluation is free, unlimited, and watermarked.
295
+ Per-developer licenses at [getquario.com](https://getquario.com). See the bundled LICENSE.
279
296
 
280
- Pass your license key in the options; it is verified offline:
297
+ Pass your license key in the options. quario verifies it offline:
281
298
 
282
299
  ```js
283
300
  const q = quario({ license: "quario_..." });
package/lib/host.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * What a host got wrong in a target factory's options, named. A target
3
+ * refuses what it does not understand at the factory call, so a typo costs
4
+ * one stack trace at the line the host wrote rather than a wrong document
5
+ * nothing reports (`docs/adr/0072`).
6
+ *
7
+ * Here rather than in a target for the reason `imageError` carries beside it:
8
+ * every target needs it, no two of them may depend on each other, and the
9
+ * engine is what they all already have. Nothing an author writes reaches this
10
+ * module -- these are host API, and no located diagnostic is involved.
11
+ */
12
+
13
+ /** @type {(msg: string) => never} */
14
+ let fail = (msg) => {
15
+ throw TypeError(msg);
16
+ };
17
+
18
+ /**
19
+ * An object a host may have written by hand: not an array, not a function, and
20
+ * not `null`, each of which is a different mistake wearing `typeof "object"`.
21
+ *
22
+ * @type {(value: unknown) => boolean}
23
+ */
24
+ let plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+
26
+ // Own keys only: an inherited enumerable property is the prototype's business,
27
+ // and naming it back at the host would name something they never wrote.
28
+ /** @type {(value: object, keys: readonly string[], at: string) => void} */
29
+ let closed = (value, keys, at) => {
30
+ for (let key of Object.keys(value))
31
+ if (!keys.includes(key)) fail(at + ': unknown option "' + key + '"');
32
+ };
33
+
34
+ /**
35
+ * A target's own options, with its key set closed. Nullish is absence, which
36
+ * is what the engine does with every value a host omits, so a host writing
37
+ * `meta: config.meta ?? null` is not a host making a mistake.
38
+ *
39
+ * @template T
40
+ * @param {T} value The options object, or a nested one.
41
+ * @param {readonly string[]} keys Every key this object may carry.
42
+ * @param {string} at The option path, which prefixes the failure.
43
+ * @returns {T} `value`, unchanged.
44
+ */
45
+ export let hostOptions = (value, keys, at) => {
46
+ if (value == null) return value;
47
+ if (!plain(value)) fail(at + ": expected an object");
48
+ closed(value, keys, at);
49
+ return value;
50
+ };
51
+
52
+ /** The three document properties a host may write. */
53
+ let PROPERTIES = ["title", "author", "subject"];
54
+
55
+ // Nullish is absence here too, one level down: a host writing
56
+ // `{ title: config.title }` over a config that carries no title has made no
57
+ // mistake, and a target skips the property the same way it skips a key that
58
+ // was never written.
59
+ /** @type {(value: any, at: string) => void} */
60
+ let everyString = (value, at) => {
61
+ for (let key of Object.keys(value))
62
+ if (value[key] != null && typeof value[key] !== "string")
63
+ fail(at + "." + key + ": expected a string");
64
+ };
65
+
66
+ /**
67
+ * The `meta` option every document target takes, validated whole: those three
68
+ * properties, each a string, and never a date. What a host cannot write is
69
+ * what keeps a render reproducible -- each of these formats has one part with
70
+ * a slot for a clock, and none of them has a way to fill it.
71
+ *
72
+ * The contract is one thing, so it is checked in one place. What stays a
73
+ * target's own is the name each property carries in its format.
74
+ *
75
+ * Returns a **copy**, so the properties are read once, here. A host that
76
+ * mutates its options object between renders cannot make one configured
77
+ * target write two different documents, and no target has to remember to
78
+ * copy for itself.
79
+ *
80
+ * @param {any} value The `meta` option.
81
+ * @param {string} at The option path, which prefixes the failure.
82
+ * @returns {any} A copy of `value`, or undefined when the host wrote none.
83
+ */
84
+ export let hostMeta = (value, at) => {
85
+ hostOptions(value, PROPERTIES, at);
86
+ if (value == null) return undefined;
87
+ everyString(value, at);
88
+ return { ...value };
89
+ };
package/lib/image.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What the engine reads out of an image item's bytes: which format they are,
3
+ * and how big the picture is. Read, never decoded -- a magic number, a PNG's
4
+ * IHDR and a JPEG's frame header, and nothing here looks at a pixel.
5
+ *
6
+ * Both answers ride on the image event, so no target reads these headers
7
+ * again. That is the whole of `docs/adr/0067`: two targets used to keep a
8
+ * parser each, in different units and with the same bug, and the engine was
9
+ * already in these bytes to name the format.
10
+ */
11
+
12
+ // The two raster formats an image item may carry, as the bytes announce
13
+ // themselves: PNG's 8-byte signature and JPEG's start-of-image marker. Read,
14
+ // never decoded -- the engine answers which format the bytes are, not what
15
+ // they depict.
16
+ let PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
17
+ let JPEG = [0xff, 0xd8, 0xff];
18
+
19
+ /**
20
+ * The image seam: name the format a `source` expression's bytes hold, or null
21
+ * when they are not image bytes at all -- including when they are not bytes.
22
+ * The traversal sniffs once per image event and the answer rides on the event,
23
+ * so no consumer repeats it.
24
+ *
25
+ * @param {any} bytes The value a `source` expression yielded.
26
+ * @returns {"png" | "jpeg" | null} The format, or null when unreadable.
27
+ */
28
+ export function sniff(bytes) {
29
+ if (!(bytes instanceof Uint8Array)) return null;
30
+ if (PNG.every((byte, i) => bytes[i] === byte)) return "png";
31
+ if (JPEG.every((byte, i) => bytes[i] === byte)) return "jpeg";
32
+ return null;
33
+ }
34
+
35
+ /** @type {(bytes: Uint8Array, at: number) => number} */
36
+ let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
37
+
38
+ // A PNG states each dimension in four bytes, so two words make one number.
39
+ /** @type {(bytes: Uint8Array, at: number) => number} */
40
+ let long = (bytes, at) => word(bytes, at) * 0x10000 + word(bytes, at + 2);
41
+
42
+ // The three codes in C0..CF that are not frame headers: DHT, the JPG
43
+ // extension, and DAC. Named as a set so the frame test is one range and one
44
+ // lookup rather than a chain of exclusions.
45
+ let NOT_FRAME = new Set([0xc4, 0xc8, 0xcc]);
46
+
47
+ // The dimensions live in the frame header, which is the first SOFn marker.
48
+ // SOF2 is a frame like SOF0, so a progressive file is read like a baseline one.
49
+ /** @type {(code: number) => boolean} */
50
+ let isFrame = (code) => code >= 0xc0 && code <= 0xcf && !NOT_FRAME.has(code);
51
+
52
+ // A PNG carries the two numbers in its IHDR at a fixed offset.
53
+ /** @type {(bytes: Uint8Array) => { width: number, height: number } } */
54
+ let pngSize = (bytes) => ({ width: long(bytes, 16), height: long(bytes, 20) });
55
+
56
+ /** @type {(bytes: Uint8Array) => { width: number, height: number } } */
57
+ let jpegSize = (bytes) => {
58
+ for (let at = 2; at + 9 < bytes.length; at += 2 + word(bytes, at + 2)) {
59
+ if (bytes[at] !== 0xff) break;
60
+ if (isFrame(bytes[at + 1])) return { width: word(bytes, at + 7), height: word(bytes, at + 5) };
61
+ }
62
+ return { width: 0, height: 0 };
63
+ };
64
+
65
+ // One reader per format `sniff` names.
66
+ /** @type {Record<string, (bytes: Uint8Array) => { width: number, height: number }>} */
67
+ let READERS = { png: pngSize, jpeg: jpegSize };
68
+
69
+ /**
70
+ * The image's own size in pixels, or null when the header does not carry one
71
+ * -- bytes whose magic numbers `sniff` vouched for, truncated before the
72
+ * dimensions. Read, never decoded: a PNG's IHDR and a JPEG's frame header
73
+ * carry the two numbers, and nothing here looks at a pixel.
74
+ *
75
+ * The size rides on the image event beside the format, so every target places
76
+ * a picture from a field rather than parsing the same two headers again, and a
77
+ * file too short to state its size fails once, in the engine, for every target
78
+ * alike (`docs/adr/0067`).
79
+ *
80
+ * @param {Uint8Array} bytes The image file.
81
+ * @param {"png" | "jpeg"} format The format `sniff` named.
82
+ * @returns {{ width: number, height: number } | null} The size, if readable.
83
+ */
84
+ export function dimensions(bytes, format) {
85
+ // Keyed rather than a ternary so the format list lives in one place: a
86
+ // format `sniff` learns later and this table does not would throw here
87
+ // rather than be read silently as the one on the ternary's else branch.
88
+ let size = READERS[format](bytes);
89
+ // A zero is how either reader says "the header stopped before this" -- an
90
+ // absent byte reads as zero, and no image is zero wide.
91
+ return size.width > 0 && size.height > 0 ? size : null;
92
+ }
package/lib/index.d.ts CHANGED
@@ -506,8 +506,20 @@ export interface TokenStyle {
506
506
  /** One static text run of a cell's template, verbatim (sjabloon's literal token). */
507
507
  export type LiteralToken = SjabloonLiteralToken & TokenStyle;
508
508
 
509
- /** One interpolation's pre-stringify value (sjabloon's value token). */
510
- export type ValueToken = SjabloonValueToken & TokenStyle;
509
+ /** Which page value a token *is*, on the two whose interpolation says so. */
510
+ export type PageField = "page.number" | "page.total";
511
+
512
+ /**
513
+ * One interpolation's pre-stringify value (sjabloon's value token).
514
+ *
515
+ * `field` is present where the interpolation is exactly `{{ page.number }}` or
516
+ * `{{ page.total }}`. The value is unchanged -- the number this render saw --
517
+ * and `field` says which page value the token stands for, so a target whose
518
+ * own document format numbers pages writes a live field there instead
519
+ * (SCHEMA.md, "Page bands"). Anything computed from a page value carries no
520
+ * `field`, and a target that paginates itself, or not at all, ignores the key.
521
+ */
522
+ export type ValueToken = SjabloonValueToken & TokenStyle & { field?: PageField };
511
523
 
512
524
  export type Token = LiteralToken | ValueToken;
513
525
 
@@ -629,6 +641,14 @@ export interface ImageEvent {
629
641
  bytes: Uint8Array;
630
642
  /** Sniffed from the bytes' magic numbers, so no consumer repeats it. */
631
643
  format: ImageFormat;
644
+ /**
645
+ * The image's own width in pixels, read from the same header. Bytes too
646
+ * short to state a size never reach a target: that is a render error on
647
+ * every target alike.
648
+ */
649
+ width: number;
650
+ /** The image's own height in pixels, read from the same header. */
651
+ height: number;
632
652
  fit: ImageFit;
633
653
  /** The rendered `alt` template, present when the item declares one. */
634
654
  alt?: Token[];
@@ -900,6 +920,33 @@ export function isDiagnostic(e: unknown): e is QuarioDiagnostic;
900
920
  */
901
921
  export function imageError(path: string | undefined, said: string, cause?: unknown): Error;
902
922
 
923
+ /** Optional document information; never includes dates, so output stays deterministic. */
924
+ export interface QuarioMeta {
925
+ title?: string;
926
+ author?: string;
927
+ subject?: string;
928
+ }
929
+
930
+ /**
931
+ * Close a **target factory's** option key set, for the target itself to call.
932
+ * A target refuses what it does not understand at the factory call, so a
933
+ * host's typo costs one stack trace where they wrote it rather than an option
934
+ * silently lost. Throws a `TypeError` prefixed with `at`.
935
+ *
936
+ * Nullish is absence. Own enumerable keys only, so an inherited property is
937
+ * not reported back as an option the host wrote.
938
+ */
939
+ export function hostOptions<T>(value: T, keys: readonly string[], at: string): T;
940
+
941
+ /**
942
+ * Validate a target's `meta` option whole: `title`, `author` and `subject`,
943
+ * each a string, and nothing else. Every document target takes the same three,
944
+ * so the contract is checked once; the name each property carries inside a
945
+ * given format stays that target's own. Throws a `TypeError` prefixed with
946
+ * `at`.
947
+ */
948
+ export function hostMeta<T extends QuarioMeta | null | undefined>(value: T, at: string): T;
949
+
903
950
  /**
904
951
  * Join a token stream to display text: literals verbatim, values through
905
952
  * `display()`. Re-exported from sjabloon.
package/lib/index.js CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  STYLE_NAMES,
35
35
  } from "./style.js";
36
36
  export { imageError, isDiagnostic } from "./locate.js";
37
+ export { hostMeta, hostOptions } from "./host.js";
37
38
 
38
39
  /** @typedef {import("./scope.js").Scope} Scope */
39
40
 
package/lib/license.js CHANGED
@@ -5,18 +5,18 @@
5
5
  // Verification is entirely offline, and asynchronous only because WebCrypto
6
6
  // is.
7
7
 
8
- // Release date of this version, written into the version commit by
9
- // release-please (`x-release-please-date`). Both constants keep the `let`
10
- // spelling that `scripts/license/keygen.mjs` matches on, though nothing
11
- // reassigns them. A key is valid for every version released inside its window
8
+ // Release date of this version, written into the Version PR's commit by
9
+ // `scripts/stamp-release-date.mjs` (the `release-date` mark). Both constants
10
+ // keep the `let` spelling that `scripts/license/keygen.mjs` matches on,
11
+ // though nothing reassigns them. A key is valid for every version released inside its window
12
12
  // (LICENSE section 7), so validity compares ISO date strings and never reads
13
13
  // a clock — accepted output stays accepted.
14
- let RELEASE = "2026-09-07"; // x-release-please-date
14
+ let RELEASE = "2026-09-15"; // release-date
15
15
  // The verifying half of the signing pair: the 65-byte uncompressed P-256
16
16
  // point, base64url. The private half never enters the repo;
17
17
  // scripts/license/sign.mjs mints keys against it.
18
18
  let PUBKEY =
19
- "BEyXC0sH4pDoL-gGxLTqHguWi38WK2v7OrAfNvK8_4Ou8yLhcKgX5dzCoj1ojj6ez0B1cwMDPfejthqEQ4wZGjM";
19
+ "BLT0Rp5dYp7U6qiaBO5GfEx5lOntJZAbCrbmEfE824dHhdEzfylChU7xUEDj5jnArxSjXaTu8jnBPD8MX5FbNik";
20
20
 
21
21
  // The marking wording, stated once for the whole engine and carried to every
22
22
  // target on `report-start` (SCHEMA.md, "License keys"): the targets own where
package/lib/plan.js CHANGED
@@ -51,7 +51,8 @@ import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
51
51
  import { MATH } from "./math.js";
52
52
  import { REDUCERS, reducerFor } from "./reducers.js";
53
53
  import { aggregateValue, withRow } from "./scope.js";
54
- import { oneValue, opt, sameStyle, sniff } from "./stream.js";
54
+ import { dimensions, sniff } from "./image.js";
55
+ import { oneValue, opt, sameStyle } from "./stream.js";
55
56
  import {
56
57
  checkBorderSides,
57
58
  checkCellStyle,
@@ -482,10 +483,28 @@ let runProp = (expression, path, source) => (scope) => {
482
483
  locate(path, source, error, expression.isDiagnostic(error) && relocateXprsn, 1);
483
484
  }
484
485
  };
486
+ // The [live field](../../../CONTEXT.md#live-field): which page value a token
487
+ // *is*, for a target whose own format numbers pages. sjabloon offers each
488
+ // interpolation's expression source once, while compiling, which is the only
489
+ // place the words the author wrote still exist -- a rendered token carries the
490
+ // number and nothing about where it came from. Reading the source here instead
491
+ // is what hard constraint 2 forbids, and a sentinel bound to `page.number` was
492
+ // ruled out because xprsn's `==` is strict: it would break `=page.number == 1`.
493
+ //
494
+ // One table for the whole compile: the answer depends on the expression alone,
495
+ // never on where the cell sits. A Map rather than a lookup object, because the
496
+ // key is author source and nothing reached through `Object.prototype` is an
497
+ // answer to this question; each name is written once and is its own answer,
498
+ // frozen because every cell that says it shares the one object.
499
+ const PAGE_FIELDS = /** @type {Map<string, { field: import("./index.js").PageField }>} */ (
500
+ new Map(["page.number", "page.total"].map((field) => [field, Object.freeze({ field })]))
501
+ );
502
+ /** @type {(expr: string) => { field: import("./index.js").PageField } | undefined} */
503
+ let pageField = (expr) => PAGE_FIELDS.get(expr);
485
504
  /** @type {(src: string, path: string, source: any, FNS: any, BND: Set<string>) => any} */
486
505
  let compileCell = (src, path, source, FNS, BND) => {
487
506
  try {
488
- return template(src, FNS, { bound: BND });
507
+ return template(src, FNS, { bound: BND, tag: pageField });
489
508
  } catch (error) {
490
509
  locate(path, source, error, isTemplateDiagnostic(error) && relocateTemplate);
491
510
  }
@@ -1326,28 +1345,35 @@ let nodes = (
1326
1345
  let altOf = (def, path) => (def.alt != null ? textValue(def.alt, path + ".alt") : null);
1327
1346
  /** @type {(alt: any, scope: Scope) => any} */
1328
1347
  let imageAlt = (alt, scope) => alt && alt(scope);
1348
+ // The two places `locate` originates an error instead of re-throwing a
1349
+ // caught one: each fault is quario's own verdict on the bytes, so it carries
1350
+ // the location every other render error does and, having no engine
1351
+ // diagnostic behind it, stays out of `isDiagnostic`.
1352
+ /** @type {(fields: any, message: string) => never} */
1353
+ let badImage = (fields, message) =>
1354
+ locate(fields.path + ".source", fields.sourceSpec, TypeError(message));
1329
1355
  /** @type {(fields: any, scope: Scope, bytes: any) => any} */
1330
1356
  let imageEvent = (fields, scope, bytes) => {
1331
- // The bytes stay bytes. Sniffing reads their magic numbers -- it never
1332
- // decodes the image, and nothing here turns them into a string -- and
1333
- // the answer rides on the event so no target sniffs a second time.
1357
+ // The bytes stay bytes. Reading them touches the magic numbers and the
1358
+ // header the size lives in -- never a pixel, and nothing here turns them
1359
+ // into a string -- and both answers ride on the event so no target reads
1360
+ // the same header a second time.
1334
1361
  let format = sniff(bytes);
1335
- // The one place `locate` originates an error instead of re-throwing a
1336
- // caught one: the fault is quario's own verdict on the bytes, so it
1337
- // carries the location every other render error does and, having no
1338
- // engine diagnostic behind it, stays out of `isDiagnostic`.
1339
- if (!format)
1340
- locate(
1341
- fields.path + ".source",
1342
- fields.sourceSpec,
1343
- TypeError("expected a Uint8Array of PNG or JPEG bytes"),
1344
- );
1362
+ if (!format) badImage(fields, "expected a Uint8Array of PNG or JPEG bytes");
1363
+ let size = dimensions(bytes, format);
1364
+ // The one thing the engine reads past the magic numbers, and so the one
1365
+ // header failure every target shares: a file that cannot state its size
1366
+ // can be placed by none of them (`docs/adr/0067`). Past the size the
1367
+ // engine still vouches for nothing.
1368
+ if (!size) badImage(fields, "could not read the image's size from its bytes");
1345
1369
  return opt(
1346
1370
  {
1347
1371
  type: "image",
1348
1372
  role: fields.role,
1349
1373
  bytes,
1350
1374
  format,
1375
+ width: size.width,
1376
+ height: size.height,
1351
1377
  fit: fields.fit,
1352
1378
  path: fields.path,
1353
1379
  },
package/lib/stream.js CHANGED
@@ -207,29 +207,6 @@ export function isReportBand(role) {
207
207
  return REPORT_BANDS.has(role);
208
208
  }
209
209
 
210
- // The two raster formats an image item may carry, as the bytes announce
211
- // themselves: PNG's 8-byte signature and JPEG's start-of-image marker. Read,
212
- // never decoded -- the engine answers which format the bytes are, not what
213
- // they depict.
214
- let PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
215
- let JPEG = [0xff, 0xd8, 0xff];
216
-
217
- /**
218
- * The image seam: name the format a `source` expression's bytes hold, or null
219
- * when they are not image bytes at all -- including when they are not bytes.
220
- * The traversal sniffs once per image event and the answer rides on the event,
221
- * so no consumer repeats it.
222
- *
223
- * @param {any} bytes The value a `source` expression yielded.
224
- * @returns {"png" | "jpeg" | null} The format, or null when unreadable.
225
- */
226
- export function sniff(bytes) {
227
- if (!(bytes instanceof Uint8Array)) return null;
228
- if (PNG.every((byte, i) => bytes[i] === byte)) return "png";
229
- if (JPEG.every((byte, i) => bytes[i] === byte)) return "jpeg";
230
- return null;
231
- }
232
-
233
210
  // The cooperative hand-back's plumbing. A timer is the slowest way a runtime will
234
211
  // hand the loop back and, until this landed, the only one quario used: measured
235
212
  // on an M-series Mac it costs ~1.20ms per hand-back against ~0.015ms for
package/lib/style.js CHANGED
@@ -375,7 +375,8 @@ export let checkRunStyle = narrowed(RUN_STYLES, "a styled run");
375
375
  export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
376
376
  // `valign` is legal exactly where a box has height it did not ask for
377
377
  // (CONTEXT.md "Box": slack). A slot's box is the split's height, so a slot --
378
- // text or image -- reads it; a band image's box is its own, so it does not.
378
+ // text or image -- reads it; a band image's box is exactly its picture's
379
+ // height, so it does not.
379
380
  // That is the one name an image accepts in a slot and nowhere else, the same
380
381
  // shape of reason `width` reaches an item only there (docs/adr/0029).
381
382
  let SLOT_IMAGE_STYLES = [...IMAGE_STYLES, "valign"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "padvinder": "^0.9.0",
37
- "sjabloon": "^0.12.0",
37
+ "sjabloon": "^0.13.0",
38
38
  "xprsn": "^0.11.1"
39
39
  },
40
40
  "devDependencies": {