quario 0.7.0 → 0.8.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,61 @@
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.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **BREAKING: an image whose header does not state its size now fails on every
8
+ target.** A PNG or JPEG truncated before its dimensions used to render on the
9
+ HTML and CSV targets — the browser coped, and CSV has no picture to place —
10
+ while failing on the PDF and XLSX targets, which have to know how big a
11
+ picture is before they can put it anywhere. One file, two verdicts, depending
12
+ on where the report was sent.
13
+
14
+ It is now a single render error, raised where every other verdict on image
15
+ bytes is raised and naming the item's `source` the same way — for example
16
+ `header[0].source [=$.input.logo]: could not read the image's size from its bytes`.
17
+ A report that renders as a PDF today is unaffected: those bytes already
18
+ failed there. What changes is a report that renders as HTML today on bytes no other target
19
+ would accept: it now fails, and the file it names is one no target could ever
20
+ have drawn correctly.
21
+
22
+ Nothing further has changed about what the engine promises: a file whose size
23
+ reads but whose pixel data is corrupt is still nobody's guarantee, and each
24
+ target does whatever its own machinery does with it.
25
+
26
+ - **The image event carries the picture's size.** `width` and `height`, in
27
+ pixels, read from the same header the format is sniffed from. A target — the
28
+ built-in ones, or your own — places a picture from two fields instead of
29
+ parsing a PNG's IHDR and a JPEG's frame header for itself.
30
+ - **A page-number token says which page value it is.** A value token whose
31
+ interpolation is exactly `{{ page.number }}` or `{{ page.total }}` now carries
32
+ `field` on the event stream, holding that same string. The `value` is
33
+ unchanged — the number the page band was called with — so nothing renders
34
+ differently; `field` tells a target whose own document format numbers pages
35
+ that it may write its own live field there instead of the number this render
36
+ saw. Anything computed from them (`{{ page.number + 1 }}`, `{{ pad(page.total)
37
+ }}`) is an ordinary value and carries no `field`, so write the bare token
38
+ wherever a live number matters.
39
+
40
+ ### Patch Changes
41
+
42
+ - **A PNG wider or taller than 65535 pixels is no longer misplaced.** Its
43
+ dimensions were read from the low half of each of IHDR's four-byte fields, so
44
+ a 70000-pixel panorama measured 4464 pixels and was laid out at that size,
45
+ confidently and silently.
46
+ - **Every published README says where the documentation is.** Each package now
47
+ carries a Documentation section pointing at the reference, at the report schema
48
+ that normatively specifies what a report may declare, and at the package's own
49
+ API. The paragraphs that used to end on an unstated contract — the event
50
+ stream's field semantics, the style vocabulary, page columns, the Content
51
+ Security Policy a fragment with images needs, the formula mangling, and each
52
+ target's own contract — link the page that states it. Every link is an absolute
53
+ URL, so it resolves from the npm package page as readily as from an installed
54
+ copy.
55
+
56
+ ## 0.7.0
57
+
58
+ ### Minor Changes
13
59
 
14
60
  - **Styled runs.** Bold a word, colour a phrase, or format one value inside a
15
61
  sentence, and have it survive into the PDF and the spreadsheet — not only the
@@ -47,8 +93,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
47
93
  seen in the definition alone, without data, so a quiet `warnings` list is not
48
94
  a promise that every declaration will be read.
49
95
 
50
- ### Changed
51
-
52
96
  - **A `format` declaration now needs one value to speak about, and a cell can
53
97
  give it one.** Previously a `format` on a cell reached every interpolation in
54
98
  it and skipped the literal text between them, which meant
@@ -78,8 +122,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
78
122
  twice. `plan()` reports the cells this affects wherever it can see them
79
123
  without data.
80
124
 
81
- ### Fixed
82
-
83
125
  - **`round`, `floor` and `ceil` reuse their formatters instead of rebuilding
84
126
  one per call.** The three reach the decimal you wrote through `Intl`, and
85
127
  each call built a fresh formatter to do it — so a detail column of
@@ -88,7 +130,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
130
  Every answer is the value it was before: the rounding mode and the digit
89
131
  count are both part of what a reused formatter is found by, so no call can
90
132
  be handed one built for another.
91
-
92
133
  - **`round`, `floor` and `ceil` name themselves when they refuse an `n`.** All
93
134
  three threw `n must be a number from 0 to 100`, so a cell calling more than
94
135
  one was told how it failed and never which call; the message now opens with
@@ -112,7 +153,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
153
  the same and are not diagnostics, so a host following that example rethrew
113
154
  report faults as its own. Nothing about the guard changes; it never behaved
114
155
  the way the page described.
115
-
116
156
  - **A misused inline reducer says what it is, instead of how it folds.**
117
157
  `{{ min(1, 2) }}` — the two-argument scalar `min` quario does not have —
118
158
  failed at the cell with `(rows || []).map is not a function`, the fold's own
@@ -130,9 +170,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
130
170
  mistake: `sum(@.lines)` on a row carrying no lines is `0`, exactly as an
131
171
  empty array gives.
132
172
 
133
- ## [0.6.0] - 2026-09-07
173
+ ## 0.6.0
134
174
 
135
- ### Added
175
+ ### Minor Changes
136
176
 
137
177
  - **`imageError(path, said, cause)`**, the mint a render target raises an image
138
178
  failure through. The engine vouches for an image's magic numbers and no
@@ -141,7 +181,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
181
  that asked for the bytes and keeps the failing class and message behind it,
142
182
  so one report reads the same however it is rendered. It is not a diagnostic:
143
183
  `isDiagnostic` does not answer for one.
144
-
145
184
  - **Formatted cells render dramatically faster.** The engine built a fresh
146
185
  `Intl` formatter for every presented cell and now keeps them, keyed on
147
186
  everything a formatter is built from — the kind, the locale, the digit count,
@@ -197,14 +236,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
197
236
 
198
237
  - **`sort` is stable**: rows whose keys compare equal keep the order they
199
238
  arrived in. The engine has always sorted this way; the promise is new.
200
-
201
239
  - `currencyOf(style, options)` joins the package's exports beside `format()`.
202
240
  It answers which currency code a cell wears — its own when it declares one,
203
241
  else the instance default — for a target that needs the code itself rather
204
242
  than presented text. A cell that declared a code the engine could not accept
205
243
  carries `style.currency` as `null`, so `style.currency ?? yourDefault` is the
206
244
  wrong spelling and this helper is the right one.
207
-
208
245
  - **A cell may name its own currency.** `currency` is a new style declaration
209
246
  beside `format` — a three-letter ISO 4217 code saying what a money cell is
210
247
  denominated in.
@@ -257,14 +294,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
257
294
  - `split-start` events carry `path`, the split definition's schema path, as
258
295
  `item` and `image` events already do. A consumer can now name the definition
259
296
  behind a split without tracking position.
260
-
261
297
  - `fractionDigits(kind, options)` is exported beside `format()`: how many
262
298
  fraction digits a kind presents, or nothing for a kind that carries no
263
299
  count. It is the table the official targets read, and it is public so a
264
300
  consumer writing its own target presents the same digits they do.
265
-
266
- ### Changed
267
-
268
301
  - **A `date` cell with no `form` now presents `medium`, not the runtime's own
269
302
  default.** A `date` that names no form used to render whatever the platform's
270
303
  default date format was — under `en-US`, `8/14/2026` — which matched none of
@@ -285,18 +318,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
285
318
  to write no number format. Read `style.format.kind` where you read
286
319
  `style.format`, and take the digit count off the declaration rather than
287
320
  computing one.
288
-
289
321
  - **`fractionDigits` takes the whole style.** Its first argument was the kind
290
322
  and is now the style, matching `format()` and `currencyOf()`.
291
323
  `fractionDigits("number")` becomes
292
324
  `fractionDigits({ format: "number" })`. Targets no longer need it at all —
293
325
  the count rides on the declaration the stream carries.
294
-
295
326
  - **`typed()`'s second argument is the resolved declaration.** Pass
296
327
  `style.format` as it arrives on the stream; it reads the kind from the
297
328
  object. The shorthand string is still accepted, and omitting the argument is
298
329
  unchanged.
299
-
300
330
  - **A rejected box value is now dropped from the resolved style rather than
301
331
  carried on it.** A `border*` or `padding*` declaration written as an `=`
302
332
  expression used to cross the render stream with whatever the expression
@@ -435,23 +465,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
435
465
  - A `currency` code that is not a readable currency now presents nothing in
436
466
  every target, rather than the worksheet inventing a number format from it
437
467
  while the other targets fell back to the unformatted value.
438
-
439
- ### Fixed
440
-
441
468
  - **A split slot is typed with the declarations the traversal accepts.**
442
469
  `SplitSlot` reused the band items' style types, so TypeScript refused
443
470
  `valign` on an image slot — legal there, and only there — and admitted
444
471
  `spaceBefore`/`spaceAfter` on a slot, which the engine rejects. Two new
445
472
  exported types, `SlotStyleDeclarations` and `SlotImageStyleDeclarations`,
446
473
  now say what a slot may declare.
447
-
448
474
  - **A page band closure is typed as what it returns.** `PageBandRenderers`
449
475
  declared `header`/`footer` as returning item and image events only, while a
450
476
  split in a page band has always come back as its bracket — `split-start`,
451
477
  one event per slot, `split-end`. The closures now return `PageBandEvent[]`,
452
478
  a new exported union naming all four, so a TypeScript host reading a page
453
479
  band no longer types `role` onto a `split-end` that carries none.
454
-
455
480
  - **A computed style value the schema does not accept now leaves the layer
456
481
  below in place**, which is what the schema has always said it does —
457
482
  "contributes nothing, same as omit". The name is dropped from the resolved
@@ -486,7 +511,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
486
511
  the cell falls back to display text, as the documented rule says any
487
512
  unrecognised kind does. Before, such a cell could render `[object Undefined]`.
488
513
  A literal was never affected: it is checked against the four kinds.
489
-
490
514
  - **A pinned report header now refuses a lead on every band that can sit under
491
515
  its box, and only where the document settles which one that is.** When
492
516
  `header` declares a `height`, an authored `spaceBefore` on the first item of
@@ -516,9 +540,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
516
540
  than reporting against the report header, which is a band this rule never
517
541
  covers.
518
542
 
519
- ## [0.5.0] - 2026-09-05
543
+ ## 0.5.0
520
544
 
521
- ### Added
545
+ ### Minor Changes
522
546
 
523
547
  - **`STYLE_NAMES`**, every name in the style vocabulary as a read-only list,
524
548
  in the order the specification's table lists them. A tool that offers the
@@ -540,9 +564,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
540
564
  on a band image, whose boxes have no such slack. A row's or a split's layers
541
565
  under its cells' or slots' own. Undeclared is not a declaration: each target
542
566
  keeps its own default.
543
-
544
- ### Changed
545
-
546
567
  - **A table row's `style` now resolves onto that row's cells**, the box
547
568
  included, instead of meaning something different on every output. Before,
548
569
  padding and border on `detail.header`, `detail.row` or a total row's `style`
@@ -565,9 +586,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
565
586
  carries only what layers by ordinary means, and a row whose whole block was
566
587
  box carries no `style` at all.
567
588
 
568
- ## [0.4.0] - 2026-09-03
589
+ ## 0.4.0
569
590
 
570
- ### Changed
591
+ ### Minor Changes
571
592
 
572
593
  - **`format: "date"` now reads a date string, not only a `Date`.** A JSON
573
594
  document has no date type, so the kind could not be reached from parsed
@@ -591,9 +612,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
591
612
  - **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
592
613
  opts into the same revival, for consumers that write typed cells. The
593
614
  one-argument call is unchanged.
594
-
595
- ### Fixed
596
-
597
615
  - **The TypeScript declarations accept the box.** The per-side `padding*` and
598
616
  `border*` names, and the table's `detail.header`, were validated by the
599
617
  engine from 0.3.0 but were missing from the shipped declarations, so a
@@ -608,9 +626,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
608
626
  about rendering changes: a report that compiled through a cast produces the
609
627
  same output without one.
610
628
 
611
- ## [0.3.0] - 2026-09-02
629
+ ## 0.3.0
612
630
 
613
- ### Added
631
+ ### Minor Changes
614
632
 
615
633
  - **`format` is a closed style name.** `number` / `currency` / `percent` /
616
634
  `date` on text items, column cells, headers, and totals. Not images, not
@@ -618,19 +636,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
618
636
  live on `quario({ locale, currency, timeZone })`. The public `format()`
619
637
  helper is how targets present a kind; a kind on the wrong type contributes
620
638
  nothing.
621
-
622
639
  - **The report header may pin a `height` from the page top.** Dual-shaped
623
640
  like `detail`: an item array, or `{ height, items }`. `page.margin` is a
624
641
  document field (one number, all four sides), required with `height` and
625
642
  legal without. Authored `spaceBefore` on the first occupying item of the
626
643
  next band is refused.
627
-
628
644
  - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
629
645
  before or after a band item, in points, including band images and splits as
630
646
  band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
631
647
  and split slots refuse the names. `spaceBefore` drops at a fresh body page
632
648
  or strip top; page-band items keep it. Leading and inset stay cut.
633
-
634
649
  - **Per-side padding and border on the closed style vocabulary.**
635
650
  `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
636
651
  `border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
@@ -640,18 +655,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
640
655
  than a solid black stroke. The names are legal wherever `background` is,
641
656
  including images, plus `row.style`. The report default still takes only
642
657
  `family` and `size`. Column `%` widths are border-box.
643
-
644
658
  - **`detail.header` is the header-row box.** `{ style }` only, a different
645
659
  path from `columns[i].header`. It crosses the seam as `table-start.style`.
646
-
647
- ### Changed
648
-
649
660
  - **A table total is N rows.** Before, `total` was one row: a flat cell
650
661
  array or `{ style, cells }`. After, it is absent or a non-empty array of
651
662
  `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
652
663
  `total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
653
664
  The stream yields one `total-row` per emitted row.
654
-
655
665
  - **A visible text item occupies a line at its own `size`.** Empty display
656
666
  used to take the report default's leading in PDF and collapse in HTML;
657
667
  `"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
@@ -660,9 +670,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
660
670
  table cells stay contentless for height. This is not a spacing primitive;
661
671
  `visible: false` is still how an item leaves the layout.
662
672
 
663
- ## [0.2.0] - 2026-09-01
673
+ ## 0.2.0
664
674
 
665
- ### Added
675
+ ### Minor Changes
666
676
 
667
677
  - **A report default: one `style` block for the whole document.** A top-level
668
678
  `style` beside `header`/`detail`/`footer` states the typeface a report is set
@@ -672,21 +682,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
672
682
  a text declaration on an image item is. Values are literals or `=`
673
683
  expressions like any other style block's, resolved once per render in report
674
684
  scope.
675
-
676
685
  - **`report-start` carries the resolved report default as `style`.** A
677
686
  report-level fact, never merged into an item's own `style`: an event's
678
687
  `style` stays what the author wrote on that node, and a consumer composes the
679
688
  default itself, once — under its own band-role defaults and under every
680
689
  event's own style. Absent when the report declares none, so a consumer
681
690
  written before this field renders in its own baseline exactly as it did.
682
-
683
691
  - **An `uppercase` style declaration.** A boolean beside `bold` and `italic`,
684
692
  literal or an `=` expression, for the capitalised column labels business
685
693
  forms are usually set with. It is capitals, not small caps — real small caps
686
694
  need a font feature the PDF target's built-in faces cannot supply, so the
687
695
  declaration promises only what every target can draw. Image items keep
688
696
  refusing it, as they refuse every text declaration.
689
-
690
697
  - **A `split` item places values across the line instead of down the band.**
691
698
  The invoice header's "seller left, customer right", which a band could not
692
699
  say before. `{ "type": "split", "slots": [...] }` takes two or more slots,
@@ -697,13 +704,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
697
704
  a slot that renders nothing keeps its width, so a line's geometry never
698
705
  moves with the data. Splits may appear in every item array except table and
699
706
  total cells.
700
-
701
707
  - **`split-start` / `split-end` bracket a split's slots on the event stream.**
702
708
  `split-start` carries the slot geometry, then one ordinary `item` or `image`
703
709
  event per slot in order, then `split-end`. Existing consumers need no
704
710
  change: the walk driver's missing-handler rule means a target that ignores
705
711
  the bracket still receives the slot items and renders them stacked.
706
-
707
712
  - **`quario().plan(schema, funcs?)` hands the whole traversal over at once.**
708
713
  Returns `{ report, problems, anchors }` from one descent: the compiled
709
714
  report (`null` while the document has problems), every problem as
@@ -727,12 +732,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
727
732
  display rule behind the token join, re-exported beside `text()` so a stream
728
733
  consumer that stringifies token values itself renders exactly what the
729
734
  official targets render — Dates included.
730
-
731
735
  - **`maxDepth: Infinity` opts a query budget out.** The data query's traversal
732
736
  budgets accept an explicit `Infinity` per key for "this budget, unbounded".
733
737
  The 500-deep default is unchanged — it is now padvinder's own, applied for
734
738
  every consumer rather than added by quario at the seam.
735
-
736
739
  - **Located data-query errors carry padvinder's code and span.** A `data`
737
740
  query that does not parse now surfaces with padvinder's `code` and
738
741
  `start`/`end` offsets into the query you wrote — filter faults included —
@@ -742,23 +745,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
742
745
  `PADVINDER_UNKNOWN_FUNCTION`, or `PADVINDER_SYNTAX` for a path character or
743
746
  filter body that is open-endedly not a query. Traversal budgets exceeded at
744
747
  render time keep `limit`/`actual` and carry no span.
745
-
746
- ### Changed
747
-
748
748
  - **Cells render straight over the engine scope chain.** A text cell no longer
749
749
  allocates a wrapper scope and an anchor pair per cell per row — quario's
750
750
  chain already binds `$` at the render base and `@` on the detail row, and
751
751
  sjabloon now renders over it as-is. A 4-column stream over a million rows
752
752
  went from 1.7s to 0.5s. No report changes what it renders. Requires
753
753
  sjabloon 0.11.
754
-
755
754
  - **A compiled report's `functions` carry signatures.** Each entry is now
756
755
  `{ name, arity, doc? }` instead of a bare name — `arity` from the function's
757
756
  declared parameter count (or its own numeric `arity` where rest parameters
758
757
  mislead `length`), `doc` from an own `doc` string when it carries one — in
759
758
  the same call-first-seen order. `names` is unchanged. Requires xprsn 0.11
760
759
  and sjabloon 0.11.
761
-
762
760
  - **A bare `Date` renders as ISO 8601 UTC, the same on every machine.**
763
761
  Display text for a `Date` value was `String(date)`, which bakes the
764
762
  rendering host's timezone and locale into the output — so one report
@@ -767,7 +765,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
767
765
  rule; an invalid `Date` keeps its deterministic `Invalid Date` text.
768
766
  Reports that want a formatted date keep using a registered function,
769
767
  exactly as before.
770
-
771
768
  - **A bad literal pattern in the data query is a definition error.** A typo'd
772
769
  I-Regexp written as a string literal in `match()`/`search()` used to
773
770
  produce a plausible empty report with no signal; `report()` and
@@ -779,7 +776,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
779
776
  fault in an expression: the engine that decided the fault names it. A
780
777
  pattern that arrives from render data keeps RFC 9535 semantics and still
781
778
  matches nothing at render time. Requires padvinder 0.8.
782
-
783
779
  - **Each engine relocates its own diagnostic.** A located error is now a copy
784
780
  made by the engine that raised it (xprsn, sjabloon, or padvinder), so it
785
781
  carries every field that engine puts on a diagnostic — nothing is lost in
@@ -787,9 +783,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
787
783
  passes that engine's own `isDiagnostic`. Host errors are wrapped as plain
788
784
  errors with no diagnostic metadata, exactly as before. Requires xprsn 0.10,
789
785
  sjabloon 0.9, and padvinder 0.5. No report changes what it renders.
790
-
791
- ### Fixed
792
-
793
786
  - **A host error cannot pose as the report's diagnostic.** `report()` rethrows
794
787
  the first definition problem's located engine error; it chose that error by
795
788
  probing for a `code` property, so a host error class that stamps `code` on
@@ -798,9 +791,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
798
791
  `isDiagnostic`; everything else falls back to a plain `SyntaxError` naming
799
792
  the first problem, as before.
800
793
 
801
- ## [0.1.0] - 2026-08-27
794
+ ## 0.1.0
802
795
 
803
- ### Added
796
+ ### Minor Changes
804
797
 
805
798
  - **A compile-once report engine.** You hand it a JSON report definition and
806
799
  data; it plans once and streams render events that every target consumes.
package/README.md CHANGED
@@ -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 this page was rendered with; `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
118
  **`report-start.marking`** carries the evaluation wording when the render is unlicensed, or while
111
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)`
@@ -256,7 +265,8 @@ Markdown target lives in the repository at `example/markdown.js` in about 70 lin
256
265
  targets are written against 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,6 +282,13 @@ 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
294
  Commercial software with readable source. Free, unlimited, watermarked evaluation; per-developer
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[];
package/lib/license.js CHANGED
@@ -5,13 +5,13 @@
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-14"; // 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.
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.8.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": {